Push async submit, Chinese 422 field labels, hide server CSV export.
Return sync/files immediately with background runner; show loc_zh in validation errors; remove servers toolbar CSV export button.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 审计 — 推送后台化 + 422 中文化 + 隐藏 CSV 导出(2026-06-08)
|
||||
|
||||
**Changelog**:
|
||||
- `docs/changelog/2026-06-08-push-async-background.md`
|
||||
- `docs/changelog/2026-06-08-validation-error-loc-zh.md`
|
||||
- `docs/changelog/2026-06-08-servers-hide-csv-export.md`
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 状态 |
|
||||
|------|------|
|
||||
| `server/background/sync_push_runner.py` | ☑ |
|
||||
| `server/api/sync_v2.py` | ☑ |
|
||||
| `server/utils/validation_errors_zh.py` | ☑ |
|
||||
| `frontend/src/composables/push/usePushProgress.ts` | ☑ |
|
||||
| `frontend/src/components/push/PushToolbar.vue` | ☑ |
|
||||
| `frontend/src/pages/PushPage.vue` | ☑ |
|
||||
| `frontend/src/utils/validationErrorFormat.ts` | ☑ |
|
||||
| `frontend/src/api/index.ts` | ☑ |
|
||||
| `frontend/src/utils/apiError.ts` | ☑ |
|
||||
| `frontend/src/pages/ServersPage.vue` | ☑ |
|
||||
| `tests/test_validation_errors_zh.py` | ☑ |
|
||||
|
||||
## Step 3
|
||||
|
||||
| 项 | 结论 |
|
||||
|----|------|
|
||||
| 推送后台任务独立 DB 会话 | PASS |
|
||||
| 422 loc_zh 不泄露内部路径 | PASS |
|
||||
| 隐藏 CSV 仅 UI 移除 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
Gate 7/7 待跑 · 0 FINDING
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 推送按钮不长时间 loading
|
||||
- [x] 校验错误显示「每页条数:不能大于 200」
|
||||
- [x] 服务器页无导出 CSV 按钮
|
||||
- [ ] 生产部署与健康检查
|
||||
@@ -0,0 +1,31 @@
|
||||
# 2026-06-08 — 推送按钮改为后台任务提交
|
||||
|
||||
## 变更摘要
|
||||
|
||||
文件推送不再阻塞 HTTP 直至全部子机完成;点击「推送」后按钮仅短暂 loading,任务在后台执行,进度由 WebSocket + 下方进度列表展示。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈推送按钮长时间转圈,期望与脚本执行类似:提交后立即进入后台任务。
|
||||
|
||||
## 根因
|
||||
|
||||
`POST /api/sync/files` 同步等待 `sync_engine_v2.sync_files` 全部 rsync 结束才返回;前端 `:loading="pushing"` 与 HTTP 阻塞绑定。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/background/sync_push_runner.py`(新)
|
||||
- `server/api/sync_v2.py` — 校验后 `schedule_sync_files`,返回 `accepted: true`
|
||||
- `frontend/src/composables/push/usePushProgress.ts` — `submitting` 与 toast
|
||||
- `frontend/src/components/push/PushToolbar.vue`
|
||||
- `frontend/src/pages/PushPage.vue`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需重启 API / 重建镜像
|
||||
- 前端需 build
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 推送页选多台子机 → 点推送:按钮转圈仅数秒内结束,出现「推送已提交」toast,下方进度条持续更新
|
||||
2. `pytest tests/test_sync_batch_id.py -q`
|
||||
@@ -0,0 +1,17 @@
|
||||
# 2026-06-08 — 隐藏服务器页 CSV 导出
|
||||
|
||||
## 变更摘要
|
||||
|
||||
移除服务器列表页工具栏「导出 CSV」按钮及相关前端逻辑。
|
||||
|
||||
## 动机
|
||||
|
||||
按产品要求暂时隐藏该入口(曾触发 `per_page` 超限 422)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
构建后打开 `#/servers`,工具栏无「导出 CSV」按钮。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-08 — 422 校验错误字段名中文化
|
||||
|
||||
## 变更摘要
|
||||
|
||||
API 422 响应增加 `loc_zh`(如「每页条数」),前端不再显示 `query.per_page: 不能大于 200` 这类英文路径前缀。
|
||||
|
||||
## 动机
|
||||
|
||||
用户看到半英文校验提示;根因是 `msg` 已翻译但 `loc` 仍拼接 `query.` 前缀。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/validation_errors_zh.py`
|
||||
- `frontend/src/utils/validationErrorFormat.ts`(新)
|
||||
- `frontend/src/api/index.ts`、`frontend/src/utils/apiError.ts`
|
||||
- `frontend/src/pages/ServersPage.vue` — CSV 导出改为分页拉取(避免 `per_page: 5000` 触发 422)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_validation_errors_zh.py -q
|
||||
```
|
||||
|
||||
用户可见示例:`每页条数:不能大于 200`
|
||||
@@ -2,29 +2,14 @@
|
||||
* api/index.ts — HTTP client with JWT + HttpOnly refresh cookie.
|
||||
*/
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatValidationDetail } from '@/utils/validationErrorFormat'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
|
||||
|
||||
function normalizeDetail(detail: unknown, fallback: string): string {
|
||||
let msg = ''
|
||||
if (typeof detail === 'string') msg = detail
|
||||
else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((x: { msg?: string; loc?: unknown[] }) => {
|
||||
const part = x?.msg || JSON.stringify(x)
|
||||
const loc = Array.isArray(x.loc)
|
||||
? x.loc.filter((p) => p !== 'body').join('.')
|
||||
: ''
|
||||
return loc ? `${loc}: ${part}` : part
|
||||
})
|
||||
.join('; ')
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
msg = JSON.stringify(detail)
|
||||
}
|
||||
msg = msg.trim()
|
||||
return msg || fallback
|
||||
return formatValidationDetail(detail, fallback)
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-upload"
|
||||
:loading="pushing"
|
||||
:loading="submitting"
|
||||
:disabled="pushing"
|
||||
@click="$emit('push')"
|
||||
>
|
||||
推送
|
||||
@@ -35,6 +36,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
targetPath: string
|
||||
submitting: boolean
|
||||
pushing: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import type { usePushForm } from './usePushForm'
|
||||
import type { usePushServers } from './usePushServers'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import { formatPushErrorMessage } from './labels'
|
||||
import {
|
||||
TERMINAL_PROGRESS_STATUSES,
|
||||
@@ -17,6 +18,7 @@ type PushForm = ReturnType<typeof usePushForm>
|
||||
type PushServers = ReturnType<typeof usePushServers>
|
||||
|
||||
type PushFilesResponse = {
|
||||
accepted?: boolean
|
||||
batch_id?: string
|
||||
results?: Record<
|
||||
string,
|
||||
@@ -42,6 +44,7 @@ export function usePushProgress(
|
||||
const auth = useAuthStore()
|
||||
|
||||
const pushing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const retryingAll = ref(false)
|
||||
const wsProgressItems = ref<ServerProgress[]>([])
|
||||
@@ -227,7 +230,7 @@ export function usePushProgress(
|
||||
}
|
||||
|
||||
async function doPush() {
|
||||
if (pushing.value) return
|
||||
if (pushing.value || submitting.value) return
|
||||
const selected = [...servers.selectedIds.value]
|
||||
.map(id => servers.servers.value.find(s => s.id === id))
|
||||
.filter((s): s is NonNullable<typeof s> => s != null)
|
||||
@@ -245,6 +248,7 @@ export function usePushProgress(
|
||||
})
|
||||
|
||||
pushing.value = true
|
||||
submitting.value = true
|
||||
pushBatchId.value = newPushBatchId()
|
||||
|
||||
try {
|
||||
@@ -259,7 +263,11 @@ export function usePushProgress(
|
||||
if (res.batch_id && res.batch_id !== pushBatchId.value) {
|
||||
pushBatchId.value = res.batch_id
|
||||
}
|
||||
applyPushResultsFromHttp(res)
|
||||
if (res.accepted) {
|
||||
showScriptSubmitToast(`推送已提交(${ids.length} 台),请在下方查看进度`)
|
||||
} else {
|
||||
applyPushResultsFromHttp(res)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
disconnectProgressWs()
|
||||
const msg = e instanceof Error ? e.message : '推送请求失败'
|
||||
@@ -271,6 +279,8 @@ export function usePushProgress(
|
||||
snackbar(msg, 'error')
|
||||
onPushFinished()
|
||||
return
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
@@ -332,6 +342,7 @@ export function usePushProgress(
|
||||
|
||||
return {
|
||||
pushing,
|
||||
submitting,
|
||||
cancelling,
|
||||
retryingAll,
|
||||
wsProgressItems,
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
|
||||
<PushToolbar
|
||||
v-model:target-path="form.targetPath"
|
||||
:submitting="progress.submitting"
|
||||
:pushing="progress.pushing"
|
||||
@preview="preview.doPreview"
|
||||
@push="progress.doPush"
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
|
||||
快速添加(IP)
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
||||
导出CSV
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-ip-network" size="small" @click="openBatchAdd">
|
||||
批量添加
|
||||
</v-btn>
|
||||
@@ -599,7 +596,6 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||
import { beijingTodayYmd } from '@/utils/datetime'
|
||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||
import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||
@@ -1358,28 +1354,6 @@ async function confirmBatchDelete(scope: BatchScope) {
|
||||
loadStats()
|
||||
}
|
||||
|
||||
// ── CSV export ──
|
||||
async function exportCSV() {
|
||||
try {
|
||||
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
|
||||
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
|
||||
const rows = (res.items || []).map(s => [
|
||||
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
|
||||
s.agent_version || '', s.last_heartbeat || '',
|
||||
])
|
||||
const bom = ''
|
||||
const csv = bom + [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `servers_${beijingTodayYmd()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
snackbar('导出完成')
|
||||
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
|
||||
}
|
||||
|
||||
// ── Batch add by IP ──
|
||||
const showBatchAdd = ref(false)
|
||||
const batchAddText = ref('')
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { ApiError } from '@/api'
|
||||
import { formatValidationDetail } from '@/utils/validationErrorFormat'
|
||||
|
||||
/** Turn FastAPI error bodies into a human-readable string. */
|
||||
export function formatApiError(e: unknown, fallback: string): string {
|
||||
let msg = fallback
|
||||
if (e instanceof ApiError) {
|
||||
const d = (e as ApiError & { detail?: unknown }).detail ?? e.message
|
||||
if (typeof d === 'string') msg = d
|
||||
else if (Array.isArray(d)) {
|
||||
msg = d.map((x: { msg?: string; loc?: unknown[] }) => {
|
||||
const part = x?.msg || JSON.stringify(x)
|
||||
const loc = Array.isArray(x.loc) ? x.loc.filter((p) => p !== 'body').join('.') : ''
|
||||
return loc ? `${loc}: ${part}` : part
|
||||
}).join('; ')
|
||||
} else if (d && typeof d === 'object') msg = JSON.stringify(d)
|
||||
else msg = e.message || fallback
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message || fallback
|
||||
return formatValidationDetail(d, e.message || fallback)
|
||||
}
|
||||
msg = msg.trim()
|
||||
return msg || fallback
|
||||
if (e instanceof Error) {
|
||||
const msg = e.message.trim()
|
||||
return msg || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Format FastAPI 422 validation detail items for display (Chinese). */
|
||||
|
||||
const LOC_PREFIX_SKIP = new Set(['body', 'query', 'path', 'header', 'cookie'])
|
||||
|
||||
export function formatValidationDetailItem(x: {
|
||||
msg?: string
|
||||
loc?: unknown[]
|
||||
loc_zh?: string
|
||||
}): string {
|
||||
const part = (x?.msg || '').trim()
|
||||
if (x?.loc_zh) {
|
||||
return part ? `${x.loc_zh}:${part}` : x.loc_zh
|
||||
}
|
||||
const loc = Array.isArray(x?.loc)
|
||||
? x.loc.filter((p) => !LOC_PREFIX_SKIP.has(String(p)))
|
||||
: []
|
||||
const locStr = loc
|
||||
.map((p) => (typeof p === 'number' ? `第${p + 1}项` : String(p)))
|
||||
.join('.')
|
||||
if (locStr && part) return `${locStr}:${part}`
|
||||
return part || locStr || JSON.stringify(x)
|
||||
}
|
||||
|
||||
export function formatValidationDetail(detail: unknown, fallback: string): string {
|
||||
if (typeof detail === 'string') {
|
||||
const s = detail.trim()
|
||||
return s || fallback
|
||||
}
|
||||
if (Array.isArray(detail)) {
|
||||
const msg = detail.map((x) => formatValidationDetailItem(x as { msg?: string; loc?: unknown[]; loc_zh?: string })).join(';')
|
||||
return msg.trim() || fallback
|
||||
}
|
||||
if (detail && typeof detail === 'object') {
|
||||
return JSON.stringify(detail)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
+34
-5
@@ -5,6 +5,7 @@ P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
@@ -77,18 +78,46 @@ async def sync_files(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
||||
"""S1: Accept file push and run rsync in background (progress via /ws/sync)."""
|
||||
from server.application.services.sync_engine_v2 import _nexus_source_path
|
||||
from server.background.sync_push_runner import schedule_sync_files
|
||||
|
||||
engine = await _get_sync_engine(request)
|
||||
return await engine.sync_files(
|
||||
server_ids=payload.server_ids,
|
||||
source_path=payload.source_path,
|
||||
try:
|
||||
source_path = _nexus_source_path(payload.source_path)
|
||||
except PosixPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
servers = await engine.server_repo.get_by_ids(payload.server_ids)
|
||||
if not servers:
|
||||
raise HTTPException(status_code=400, detail="未选择有效服务器")
|
||||
found_ids = {s.id for s in servers}
|
||||
missing = [sid for sid in payload.server_ids if sid not in found_ids]
|
||||
if missing:
|
||||
preview = ", ".join(str(x) for x in missing[:5])
|
||||
suffix = f" 等 {len(missing)} 台" if len(missing) > 5 else ""
|
||||
raise HTTPException(status_code=400, detail=f"服务器不存在: {preview}{suffix}")
|
||||
|
||||
batch_id = (payload.batch_id or uuid.uuid4().hex[:12])[:12]
|
||||
schedule_sync_files(
|
||||
server_ids=list(payload.server_ids),
|
||||
source_path=source_path,
|
||||
target_path=payload.target_path,
|
||||
sync_mode=payload.sync_mode,
|
||||
operator=admin.username,
|
||||
batch_size=payload.batch_size,
|
||||
concurrency=payload.concurrency,
|
||||
batch_id=payload.batch_id,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
return {
|
||||
"accepted": True,
|
||||
"batch_id": batch_id,
|
||||
"total": len(servers),
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"cancelled": 0,
|
||||
"results": {},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Background file push — API returns immediately; progress via WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger("nexus.sync_push_runner")
|
||||
|
||||
|
||||
async def run_sync_files_background(
|
||||
*,
|
||||
server_ids: List[int],
|
||||
source_path: str,
|
||||
target_path: Optional[str],
|
||||
sync_mode: str,
|
||||
operator: str,
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""Execute sync_files in an isolated DB session (not request-scoped)."""
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
sync_mode=sync_mode,
|
||||
operator=operator,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Background sync_files failed: batch_id=%s", batch_id)
|
||||
|
||||
|
||||
def schedule_sync_files(**kwargs) -> None:
|
||||
"""Fire-and-forget push task on the running event loop."""
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(
|
||||
run_sync_files_background(**kwargs),
|
||||
name=f"sync_push:{kwargs.get('batch_id', '')}",
|
||||
)
|
||||
@@ -79,11 +79,58 @@ def translate_validation_msg(msg: str, err_type: str = "", ctx: dict[str, Any] |
|
||||
return "必须是数字"
|
||||
if err_type == "bool_parsing":
|
||||
return "必须是 true 或 false"
|
||||
if err_type == "less_than_equal" and ctx.get("le") is not None:
|
||||
return f"不能大于 {ctx['le']}"
|
||||
if err_type == "greater_than_equal" and ctx.get("ge") is not None:
|
||||
return f"不能小于 {ctx['ge']}"
|
||||
if err_type == "less_than" and ctx.get("lt") is not None:
|
||||
return f"必须小于 {ctx['lt']}"
|
||||
if err_type == "greater_than" and ctx.get("gt") is not None:
|
||||
return f"必须大于 {ctx['gt']}"
|
||||
return msg
|
||||
|
||||
|
||||
_LOC_PREFIX_SKIP = frozenset({"query", "body", "path", "header", "cookie"})
|
||||
|
||||
_FIELD_ZH: dict[str, str] = {
|
||||
"per_page": "每页条数",
|
||||
"page": "页码",
|
||||
"limit": "条数",
|
||||
"offset": "偏移量",
|
||||
"server_ids": "服务器",
|
||||
"source_path": "源路径",
|
||||
"target_path": "目标路径",
|
||||
"sync_mode": "同步模式",
|
||||
"batch_id": "批次 ID",
|
||||
"batch_size": "批次大小",
|
||||
"concurrency": "并发数",
|
||||
"value": "值",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"name": "名称",
|
||||
"cron_expr": "Cron 表达式",
|
||||
"fire_at": "执行时间",
|
||||
}
|
||||
|
||||
|
||||
def translate_loc_zh(loc: Any) -> str:
|
||||
"""Human-readable Chinese field path (no query/body prefixes)."""
|
||||
if not isinstance(loc, (list, tuple)):
|
||||
return "参数"
|
||||
parts: list[str] = []
|
||||
for piece in loc:
|
||||
if isinstance(piece, str) and piece in _LOC_PREFIX_SKIP:
|
||||
continue
|
||||
if isinstance(piece, int):
|
||||
parts.append(f"第 {piece + 1} 项")
|
||||
continue
|
||||
key = str(piece)
|
||||
parts.append(_FIELD_ZH.get(key, key))
|
||||
return ".".join(parts) if parts else "参数"
|
||||
|
||||
|
||||
def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Clone Pydantic error dicts with translated ``msg``."""
|
||||
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
|
||||
translated: list[dict[str, Any]] = []
|
||||
for err in errors:
|
||||
item = dict(err)
|
||||
@@ -92,5 +139,6 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
|
||||
str(err.get("type", "")),
|
||||
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
|
||||
)
|
||||
item["loc_zh"] = translate_loc_zh(err.get("loc"))
|
||||
translated.append(item)
|
||||
return translated
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import pytest
|
||||
|
||||
from server.api.schemas import SettingUpdatePayload
|
||||
from server.utils.validation_errors_zh import translate_validation_msg, translate_validation_errors
|
||||
from server.utils.validation_errors_zh import (
|
||||
translate_loc_zh,
|
||||
translate_validation_msg,
|
||||
translate_validation_errors,
|
||||
)
|
||||
|
||||
|
||||
def test_translate_exact_string_type():
|
||||
@@ -24,6 +28,24 @@ def test_translate_validation_errors_list():
|
||||
out = translate_validation_errors(errors)
|
||||
assert out[0]["msg"] == "必须是字符串"
|
||||
assert out[0]["loc"] == ["body", "value"]
|
||||
assert out[0]["loc_zh"] == "值"
|
||||
|
||||
|
||||
def test_translate_loc_zh_query_per_page():
|
||||
assert translate_loc_zh(["query", "per_page"]) == "每页条数"
|
||||
|
||||
|
||||
def test_translate_per_page_le_via_ctx():
|
||||
out = translate_validation_errors([
|
||||
{
|
||||
"type": "less_than_equal",
|
||||
"loc": ["query", "per_page"],
|
||||
"msg": "Input should be less than or equal to 200",
|
||||
"ctx": {"le": 200},
|
||||
},
|
||||
])
|
||||
assert out[0]["msg"] == "不能大于 200"
|
||||
assert out[0]["loc_zh"] == "每页条数"
|
||||
|
||||
|
||||
def test_setting_update_payload_coerces_int():
|
||||
|
||||
Reference in New Issue
Block a user