From 0f2268155cbcb5640b2a7974e11f6ee44664a8ae Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 3 May 2026 07:27:53 +0800 Subject: [PATCH] fix: repair failed knowledge-base document UI --- frontend/package.json | 1 + .../knowledge-base-failed-document-ui-e2e.mjs | 213 ++++++++++++++++++ frontend/scripts/phase1-closed-loop-e2e.mjs | 4 +- frontend/scripts/phase2-closed-loop-e2e.mjs | 4 +- frontend/scripts/phase3-closed-loop-e2e.mjs | 6 +- frontend/src/app/globals.css | 18 ++ frontend/src/app/knowledge-bases/page.tsx | 128 ++++++++++- .../完整自动化测试报告修复记录-2026-05-03.md | 83 +++++++ scripts/run-production-regression.ps1 | 17 ++ 9 files changed, 459 insertions(+), 15 deletions(-) create mode 100644 frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs create mode 100644 project-docs/完整自动化测试报告修复记录-2026-05-03.md diff --git a/frontend/package.json b/frontend/package.json index e0276a24..2b23e86a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "qa:kb-failed-ui": "node scripts/knowledge-base-failed-document-ui-e2e.mjs", "qa:phase1": "node scripts/phase1-closed-loop-e2e.mjs", "qa:phase2": "node scripts/phase2-closed-loop-e2e.mjs", "qa:phase3": "node scripts/phase3-closed-loop-e2e.mjs" diff --git a/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs b/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs new file mode 100644 index 00000000..eb20da8f --- /dev/null +++ b/frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs @@ -0,0 +1,213 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `kb-failed-ui-${Date.now()}`; +const webBase = process.env.KB_FAILED_UI_WEB_URL || "http://127.0.0.1:3005"; +const apiBase = process.env.KB_FAILED_UI_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.KB_FAILED_UI_HEALTH_URL || "http://127.0.0.1:8000/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.KB_FAILED_UI_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "kb-failed-document-ui", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const result = { + run_id: runId, + web_base: webBase, + api_base: apiBase, + health_url: healthUrl, + artifact_dir: artifactDir, + checks: [], +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function apiRequest(api, method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(api) { + const payload = await apiRequest(api, "post", "/auth/login", { + data: { account: "admin@example.com", password: adminPassword }, + }); + expectOk(payload, "admin login"); + return payload.body.data; +} + +async function main() { + const api = await request.newContext(); + const browser = await chromium.launch({ headless: true }); + + try { + const frontend = await api.get(`${webBase}/login`); + const health = await api.get(healthUrl); + assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + + const adminSession = await login(api); + + const kbName = `${runId} KB`; + const create = await apiRequest(api, "post", "/knowledge-bases", { + authToken: adminSession.access_token, + multipart: { name: kbName, description: "KB failed document ui regression" }, + }); + expectOk(create, "create knowledge base"); + const kbId = create.body.data.id; + + const goodDoc = await apiRequest(api, "post", `/knowledge-bases/${kbId}/documents`, { + authToken: adminSession.access_token, + multipart: { + file: { + name: `${runId}-good.txt`, + mimeType: "text/plain", + buffer: Buffer.from("合同审查依据:付款、保密、违约责任。", "utf8"), + }, + }, + }); + expectOk(goodDoc, "upload good kb document"); + + const failedDoc = await apiRequest(api, "post", `/knowledge-bases/${kbId}/documents`, { + authToken: adminSession.access_token, + multipart: { + file: { + name: `${runId}-failed.txt`, + mimeType: "text/plain", + buffer: Buffer.from(""), + }, + }, + }); + expectOk(failedDoc, "upload failed kb document"); + assert(failedDoc.body.data.status === "failed", "failed kb document should stay failed", failedDoc.body.data); + + const context = await browser.newContext({ + viewport: { width: 1440, height: 1100 }, + }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: adminSession.access_token, user: adminSession.user }, + ); + + const page = await context.newPage(); + await page.goto(`${webBase}/knowledge-bases`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2200); + await page.getByRole("button", { name: kbName }).click(); + await page.waitForTimeout(1200); + await page.screenshot({ path: screenshotPath("knowledge-base-failed-ui"), fullPage: true }); + + const body = await page.locator("body").innerText(); + assert(body.includes("异常文档恢复"), "failed document recovery panel should be visible", body.slice(0, 1800)); + assert(body.includes("重试处理"), "retry button should be visible for failed document", body.slice(-1600)); + const disabledLinks = await page.locator("a.disabled-link[aria-disabled='true']").count(); + assert(disabledLinks >= 2, "failed document chat actions should be disabled", { disabledLinks }); + const failedFileNameVisible = body.includes(`${runId}-failed.txt`); + assert(failedFileNameVisible, "failed document filename should be visible in recovery panel", body.slice(-1800)); + + const summary = { + ...result, + total: 1, + passed: 1, + failed: 0, + checks: [ + { + id: "KB-FAILED-UI-001", + title: "knowledge base failed document UI", + status: "PASS", + details: { + kb_id: kbId, + disabled_links: disabledLinks, + screenshot: "knowledge-base-failed-ui.png", + }, + }, + ], + }; + fs.writeFileSync(path.join(artifactDir, "kb-failed-document-ui-summary.json"), JSON.stringify(summary, null, 2), "utf8"); + fs.writeFileSync( + path.join(artifactDir, "kb-failed-document-ui-summary.md"), + [ + "# KB Failed Document UI E2E", + "", + `- Run ID: \`${runId}\``, + `- Web: \`${webBase}\``, + `- API: \`${apiBase}\``, + "- Result: PASS", + "", + "| Check | Status |", + "|---|---|", + "| KB failed document UI | PASS |", + ].join("\n"), + "utf8", + ); + console.log(JSON.stringify(summary, null, 2)); + } finally { + await browser.close().catch(() => undefined); + await api.dispose().catch(() => undefined); + } +} + +main().catch((error) => { + const summary = { + ...result, + total: 1, + passed: 0, + failed: 1, + checks: [ + { + id: "KB-FAILED-UI-001", + title: "knowledge base failed document UI", + status: "FAIL", + error: error.message, + details: error.details || {}, + }, + ], + }; + fs.writeFileSync(path.join(artifactDir, "kb-failed-document-ui-summary.json"), JSON.stringify(summary, null, 2), "utf8"); + console.error(JSON.stringify(summary, null, 2)); + process.exit(1); +}); diff --git a/frontend/scripts/phase1-closed-loop-e2e.mjs b/frontend/scripts/phase1-closed-loop-e2e.mjs index 80d7f92c..70bdb4ba 100644 --- a/frontend/scripts/phase1-closed-loop-e2e.mjs +++ b/frontend/scripts/phase1-closed-loop-e2e.mjs @@ -9,8 +9,8 @@ const __dirname = path.dirname(__filename); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const runId = `phase1-auto-${Date.now()}`; const webBase = process.env.PHASE1_WEB_URL || "http://127.0.0.1:3004"; -const apiBase = process.env.PHASE1_API_URL || `${webBase}/api/v1`; -const healthUrl = process.env.PHASE1_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health"); +const apiBase = process.env.PHASE1_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.PHASE1_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.PHASE1_ARTIFACT_DIR || diff --git a/frontend/scripts/phase2-closed-loop-e2e.mjs b/frontend/scripts/phase2-closed-loop-e2e.mjs index 99c00e0d..5417f8b9 100644 --- a/frontend/scripts/phase2-closed-loop-e2e.mjs +++ b/frontend/scripts/phase2-closed-loop-e2e.mjs @@ -9,8 +9,8 @@ const __dirname = path.dirname(__filename); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const runId = `phase2-auto-${Date.now()}`; const webBase = process.env.PHASE2_WEB_URL || "http://127.0.0.1:3005"; -const apiBase = process.env.PHASE2_API_URL || `${webBase}/api/v1`; -const healthUrl = process.env.PHASE2_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health"); +const apiBase = process.env.PHASE2_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.PHASE2_HEALTH_URL || "http://127.0.0.1:8000/health"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.PHASE2_ARTIFACT_DIR || diff --git a/frontend/scripts/phase3-closed-loop-e2e.mjs b/frontend/scripts/phase3-closed-loop-e2e.mjs index fb021b81..36064f3b 100644 --- a/frontend/scripts/phase3-closed-loop-e2e.mjs +++ b/frontend/scripts/phase3-closed-loop-e2e.mjs @@ -9,9 +9,9 @@ const __dirname = path.dirname(__filename); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const runId = `phase3-auto-${Date.now()}`; const webBase = process.env.PHASE3_WEB_URL || "http://127.0.0.1:3006"; -const apiBase = process.env.PHASE3_API_URL || `${webBase}/api/v1`; -const healthUrl = process.env.PHASE3_HEALTH_URL || apiBase.replace(/\/api\/v1\/?$/, "/health"); -const readinessUrl = process.env.PHASE3_READINESS_URL || `${apiBase.replace(/\/$/, "")}/system/readiness`; +const apiBase = process.env.PHASE3_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.PHASE3_HEALTH_URL || "http://127.0.0.1:8000/health"; +const readinessUrl = process.env.PHASE3_READINESS_URL || "http://127.0.0.1:8000/api/v1/system/readiness"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; const artifactDir = process.env.PHASE3_ARTIFACT_DIR || diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e81e49b3..56d24cd8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2709,6 +2709,24 @@ body::selection { border: 1px solid rgba(17, 18, 23, 0.12); } +.disabled-link { + opacity: 0.58; + filter: grayscale(0.18); + box-shadow: none !important; +} + +.kb-recovery-item { + align-items: flex-start !important; +} + +.kb-recovery-item .ant-list-item { + align-items: flex-start; +} + +.kb-recovery-item + .kb-recovery-item { + border-top: 1px solid rgba(148, 163, 184, 0.18); +} + .landing-trust-row { display: flex; flex-wrap: wrap; diff --git a/frontend/src/app/knowledge-bases/page.tsx b/frontend/src/app/knowledge-bases/page.tsx index 1c1a81f8..2411d601 100644 --- a/frontend/src/app/knowledge-bases/page.tsx +++ b/frontend/src/app/knowledge-bases/page.tsx @@ -71,6 +71,48 @@ function getDocumentStatusMeta(status: string) { return DOCUMENT_STATUS_META[status] ?? { color: "default", label: status }; } +function isDocumentReadyForChat(document: KnowledgeBaseDocumentItem) { + return document.status === "indexed" && document.index_status === "completed"; +} + +function isDocumentFailedLike(document: KnowledgeBaseDocumentItem) { + return ( + document.status === "failed" || + document.parse_status === "failed" || + document.index_status === "failed" || + document.retryable === true || + Boolean(document.failure_reason) + ); +} + +function isDocumentRetryable(document: KnowledgeBaseDocumentItem) { + return document.retryable === true || isDocumentFailedLike(document); +} + +function getDocumentActionState(document: KnowledgeBaseDocumentItem) { + if (isDocumentReadyForChat(document)) { + return { + readyForChat: true, + disabledReason: null, + retryable: false, + }; + } + + if (isDocumentFailedLike(document)) { + return { + readyForChat: false, + disabledReason: document.error_message || document.failure_reason || "当前文档处理失败,请先重试或重新上传替换文件。", + retryable: isDocumentRetryable(document), + }; + } + + return { + readyForChat: false, + disabledReason: document.next_action || "当前文档尚未完成索引,暂时不能进入问答。", + retryable: false, + }; +} + function buildKnowledgeBaseChatHref( knowledgeBase: KnowledgeBaseItem | null, document: KnowledgeBaseDocumentItem | null, @@ -116,6 +158,22 @@ export default function KnowledgeBasesPage() { [items, selectedId], ); + const orderedDocuments = useMemo(() => { + return [...documents].sort((left, right) => { + const leftPriority = isDocumentFailedLike(left) ? 0 : left.status === "processing" || left.status === "pending" ? 1 : 2; + const rightPriority = isDocumentFailedLike(right) ? 0 : right.status === "processing" || right.status === "pending" ? 1 : 2; + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + return new Date(right.created_at).getTime() - new Date(left.created_at).getTime(); + }); + }, [documents]); + + const failedDocuments = useMemo( + () => orderedDocuments.filter((document) => isDocumentFailedLike(document)), + [orderedDocuments], + ); + const loadKnowledgeBases = useCallback(async () => { if (currentUser?.role !== "admin") { setLoading(false); @@ -204,8 +262,6 @@ export default function KnowledgeBasesPage() { await loadDocuments(selectedId, true); }; - const isDocumentReadyForChat = (document: KnowledgeBaseDocumentItem) => document.status === "indexed" && document.index_status === "completed"; - const handleCreate = async (values: KnowledgeBaseFormValues) => { setSubmitting(true); try { @@ -392,6 +448,61 @@ export default function KnowledgeBasesPage() { + + {failedDocuments.length ? ( + { + const actionState = getDocumentActionState(document); + return ( + + + + {document.file_name} + 失败文档 + {formatLifecycleBadge(document.parse_status)} + {formatLifecycleBadge(document.index_status)} + + {document.error_message || document.failure_reason || "文档处理失败"} + {document.status_detail || "请检查文件内容或格式后重试。"} + {document.next_action ? 下一步:{document.next_action} : null} + + { + event.preventDefault(); + messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); + }} + > + 审查要点 + + { + event.preventDefault(); + messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); + }} + > + 继续问答 + + + + + + ); + }} + /> + ) : ( + + )} + + }} @@ -521,7 +632,8 @@ export default function KnowledgeBasesPage() { key: "chat_actions", width: 220, render: (_value: unknown, record: KnowledgeBaseDocumentItem) => { - const readyForChat = isDocumentReadyForChat(record); + const actionState = getDocumentActionState(record); + const readyForChat = actionState.readyForChat; return ( { if (!readyForChat) { event.preventDefault(); - messageApi.warning("当前文档尚未完成索引,暂时不能进入问答。"); + messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); } }} > @@ -562,14 +674,14 @@ export default function KnowledgeBasesPage() { onClick={(event) => { if (!readyForChat) { event.preventDefault(); - messageApi.warning("当前文档尚未完成索引,暂时不能进入问答。"); + messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); } }} > 继续问答 - {record.retryable ? ( - ) : null} diff --git a/project-docs/完整自动化测试报告修复记录-2026-05-03.md b/project-docs/完整自动化测试报告修复记录-2026-05-03.md new file mode 100644 index 00000000..cef18190 --- /dev/null +++ b/project-docs/完整自动化测试报告修复记录-2026-05-03.md @@ -0,0 +1,83 @@ +# 完整自动化测试报告修复记录(2026-05-03) + +## 对应报告 + +- `project-docs/完整自动化测试报告-2026-05-03.md` + +## 已修复问题 + +### 1. 知识库 failed 文档未稳定暴露恢复入口 + +已修复: + +- `frontend/src/app/knowledge-bases/page.tsx` + +处理方式: + +- 新增 failed 文档恢复区。 +- 失败文档按优先级前置展示。 +- `重试处理` 按失败态兜底渲染,不再只依赖 `retryable` 单字段。 + +### 2. failed 文档 Chat 入口禁用态不稳定 + +已修复: + +- `frontend/src/app/knowledge-bases/page.tsx` +- `frontend/src/app/globals.css` + +处理方式: + +- 增加统一的 failed / retryable / ready 判断。 +- failed 文档的 `审查要点` / `继续问答` 强制显示为禁用态。 +- 点击禁用态时给出明确原因提示。 + +### 3. 自动化脚本默认健康检查口径不准确 + +已修复: + +- `frontend/scripts/phase1-closed-loop-e2e.mjs` +- `frontend/scripts/phase2-closed-loop-e2e.mjs` +- `frontend/scripts/phase3-closed-loop-e2e.mjs` + +处理方式: + +- 不再从前端入口反推 `/health` +- 改为显式默认后端: + - API:`http://127.0.0.1:8000/api/v1` + - Health:`http://127.0.0.1:8000/health` + - Readiness:`http://127.0.0.1:8000/api/v1/system/readiness` + +### 4. 缺少知识库 failed 文档专项前端回归 + +已新增: + +- `frontend/scripts/knowledge-base-failed-document-ui-e2e.mjs` +- `frontend/package.json` +- `scripts/run-production-regression.ps1` + +处理方式: + +- 新增独立专项 E2E: + - 创建知识库 + - 上传成功文档 + - 上传 failed 文档 + - 打开知识库页面 + - 验证恢复区、重试按钮、Chat 禁用态 +- 并接入 `run-production-regression.ps1` + +## 验证结果 + +已完成: + +- `frontend/npm.cmd run lint` +- `frontend/npm.cmd run build` + +结果: + +- `lint` 通过 +- `build` 通过 + +说明: + +- 本次修复已完成前端静态校验与生产构建校验。 +- 未在本地再跑完整浏览器 E2E,因为当前回归脚本依赖目标环境与管理员口令。 diff --git a/scripts/run-production-regression.ps1 b/scripts/run-production-regression.ps1 index 07eb7305..fefba86d 100644 --- a/scripts/run-production-regression.ps1 +++ b/scripts/run-production-regression.ps1 @@ -84,6 +84,10 @@ function Invoke-NodePhase { PHASE3_HEALTH_URL = $env:PHASE3_HEALTH_URL PHASE3_READINESS_URL = $env:PHASE3_READINESS_URL PHASE3_ARTIFACT_DIR = $env:PHASE3_ARTIFACT_DIR + KB_FAILED_UI_WEB_URL = $env:KB_FAILED_UI_WEB_URL + KB_FAILED_UI_API_URL = $env:KB_FAILED_UI_API_URL + KB_FAILED_UI_HEALTH_URL = $env:KB_FAILED_UI_HEALTH_URL + KB_FAILED_UI_ARTIFACT_DIR = $env:KB_FAILED_UI_ARTIFACT_DIR } if ($Phase -eq "phase1") { @@ -102,6 +106,11 @@ function Invoke-NodePhase { $env:PHASE3_HEALTH_URL = $HealthUrl $env:PHASE3_READINESS_URL = $ReadinessUrl $env:PHASE3_ARTIFACT_DIR = $phaseArtifactDir + } elseif ($Phase -eq "kb-failed-ui") { + $env:KB_FAILED_UI_WEB_URL = $WebUrl + $env:KB_FAILED_UI_API_URL = $ApiUrl + $env:KB_FAILED_UI_HEALTH_URL = $HealthUrl + $env:KB_FAILED_UI_ARTIFACT_DIR = $phaseArtifactDir } Push-Location $frontendDir @@ -140,6 +149,13 @@ if ($phase2.exit_code -eq 0) { Add-Check -Key "phase2_production_e2e" -Label "phase2 production E2E" -Status "fail" -Detail "phase2 production E2E failed" -Meta @{ log = $phase2.log; artifact_dir = $phase2.artifact_dir } } +$kbFailedUi = Invoke-NodePhase -Phase "kb-failed-ui" -ScriptName "knowledge-base-failed-document-ui-e2e.mjs" +if ($kbFailedUi.exit_code -eq 0) { + Add-Check -Key "kb_failed_document_ui_e2e" -Label "knowledge base failed document UI E2E" -Status "pass" -Detail "knowledge base failed document UI E2E passed" -Meta @{ log = $kbFailedUi.log; artifact_dir = $kbFailedUi.artifact_dir } +} else { + Add-Check -Key "kb_failed_document_ui_e2e" -Label "knowledge base failed document UI E2E" -Status "fail" -Detail "knowledge base failed document UI E2E failed" -Meta @{ log = $kbFailedUi.log; artifact_dir = $kbFailedUi.artifact_dir } +} + $phase3 = Invoke-NodePhase -Phase "phase3" -ScriptName "phase3-closed-loop-e2e.mjs" if ($phase3.exit_code -eq 0) { Add-Check -Key "phase3_production_e2e" -Label "phase3 production E2E" -Status "pass" -Detail "phase3 production E2E passed" -Meta @{ log = $phase3.log; artifact_dir = $phase3.artifact_dir } @@ -189,6 +205,7 @@ $markdown.Add("") | Out-Null $markdown.Add('- `production-regression-summary.json`') | Out-Null $markdown.Add('- `phase1/`') | Out-Null $markdown.Add('- `phase2/`') | Out-Null +$markdown.Add('- `kb-failed-ui/`') | Out-Null $markdown.Add('- `phase3/`') | Out-Null $markdown.Add('- `phase*.log`') | Out-Null $markdown | Set-Content -Encoding UTF8 -Path $summaryMd