PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
// deploy/m3_e2e.js —— M3 综合验收(PM 使用,在远端运行)
// 覆盖:KG 自由浏览(真实 Neo4j)、抽取任务 tab(真实 8000)、三档视口布局、主题切换
const { chromium } = require('playwright');
const BASE = 'http://127.0.0.1:5180';
const OUT_DIR = `${__dirname}/../artifacts/m3`;
const fs = require('fs');
fs.mkdirSync(OUT_DIR, { recursive: true });
const results = [];
function check(name, ok, extra = '') {
results.push({ name, ok });
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
}
async function waitFor(fn, timeoutMs, intervalMs = 700, label = '') {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const v = await fn();
if (v) return v;
} catch {}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`等待超时(${timeoutMs}ms): ${label}`);
}
(async () => {
const browser = await chromium.launch();
// ============ 桌面档:KG 浏览 + 抽取 ============
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
// KG tab:实体搜索 + 邻域
await page.click('.bench-tab:has-text("知识图谱")');
await waitFor(() => page.locator('.bench-body input[type="text"], .bench-body input').count(), 10000, 500, 'KG 搜索框');
await page.fill('.bench-body input', 'OsSPL14');
await page.press('.bench-body input', 'Enter');
await waitFor(async () => {
const n = await page.locator('.bench-body .kg-result, .bench-body [class*="result"], .bench-body [class*="entity"]').count();
return n > 0;
}, 20000, 800, '实体搜索结果');
check('KG:实体搜索有结果(真实 Neo4j fulltext 替代)', true);
await page.screenshot({ path: `${OUT_DIR}/01-kg-search.png` });
// 邻域渲染(点击第一个结果或通过已选中实体)
const clicked = await page.evaluate(async () => {
const items = document.querySelectorAll('.bench-body [class*="result"] button, .bench-body [class*="entity"] button, .bench-body [class*="result"] [class*="item"]');
if (items[0]) { items[0].click(); return true; }
return false;
});
await waitFor(async () => {
const svg = await page.locator('.bench-body svg').count();
const nodes = await page.locator('.bench-body svg circle, .bench-body svg [class*="node"]').count();
return svg > 0 && nodes > 0;
}, 30000, 800, 'KG 图谱渲染');
check('KG:邻域图谱渲染(节点+边)', true);
await page.screenshot({ path: `${OUT_DIR}/02-kg-graph.png` });
// 抽取任务 tab
await page.click('.bench-tab:has-text("提取任务")');
await waitFor(async () => {
const n = await page.locator('.bench-body [class*="job"], .bench-body [class*="prog"]').count();
return n > 0;
}, 20000, 800, '抽取任务列表');
check('抽取:真实任务列表渲染', true);
const jobText = await page.locator('.bench-body').textContent();
check('抽取:含真实任务名或状态', /MinerU|completed|failed|running|queued|wang|Pecan/i.test(jobText));
await page.screenshot({ path: `${OUT_DIR}/03-extraction.png` });
check('桌面档无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|'));
await ctx.close();
}
// ============ 平板档:抽屉布局 ============
{
const ctx = await browser.newContext({ viewport: { width: 768, height: 1024 } });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell.drawers-mode').count(), 15000, 500, 'drawers-mode');
check('平板:drawers-mode 生效(单栏)', true);
// 汉堡开左抽屉
const hamburger = await page.locator('.hamburger-btn').count();
check('平板:顶栏汉堡按钮出现', hamburger > 0);
if (hamburger > 0) {
await page.click('.hamburger-btn');
await waitFor(() => page.locator('.drawer').count(), 5000, 400, '左抽屉');
const drawerVisible = await page.locator('.drawer').first().isVisible();
check('平板:左抽屉打开', drawerVisible);
await page.screenshot({ path: `${OUT_DIR}/04-tablet-drawer.png` });
// Esc 关闭(抽屉仍挂载但移出屏幕——断言 open class 而非 isHidden)
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
const openClass = await page.locator('.drawer.left').first().evaluate((el) => el.classList.contains('open')).catch(() => false);
check('平板:Esc 关闭抽屉', !openClass);
}
await ctx.close();
}
// ============ 手机档:全屏覆盖层 + 主题 ============
{
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await waitFor(() => page.locator('.app-shell.mobile').count(), 15000, 500, 'mobile 档');
check('手机:mobile 档生效', true);
const sb = await page.locator('.status-bar').isHidden().catch(() => true);
check('手机:底栏隐藏(FR-RESP-004)', sb);
const benchBtn = await page.locator('.bench-btn').count();
check('手机:工作台按钮出现', benchBtn > 0);
if (benchBtn > 0) {
await page.click('.bench-btn');
await waitFor(() => page.locator('.bench-overlay').count(), 5000, 400, '工作台覆盖层');
check('手机:工作台全屏覆盖层打开', true);
await page.screenshot({ path: `${OUT_DIR}/05-mobile-overlay.png` });
// tab 切换(PM 修复的接缝)。注意覆盖层内有两套 tab(外壳 tab 条 + BenchPanel 内部 tab),
// 用外壳 tab 条(.bench-overlay-tabs 内)点击,用最后匹配的 on 断言
await page.click('.bench-overlay-tabs .bench-tab:has-text("文献来源")');
const activeTab = await page.locator('.bench-overlay-tabs .bench-tab.on').first().textContent();
check('手机:覆盖层 tab 切换(PM 接线修复)', activeTab.includes('文献来源'), activeTab.trim());
await page.click('.bench-overlay .bench-close-btn, .bench-overlay [class*="close"]').catch(async () => {
await page.keyboard.press('Escape');
});
}
// 主题切换(浅色)
await page.click('.top-bar .icon-btn:last-child');
await page.waitForTimeout(300);
const theme = await page.evaluate(() => document.documentElement.dataset.theme);
check('主题:切换为浅色(dataset.theme)', theme === 'light');
const canvasBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
check('主题:浅色 canvas 生效', canvasBg === 'rgb(246, 248, 252)', canvasBg);
await page.screenshot({ path: `${OUT_DIR}/06-mobile-light.png` });
await ctx.close();
}
await browser.close();
const passed = results.filter((r) => r.ok).length;
console.log(`\nM3 验收结果:${passed}/${results.length} 通过`);
process.exit(passed === results.length ? 0 : 1);
})();