fix: combobox 清空 null 导致服务器列表加载失败

v-combobox 清空时 search 为 null 使 trim() 抛错;422 响应剥离不可序列化的 ctx 避免 500。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 17:52:49 +08:00
parent c69e45a571
commit af912662de
6 changed files with 59 additions and 7 deletions
@@ -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
+3 -2
View File
@@ -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,
+3 -2
View File
@@ -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)
+15
View File
@@ -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