diff --git a/docs/changelog/2026-06-11-servers-search-null-fix.md b/docs/changelog/2026-06-11-servers-search-null-fix.md new file mode 100644 index 00000000..9b0452cd --- /dev/null +++ b/docs/changelog/2026-06-11-servers-search-null-fix.md @@ -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。 diff --git a/frontend/src/composables/useServerPagination.ts b/frontend/src/composables/useServerPagination.ts index 7576d5f9..e1faf8b4 100644 --- a/frontend/src/composables/useServerPagination.ts +++ b/frontend/src/composables/useServerPagination.ts @@ -30,7 +30,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) { const sortBy = ref([]) function listQueryParams(): Record { - const q = search.value.trim() + const q = String(search.value ?? '').trim() // 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到 const applyPathFilter = targetPathUnset !== undefined && !q return { diff --git a/frontend/src/composables/useServerSearchHistory.ts b/frontend/src/composables/useServerSearchHistory.ts index 4a093d6c..40a95cea 100644 --- a/frontend/src/composables/useServerSearchHistory.ts +++ b/frontend/src/composables/useServerSearchHistory.ts @@ -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('/servers/search-history', { query }) + const res = await http.put('/servers/search-history', { + query: normalized, + }) items.value = res.history || [] } catch { // non-blocking diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index e6959a2e..3895e8bc 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -762,6 +762,7 @@ const { items: serverSearchHistory, record: recordServerSearch, bootstrap: boots let searchDebounceTimer: ReturnType 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, diff --git a/server/utils/validation_errors_zh.py b/server/utils/validation_errors_zh.py index b3d7dc53..ff99583e 100644 --- a/server/utils/validation_errors_zh.py +++ b/server/utils/validation_errors_zh.py @@ -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) diff --git a/tests/test_validation_errors_zh.py b/tests/test_validation_errors_zh.py index a62e4309..9fd8736f 100644 --- a/tests/test_validation_errors_zh.py +++ b/tests/test_validation_errors_zh.py @@ -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