fix: combobox 清空 null 导致服务器列表加载失败
v-combobox 清空时 search 为 null 使 trim() 抛错;422 响应剥离不可序列化的 ctx 避免 500。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Changelog — 修复 Servers 搜索 combobox null 导致列表加载失败
|
||||
|
||||
**日期**:2026-06-11
|
||||
|
||||
## 摘要
|
||||
|
||||
`v-combobox` 清空时 `search` 为 `null`,`trim()` 抛错触发「加载服务器列表失败」;同时 `{ query: null }` 使 422 响应因 `ctx` 含 `ValueError` 无法 JSON 序列化而变成 500。
|
||||
|
||||
## 动机
|
||||
|
||||
生产日志:`PUT /api/servers/search-history` 输入 `query: null` → 422 处理崩溃;前端 `search.value.trim()` 在 null 时异常。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useServerPagination.ts`
|
||||
- `frontend/src/composables/useServerSearchHistory.ts`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `server/utils/validation_errors_zh.py`
|
||||
- `tests/test_validation_errors_zh.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需部署前后端;无 DB 变更
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_validation_errors_zh.py tests/test_server_search_history.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
|
||||
Servers 页清空搜索框 → 列表正常加载,无错误 toast。
|
||||
@@ -30,7 +30,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
|
||||
const sortBy = ref<TableSortItem[]>([])
|
||||
|
||||
function listQueryParams(): Record<string, unknown> {
|
||||
const q = search.value.trim()
|
||||
const q = String(search.value ?? '').trim()
|
||||
// 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到
|
||||
const applyPathFilter = targetPathUnset !== undefined && !q
|
||||
return {
|
||||
|
||||
@@ -74,9 +74,12 @@ export function useServerSearchHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function record(query: string) {
|
||||
async function record(query: string | null | undefined) {
|
||||
const normalized = String(query ?? '').trim()
|
||||
try {
|
||||
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', { query })
|
||||
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
|
||||
query: normalized,
|
||||
})
|
||||
items.value = res.history || []
|
||||
} catch {
|
||||
// non-blocking
|
||||
|
||||
@@ -762,6 +762,7 @@ const { items: serverSearchHistory, record: recordServerSearch, bootstrap: boots
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>
|
||||
function onSearch() {
|
||||
if (search.value == null) search.value = ''
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
void recordServerSearch(search.value)
|
||||
@@ -1046,7 +1047,7 @@ watch(search, () => {
|
||||
watch(
|
||||
() => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const,
|
||||
([q, mainTotal, unsetTotal, pendingCount, isLoading]) => {
|
||||
if (!q.trim() || isLoading) return
|
||||
if (!String(q ?? '').trim() || isLoading) return
|
||||
if (mainTotal > 0 || unsetTotal > 0) return
|
||||
if (pendingCount <= 0) return
|
||||
void nextTick(() => {
|
||||
@@ -1102,7 +1103,7 @@ function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
async function loadPendingServers(silent = false) {
|
||||
if (!silent) pendingLoading.value = true
|
||||
try {
|
||||
const q = search.value.trim()
|
||||
const q = String(search.value ?? '').trim()
|
||||
const res = await http.get<{ items: PendingServerItem[]; total: number }>(
|
||||
'/servers/pending',
|
||||
q ? { search: q } : undefined,
|
||||
|
||||
@@ -133,11 +133,12 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
|
||||
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
|
||||
translated: list[dict[str, Any]] = []
|
||||
for err in errors:
|
||||
item = dict(err)
|
||||
ctx = err.get("ctx") if isinstance(err.get("ctx"), dict) else None
|
||||
item = {k: v for k, v in err.items() if k != "ctx"}
|
||||
item["msg"] = translate_validation_msg(
|
||||
str(err.get("msg", "")),
|
||||
str(err.get("type", "")),
|
||||
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
|
||||
ctx,
|
||||
)
|
||||
item["loc_zh"] = translate_loc_zh(err.get("loc"))
|
||||
translated.append(item)
|
||||
|
||||
@@ -62,6 +62,21 @@ def test_translate_list_too_long_ctx():
|
||||
assert msg == "最多 50 项,当前 101 项"
|
||||
|
||||
|
||||
def test_translate_strips_non_json_ctx():
|
||||
"""Pydantic value_error ctx may contain Exception objects — must not leak into JSON."""
|
||||
out = translate_validation_errors([
|
||||
{
|
||||
"type": "value_error",
|
||||
"loc": ("body",),
|
||||
"msg": "Value error, query 或 history 至少提供一个",
|
||||
"input": {"query": None},
|
||||
"ctx": {"error": ValueError("query 或 history 至少提供一个")},
|
||||
},
|
||||
])
|
||||
assert "ctx" not in out[0]
|
||||
assert out[0]["msg"] == "query 或 history 至少提供一个"
|
||||
|
||||
|
||||
def test_batch_agent_accepts_101_ids():
|
||||
from server.api.schemas import BatchAgentAction
|
||||
|
||||
|
||||
Reference in New Issue
Block a user