#!/usr/bin/env node /** * 用户说明书 UI 截图采集脚本(真实 dev 环境 http://127.0.0.1:8080) * * 运行方式(必须在 web/ 目录下,以便 resolve playwright): * cd /home/weichen/projects/Agent-System/web * node ../../.worktrees/feature-user-manual/docs/user-manual/scripts/capture-screenshots.mjs [section...] * * section 可选:onboarding workbench project chat doc docgov admin all(默认 all) * 例:只重跑管理台 → node capture-screenshots.mjs admin * * 红线:不修改/删除既有项目数据(项目 143 只读);演示数据统一前缀「说明书演示」。 */ import fs from 'fs'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // playwright 装在主仓 web/node_modules;脚本本身在 worktree 内,需显式指向解析基准 const WEB_DIR = '/home/weichen/projects/Agent-System/web'; const { chromium } = createRequire(path.join(WEB_DIR, 'package.json'))('playwright'); const OUT = path.resolve(__dirname, '../images'); const STATE_FILE = path.join(__dirname, '.capture-state.json'); fs.mkdirSync(OUT, { recursive: true }); const BASE = 'http://127.0.0.1:8080'; const DEMO_PREFIX = '说明书演示'; const DEMO_PROJECT_NAME = '说明书演示-超声骨刀系统'; const DEMO_PROJECT_DESC = '用于骨科微创手术的双频超声吸引与止血设备,需符合 GB 9706.1 与 NMPA 注册法规要求。(说明书截图演示项目)'; const CHAT_PROMPT = '请帮我梳理一下这个项目的用户需求收集表应该包含哪些内容'; const USERS = { admin: { username: 'admin', password: 'admin-secret-password-123' }, zhanggong: { username: 'zhanggong', password: '123456' }, lizhuguan: { username: 'lizhuguan', password: '123456' }, wangzong: { username: 'wangzong', password: '123456' }, }; const results = []; function record(file, ok, note = '') { results.push({ file, ok, note }); console.log(`${ok ? '[OK]' : '[..]'} ${file}${note ? ' — ' + note : ''}`); } function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); } catch { return {}; } } function saveState(patch) { const st = { ...loadState(), ...patch }; fs.writeFileSync(STATE_FILE, JSON.stringify(st, null, 2), 'utf-8'); } // ─── API helpers ─── async function apiLogin(username, password) { const res = await fetch(`${BASE}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (!res.ok) throw new Error(`login ${username} failed: HTTP ${res.status}`); return (await res.json()).token; } async function api(token, method, p, body) { const res = await fetch(`${BASE}${p}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch { /* keep text */ } return { status: res.status, json, text }; } // ─── browser helpers ─── let browser; async function newPage() { const context = await browser.newContext({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1.5, locale: 'zh-CN', }); const page = await context.newPage(); page.setDefaultTimeout(30_000); return { context, page }; } async function settle(page, ms = 600) { await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {}); await page.waitForTimeout(ms); } async function shot(page, name, opts = {}) { const file = path.join(OUT, name); await page.screenshot({ path: file, fullPage: false, ...opts }); return name; } async function uiLogin(page, username, password) { await page.goto(BASE + '/'); await page.fill('input[placeholder*="系统用户名"]', username); await page.fill('input[placeholder*="登录密码"]', password); await page.click('button:has-text("进入医疗协同工作台")'); } async function uiLoginWorkbench(page, username, password) { await uiLogin(page, username, password); await page.getByText('新建立项会话').waitFor({ timeout: 30_000 }); await settle(page, 1200); } async function closeModal(page) { for (const name of ['关闭面板', '关闭', '取消']) { const b = page.getByRole('button', { name, exact: true }).last(); if (await b.isVisible({ timeout: 800 }).catch(() => false)) { await b.click(); await page.waitForTimeout(400); return; } } // 多数弹窗支持点击遮罩(backdrop)关闭:点左上角空白处 await page.mouse.click(8, 8); await page.waitForTimeout(400); await page.keyboard.press('Escape'); await page.waitForTimeout(300); } /** 在左栏选中指定项目会话;不存在则通过立项弹窗创建(含成员职责分配)。 */ async function ensureDemoProject(page, projectName = DEMO_PROJECT_NAME) { const aside = page.locator('aside'); const existing = aside.getByText(projectName, { exact: false }).first(); if (await existing.isVisible({ timeout: 3000 }).catch(() => false)) { await existing.click(); await page.waitForTimeout(1500); await settle(page); return false; } await page.getByRole('button', { name: '新建立项会话' }).click(); const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); await heading.waitFor({ timeout: 30_000 }); const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); await modal.locator('input[placeholder*="无线便携式"]').fill(projectName); await modal.locator('textarea').first().fill(DEMO_PROJECT_DESC); for (const [name, role] of [ ['李主管', 'reviewer'], ['王总监', 'approver'], ]) { const memberName = modal.getByText(name, { exact: true }); if (!(await memberName.isVisible({ timeout: 5000 }).catch(() => false))) continue; await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); const container = memberName.locator( 'xpath=ancestor::div[contains(@style,"justify-content: space-between")][1]', ); await container.locator('select').selectOption(role); } await modal.getByRole('button', { name: /确认立项/ }).click(); await heading.waitFor({ state: 'hidden', timeout: 15_000 }); await page.locator('header').getByText(projectName).waitFor({ timeout: 30_000 }); await settle(page, 1500); return true; } /** 按项目名查 project_id(以张工会话列表为准)。 */ async function findProjectIdByName(projectName) { const token = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); const sessRes = await api(token, 'GET', '/api/v1/chat/sessions'); const sessions = sessRes.json?.sessions || sessRes.json || []; const hit = (Array.isArray(sessions) ? sessions : []).find((s) => (s.title || s.project_name || '').includes(projectName), ); return hit?.project_id || null; } /** 项目工作流配置弹窗:规划态下完成 选择→配置职责→激活。captureShots 时产出 22/23。 */ async function configureAndActivateWorkflow(page, { captureShots = false } = {}) { const cfgBtn = page.getByRole('button', { name: '配置工作流' }); if (!(await cfgBtn.isVisible({ timeout: 5000 }).catch(() => false))) return false; await cfgBtn.click(); await page.getByText('项目工作流配置').waitFor({ timeout: 10_000 }); await settle(page, 1500); // 若已有规划草稿,重开弹窗会直接进入 configure 步骤;按当前步骤自适应 const nextBtn = page.getByRole('button', { name: /下一步/ }); if (await nextBtn.isVisible({ timeout: 3000 }).catch(() => false)) { if (captureShots) { await shot(page, '22-workflow-switcher.png'); record('22-workflow-switcher.png', true, '工作流切换控件展开态(项目设置-选择工作流步骤)'); } await nextBtn.click(); await page.waitForTimeout(2000); } else if (captureShots) { record('22-workflow-switcher.png', true, '已有规划草稿直达配置步骤(沿用首次截图)'); } await page.getByRole('button', { name: /校验并预览/ }).waitFor({ timeout: 10_000 }); // configure 步骤:勾选《用户初始需求收集表》的「纳入人工审批」,再指派 编制=张工 审核=李主管 批准=王总监 const intakeRow = page .locator('xpath=//div[.//text()="用户初始需求收集表" and .//text()="纳入人工审批"]') .last(); await intakeRow.scrollIntoViewIfNeeded(); const approvalLabel = intakeRow.locator('label', { hasText: '纳入人工审批' }).first(); const approvalBox = approvalLabel.locator('input[type="checkbox"]'); if (!(await approvalBox.isChecked().catch(() => false))) { await approvalLabel.click(); await page.waitForTimeout(800); } const dutySelects = intakeRow.locator('select'); const dn = await dutySelects.count(); const st0 = loadState(); for (let i = 0; i < dn; i++) { const sel = dutySelects.nth(i); const firstOpt = ((await sel.locator('option').first().innerText().catch(() => '')) || '').trim(); const uid = firstOpt.startsWith('作者') ? st0.uidZhang : firstOpt.startsWith('审核') ? st0.uidLi : firstOpt.startsWith('批准') ? st0.uidWang : null; if (!uid) continue; // 带校验重试:React 重渲染可能导致 selectOption 未生效 for (let attempt = 0; attempt < 3; attempt++) { await sel.selectOption({ value: String(uid) }).catch(() => {}); if ((await sel.inputValue().catch(() => '')) === String(uid)) break; await page.waitForTimeout(600); } } await settle(page, 500); for (let i = 0; i < dn; i++) { const v = await dutySelects.nth(i).inputValue().catch(() => ''); if (!v) console.log(`职责下拉 ${i} 未指派成功`); } if (captureShots) { await shot(page, '23-project-settings.png'); record('23-project-settings.png', true, '项目设置弹窗(配置职责步骤:三权指派)'); } await page.getByRole('button', { name: /校验并预览/ }).click(); await page.waitForTimeout(2500); const activateBtn = page.getByRole('button', { name: /确认激活/ }); if (!(await activateBtn.isVisible({ timeout: 8000 }).catch(() => false))){ const errText = await page.locator('[role="alert"]').innerText().catch(() => ''); record('工作流激活', false, `确认激活按钮未出现 ${errText}`); await closeModal(page); return false; } await activateBtn.click(); await page.getByText('项目工作流配置').waitFor({ state: 'hidden', timeout: 15_000 }); await settle(page, 1500); console.log('工作流已激活'); return true; } /** 打开当前项目的 intake 受控文档,返回 docId。 */ async function openIntakeDoc(page) { const catTrigger = page.getByTestId('doc-category-trigger'); await catTrigger.waitFor({ timeout: 15_000 }); await catTrigger.click(); const catItem = page.locator('.dropdown-menu button[data-category="workflow"]').first(); if (await catItem.count()) await catItem.click(); await page.waitForTimeout(500); const trigger = page.getByTestId('doc-selector-trigger'); await trigger.waitFor({ timeout: 15_000 }); await trigger.click(); const item = page .locator('.dropdown-menu button[data-doc-id]') .filter({ hasText: /用户初始需求|需求摸底|intake/i }) .first(); await item.waitFor({ timeout: 15_000 }); const docId = await item.getAttribute('data-doc-id'); await item.click(); await page.waitForTimeout(1200); await settle(page); return docId; } /** 定位本轮对话的 assistant 气泡并等待终态。 */ async function waitChatTerminal(page, promptSnippet, timeoutMs = 180_000) { const userBubble = page .locator('[data-chat-role="user"]') .filter({ hasText: promptSnippet }) .last(); await userBubble.waitFor({ timeout: 30_000 }); const assistant = userBubble.locator( 'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]', ); await assistant.waitFor({ timeout: 60_000 }); const TERMINAL = /^(completed|pending_author_confirmation|failed|cancelled|quality_failed|blocked_pending_governance)$/; try { await page.waitForFunction( ({ re }) => { const els = document.querySelectorAll('[data-chat-role="assistant"]'); const last = els[els.length - 1]; return last && new RegExp(re).test(last.getAttribute('data-chat-status') || ''); }, { re: TERMINAL.source }, { timeout: timeoutMs }, ); const status = await page .locator('[data-chat-role="assistant"]') .last() .getAttribute('data-chat-status'); return { done: true, status }; } catch { const status = await page .locator('[data-chat-role="assistant"]') .last() .getAttribute('data-chat-status') .catch(() => null); return { done: false, status }; } } /** 共享 dev 环境偶发账号被停用:登录前确保演示账号 active */ async function ensureUsersActive(usernames) { const token = await apiLogin(USERS.admin.username, USERS.admin.password); const users = (await api(token, 'GET', '/api/v1/admin/users')).json?.users || []; for (const name of usernames) { const u = users.find((x) => x.username === name); if (u && u.status !== 'active') { await api(token, 'PUT', `/api/v1/admin/users/${u.id}/status`, { status: 'active' }); console.log(`账号 ${name} 已恢复 active`); } } } /** 确保演示项目成员齐备(创建弹窗的成员勾选不可靠,用 API 兜底;幂等) */ async function ensureDemoMembers(projectId) { const token = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); let pid = projectId; if (!pid) { const sessRes = await api(token, 'GET', '/api/v1/chat/sessions'); const sessions = sessRes.json?.sessions || sessRes.json || []; const demo = (Array.isArray(sessions) ? sessions : []).find((s) => (s.title || s.project_name || '').includes(DEMO_PREFIX), ); if (!demo) return null; pid = demo.project_id; } const members = (await api(token, 'GET', `/api/v1/projects/${pid}/members`)).json?.members || []; const have = new Set(members.map((m) => m.user_id)); // 解析演示账号 ID const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); const users = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; const idOf = (uname) => users.find((u) => u.username === uname)?.id; const wanted = [ { uname: 'lizhuguan', duty: 'reviewer' }, { uname: 'wangzong', duty: 'approver' }, ]; for (const w of wanted) { const uid = idOf(w.uname); if (!uid) continue; if (!have.has(uid)) { await api(token, 'POST', `/api/v1/projects/${pid}/members`, { user_id: uid, project_role: 'member', document_duty: w.duty, }); console.log(`项目 ${pid} 添加成员 ${w.uname}(${uid}) duty=${w.duty}`); } } saveState({ ...(projectId ? {} : { demoProjectId: pid }), uidZhang: idOf('zhanggong'), uidLi: idOf('lizhuguan'), uidWang: idOf('wangzong'), }); return pid; } // ─── sections ─── async function secOnboarding() { // 01 登录页(账号密码 Tab) { const { context, page } = await newPage(); await page.goto(BASE + '/'); await page.locator('input[placeholder*="系统用户名"]').waitFor({ timeout: 20_000 }); await settle(page, 800); await shot(page, '01-login.png'); record('01-login.png', true, '登录页-账号密码 Tab'); // 02 注册 Tab const regTab = page.getByRole('button', { name: /用户自主注册/ }); if (await regTab.isVisible({ timeout: 3000 }).catch(() => false)) { await regTab.click(); await page.locator('input[placeholder="如 zhang_san"]').fill('demo02'); await page.locator('input[placeholder="如 张三"]').fill('演示用户02'); await page.locator('input[placeholder="至少 6 位字符"]').fill('123456'); await page.locator('input[placeholder="再次输入密码"]').fill('123456'); await page.locator('input[placeholder="如 研发中心 / 临床部"]').fill('研发中心'); await page.locator('input[placeholder="如 EMP-888"]').fill('EMP-102'); await settle(page, 400); await shot(page, '02-login-register.png'); record('02-login-register.png', true, '注册 Tab 填好示例值(未提交)'); } else { record('02-login-register.png', false, '注册 Tab 不可见(服务端已关闭自主注册)'); } await context.close(); } // 03 首次登录强制改密 { const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); let demoUser = 'demo01'; const usersRes = await api(adminToken, 'GET', '/api/v1/admin/users'); const users = usersRes.json?.users || []; if (users.some((u) => u.username === 'demo01')) { // 已存在:若已完成改密则换后缀账号 demoUser = users.some((u) => u.username === 'demo01') ? 'demo01' : demoUser; } else { const cr = await api(adminToken, 'POST', '/api/v1/admin/users', { username: 'demo01', name: '演示员工01', initial_password: 'InitPass123', role_id: 'role-engineer', position: '方案工程师', department: '研发中心', }); if (cr.status >= 400) throw new Error(`create demo01 failed: ${cr.text}`); } const { context, page } = await newPage(); await uiLogin(page, demoUser, 'InitPass123'); const force = page.getByPlaceholder('请输入初始密码'); if (await force.isVisible({ timeout: 15_000 }).catch(() => false)) { await page.fill('input[placeholder="请输入初始密码"]', 'InitPass123'); await settle(page, 400); await shot(page, '03-first-login-password.png'); record('03-first-login-password.png', true, '首次登录强制改密弹窗'); } else { // demo01 已完成过改密:另建 demo01b await context.close(); const alt = 'demo01b'; const users2 = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; if (!users2.some((u) => u.username === alt)) { await api(adminToken, 'POST', '/api/v1/admin/users', { username: alt, name: '演示员工01', initial_password: 'InitPass123', role_id: 'role-engineer', position: '方案工程师', department: '研发中心', }); } const { context: c2, page: p2 } = await newPage(); await uiLogin(p2, alt, 'InitPass123'); await p2.getByPlaceholder('请输入初始密码').waitFor({ timeout: 15_000 }); await p2.fill('input[placeholder="请输入初始密码"]', 'InitPass123'); await settle(p2, 400); await shot(p2, '03-first-login-password.png'); record('03-first-login-password.png', true, `首次登录强制改密弹窗(账号 ${alt})`); await c2.close(); return; } await context.close(); } } async function secWorkbench() { await ensureUsersActive(['zhanggong']); const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page); await ensureDemoMembers(); // 等聊天输入区与文档区就绪 await page.getByPlaceholder(/向项目助手提问/).waitFor({ timeout: 20_000 }); await settle(page, 1500); await shot(page, '10-workbench-overview.png'); record('10-workbench-overview.png', true, '三栏工作台总览(张工)'); await page.locator('header').screenshot({ path: path.join(OUT, '11-topbar.png') }); record('11-topbar.png', true, '顶栏特写(当前项目 + 项目助手在线)'); await page.locator('aside').screenshot({ path: path.join(OUT, '12-left-rail.png') }); record('12-left-rail.png', true, '左栏项目会话列表特写'); // 13 个人设置 await page.locator('button[title="个人资料与系统偏好设置"]').click(); await page.waitForTimeout(1000); await settle(page, 500); await shot(page, '13-user-settings.png'); record('13-user-settings.png', true, '个人设置弹窗'); await closeModal(page); // 14 我的项目权限 await page.locator('div[title="点击查看账号信息与当前项目各文档法定权限矩阵"]').click(); await page.getByText('当前账号与项目职责权限矩阵').waitFor({ timeout: 10_000 }); await settle(page, 600); await shot(page, '14-my-rights.png'); record('14-my-rights.png', true, '我的项目权限弹窗'); await closeModal(page); await context.close(); } async function secProject() { await ensureUsersActive(['zhanggong']); const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page); await ensureDemoMembers(); // 20 新建立项会话弹窗(填好示例,不提交) await page.getByRole('button', { name: '新建立项会话' }).click(); const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); await heading.waitFor({ timeout: 30_000 }); const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); await modal.locator('input[placeholder*="无线便携式"]').fill('说明书演示-高频电刀系统'); await modal .locator('textarea') .first() .fill('用于普外科高频切割与凝血,需符合 GB 9706.1 与 YY 1057 标准要求。(示例填写,未提交)'); const memberName = modal.getByText('李主管', { exact: true }); if (await memberName.isVisible({ timeout: 5000 }).catch(() => false)) { await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); } await settle(page, 600); await shot(page, '20-create-project.png'); record('20-create-project.png', true, '新建立项会话弹窗(示例已填,未提交)'); const cancel = modal.getByRole('button', { name: '取消', exact: true }); if (await cancel.isVisible().catch(() => false)) await cancel.click(); else await page.keyboard.press('Escape'); await page.waitForTimeout(600); // 21 项目成员弹窗 await page.locator('aside').getByText('项目成员', { exact: true }).click(); await page.getByText('项目成员与职责权限管理').waitFor({ timeout: 10_000 }); await settle(page, 800); await shot(page, '21-project-members.png'); record('21-project-members.png', true, '项目成员与职责分配弹窗'); await page.keyboard.press('Escape'); await page.waitForTimeout(400); // 成员弹窗点遮罩关闭 const membersModal = page.getByText('项目成员与职责权限管理'); if (await membersModal.isVisible().catch(() => false)) { await page.mouse.click(20, 450); await page.waitForTimeout(400); } // 22/23 项目工作流配置弹窗(规划态:选择→配置职责→激活;已激活:只读视图) const eyeBtn = page.locator('button[title="查看工作流设置"]'); const activated = await configureAndActivateWorkflow(page, { captureShots: true }); if (activated) { saveState({ workflowActivated: true }); } else if (await eyeBtn.isVisible({ timeout: 5000 }).catch(() => false)) { // 已激活(重跑场景):顶栏切换器特写 + 冻结只读设置视图 await shot(page, '22-workflow-switcher.png', { clip: { x: 0, y: 0, width: 1600, height: 52 } }); record('22-workflow-switcher.png', true, '顶栏工作流切换器(运行中状态,重跑替代视角)'); await eyeBtn.click(); await page.getByText('项目工作流配置').waitFor({ timeout: 10_000 }); await settle(page, 800); await shot(page, '23-project-settings.png'); record('23-project-settings.png', true, '项目设置弹窗(已激活只读视图,重跑替代视角)'); await closeModal(page); } else { record('22-workflow-switcher.png', false, '未定位到工作流设置入口'); record('23-project-settings.png', false, '未定位到工作流设置入口'); } await context.close(); } const CLAIM_PROJECT_NAME = '说明书演示-内窥镜止血系统'; /** 为李主管/王总监注册项目私聊会话(等价于 UI 添加成员时的 upsertChatSession;幂等) */ async function ensureMemberSessions(pid) { const st = loadState(); for (const [key, uid] of [ ['lizhuguan', st.uidLi], ['wangzong', st.uidWang], ]) { if (!uid || !pid) continue; const token = await apiLogin(USERS[key].username, USERS[key].password); const list = (await api(token, 'GET', `/api/v1/chat/sessions?user_id=${uid}&limit=100`)).json || []; if (!list.some((s) => s.project_id === pid)) { await api(token, 'POST', '/api/v1/chat/sessions', { session_id: `sess-p${pid}-u${uid}`, title: DEMO_PROJECT_NAME, user_id: uid, project_id: pid, }); console.log(`已为 ${key} 注册项目 ${pid} 会话`); } } } /** * 50-doc-claim 补拍专用:认领按钮要求「受控 + 非模板 + 编制人未认领」。 * 主项目文档已被编制(保存正文即自动认领),故用第二个演示项目走真实生成链路, * 编制人不编辑、直接打开文档捕获「认领此文档」按钮。 */ async function secClaim50() { await ensureUsersActive(['zhanggong']); const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page, CLAIM_PROJECT_NAME); const pid = await findProjectIdByName(CLAIM_PROJECT_NAME); if (pid) await ensureDemoMembers(pid); // 激活工作流(intake 纳入人工审批 + 三权指派) await configureAndActivateWorkflow(page, { captureShots: false }); // 让 PI 真实生成 intake 受控交付物(如已生成则跳过) let docId = null; const zhangToken = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); if (pid) { const docs = (await api(zhangToken, 'GET', `/api/v1/projects/${pid}/documents?category=workflow`)).json || []; const intake = docs.find((d) => d.task_key === 'intake'); if (intake && !intake.is_template_only) docId = intake.doc_id; } if (!docId) { const box = page.getByPlaceholder(/向项目助手提问/); const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; await box.fill(genPrompt); await page.getByRole('button', { name: /发送/ }).click(); const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 300_000); console.log(`claim50 编制指令终态: ${term.status} done=${term.done}`); await page.waitForTimeout(3000); } // 刷新后重新进入(拿到最新文档清单),打开 intake 文档但不编辑 await page.reload(); await page.getByText('新建立项会话').waitFor({ timeout: 30_000 }); await settle(page, 1500); await page.locator('aside').getByText(CLAIM_PROJECT_NAME, { exact: false }).first().click(); await page.waitForTimeout(2000); await openIntakeDoc(page); const claimBtn = page.getByRole('button', { name: /认领此文档/ }); if (await claimBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await settle(page, 500); await shot(page, '50-doc-claim.png'); record('50-doc-claim.png', true, '编制人视角「认领此文档」(认领演示项目真实状态)'); await claimBtn.click(); await page.waitForTimeout(1200); } else { // 替代方案:产品当前逻辑下编制人打开/保存非模板文档即自动认领(服务端 author_claimed 与前端 isClaimed 联动), // 「认领此文档」按钮不再可达;截取编制人视角的编制中状态栏(含「已被编制人认领」徽标与「提交送审」按钮)。 const claimedBadge = page.getByText('已被编制人认领'); if (await claimedBadge.isVisible({ timeout: 5000 }).catch(() => false)) { await settle(page, 500); await shot(page, '50-doc-claim.png'); record( '50-doc-claim.png', true, '替代视角:编制中状态栏(已被编制人认领 + 提交送审);认领按钮因自动认领逻辑不可达', ); } else { await shot(page, '50-doc-claim-debug.png'); record('50-doc-claim.png', false, '认领按钮与已认领徽标均未出现(调试图见 50-doc-claim-debug.png)'); } } await context.close(); } async function secChat() { await ensureUsersActive(['zhanggong']); const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page); // 30 真实 PI 对话 const box = page.getByPlaceholder(/向项目助手提问/); await box.waitFor({ timeout: 20_000 }); // 已有相同对话则直接截图,避免重复发问 const existing = page.locator('[data-chat-role="user"]').filter({ hasText: CHAT_PROMPT.slice(0, 12) }); if (await existing.first().isVisible({ timeout: 3000 }).catch(() => false)) { const term = await waitChatTerminal(page, CHAT_PROMPT.slice(0, 12), 10_000); await settle(page, 800); await shot(page, '30-chat-real.png'); record('30-chat-real.png', true, `真实 PI 对话(复用已有会话,状态 ${term.status})`); } else { await box.fill(CHAT_PROMPT); await page.getByRole('button', { name: /发送/ }).click(); const term = await waitChatTerminal(page, CHAT_PROMPT.slice(0, 12), 180_000); await settle(page, 1000); // 滚动到对话底部 await page.mouse.wheel(0, 3000); await page.waitForTimeout(500); await shot(page, '30-chat-real.png'); record( '30-chat-real.png', term.done, term.done ? `真实 PI 对话完成(状态 ${term.status})` : `3 分钟内未完成,截流式进行中状态(当前 ${term.status || '未知'})`, ); } // 32 意图计划确认卡片(自然出现才截) const planCard = page.getByTestId('plan-confirm-card'); if (await planCard.first().isVisible({ timeout: 3000 }).catch(() => false)) { await shot(page, '32-intent-plan-card.png'); record('32-intent-plan-card.png', true, '意图计划确认卡片'); } else { record('32-intent-plan-card.png', false, '本轮对话未出现意图计划确认卡片(缺图)'); } // 31 附件上传状态(不发送) const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manual-shot-')); const f1 = path.join(tmpDir, '用户需求访谈纪要.txt'); const f2 = path.join(tmpDir, '竞品参数对照表.csv'); fs.writeFileSync(f1, '用户需求访谈纪要(示例附件)\n1. 预期用途:骨科微创手术超声吸引。\n2. 目标用户:外科医生。\n'); fs.writeFileSync(f2, '参数,竞品A,竞品B\n频率,kHz,25,36\n功率,W,120,150\n'); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles([f1, f2]); await page.getByText('用户需求访谈纪要.txt').waitFor({ timeout: 10_000 }); await settle(page, 500); await shot(page, '31-chat-attachment.png'); record('31-chat-attachment.png', true, '输入区附件上传状态(2 个文件,未发送)'); // 清理附件 for (const b of await page.locator('button[title="移除此文档"]').all()) { await b.click().catch(() => {}); } // 33 历史记录/搜索 await page.locator('button[title*="搜索历史对话"]').click(); await page.waitForTimeout(800); const searchInput = page.locator('input[placeholder*="搜索"], input[placeholder*="关键词"]').first(); if (await searchInput.isVisible({ timeout: 5000 }).catch(() => false)) { await searchInput.fill('需求'); await page.waitForTimeout(1500); } await settle(page, 500); await shot(page, '33-history.png'); record('33-history.png', true, '历史记录搜索界面(关键词「需求」)'); await closeModal(page); await context.close(); } async function secDoc() { await ensureUsersActive(['zhanggong']); const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page); // intake 受控文档可能尚未生成(选择器 0 份):先让 PI 真实编制首轮交付物 let docId = await openIntakeDoc(page).catch(() => null); if (!docId) { console.log('受控文档尚未生成,发送真实编制指令…'); const box = page.getByPlaceholder(/向项目助手提问/); const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; await box.fill(genPrompt); await page.getByRole('button', { name: /发送/ }).click(); const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 300_000); console.log(`编制指令终态: ${term.status} done=${term.done}`); await page.waitForTimeout(3000); docId = await openIntakeDoc(page).catch(() => null); } if (docId) saveState({ intakeDocId: docId }); // 40 文档块编辑态 const pane = page.getByTestId('active-document-pane'); let edited = false; const tryEdit = async () => { const block = pane.getByTestId('document-block').first(); if (!(await block.isVisible({ timeout: 10_000 }).catch(() => false))) return false; await block.dblclick(); const dialog = page.getByRole('dialog', { name: '编辑受控块' }); if (!(await dialog.isVisible({ timeout: 8000 }).catch(() => false))) return false; const editor = dialog.locator('textarea, [contenteditable="true"]').first(); const cur = await editor.inputValue().catch(() => ''); if (!cur.includes('说明书截图演示')) { await editor.fill((cur ? cur + '\n\n' : '') + '本段落用于用户说明书截图演示编辑态。'); } await settle(page, 400); await shot(page, '40-doc-editor.png'); record('40-doc-editor.png', true, '文档块编辑弹窗(双击段落进入)'); await dialog.getByRole('button', { name: '保存修改', exact: true }).click(); await page.waitForTimeout(1500); return true; }; edited = await tryEdit(); if (!edited) { // 模板预览态无法编辑:让 PI 真实生成首轮受控交付物后重试 console.log('模板预览态,发送真实编制指令生成交付物…'); const box = page.getByPlaceholder(/向项目助手提问/); const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; await box.fill(genPrompt); await page.getByRole('button', { name: /发送/ }).click(); const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 240_000); console.log(`编制指令终态: ${term.status} done=${term.done}`); await openIntakeDoc(page); await page.waitForTimeout(2000); edited = await tryEdit(); } if (!edited) record('40-doc-editor.png', false, '文档块编辑弹窗未能打开'); // 41 版本历史抽屉 const histBtn = page.getByRole('button', { name: '版本历史', exact: true }); if (await histBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await histBtn.click(); const drawer = page.getByRole('dialog', { name: '版本历史' }); await drawer.waitFor({ timeout: 10_000 }); await settle(page, 500); await shot(page, '41-version-history.png'); record('41-version-history.png', true, '版本历史抽屉'); await drawer.getByRole('button', { name: '关闭' }).click(); } else { record('41-version-history.png', false, '版本历史按钮不可用(版本账本未就绪)'); } // 42 导出菜单展开 const exportBtn = page.locator('button[title^="导出交付物"]'); if (await exportBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await exportBtn.click(); await page.getByText('选择交付物导出渲染格式').waitFor({ timeout: 5000 }); await settle(page, 300); await shot(page, '42-export.png'); record('42-export.png', true, '导出菜单展开(DOCX/XLSX/Markdown)'); await page.keyboard.press('Escape'); await page.mouse.click(800, 500); } else { record('42-export.png', false, '导出入口不可见'); } // 43 文档可见性设置弹窗 const visBtn = page.getByRole('button', { name: /权限覆盖|已设权限覆盖/ }); if (await visBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await visBtn.click(); await page.getByText(/单文档可见性覆盖策略/).waitFor({ timeout: 10_000 }); await settle(page, 500); await shot(page, '43-doc-visibility.png'); record('43-doc-visibility.png', true, '文档可见性设置弹窗'); await closeModal(page); } else { record('43-doc-visibility.png', false, '权限覆盖按钮不可见(当前文档/角色无入口)'); } await context.close(); } async function docgovState(token, docId) { const r = await api(token, 'GET', `/api/v1/docgov/document?doc_id=${encodeURIComponent(docId)}`); return r.status === 200 ? r.json : null; } async function openDemoAndDoc(page) { await ensureDemoProject(page); return openIntakeDoc(page); } async function secDocgov() { await ensureUsersActive(['zhanggong','lizhuguan','wangzong']); await ensureDemoMembers(); await ensureMemberSessions(loadState().demoProjectId); const zhangToken = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); const liToken = await apiLogin(USERS.lizhuguan.username, USERS.lizhuguan.password); const wangToken = await apiLogin(USERS.wangzong.username, USERS.wangzong.password); // 先拿到 docId:优先 state 文件,否则用张工的 UI 打开一次 let docId = loadState().intakeDocId || null; if (!docId) { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); docId = await openDemoAndDoc(page); await context.close(); if (docId) saveState({ intakeDocId: docId }); } if (!docId) { record('50~55', false, '未能定位 intake 受控文档 docId'); return; } const rec0 = await docgovState(zhangToken, docId); const state = rec0?.current_state || 'drafting'; const claimed = Boolean(rec0?.author_claimed); console.log(`docgov: docId=${docId} state=${state} claimed=${claimed} duties=${rec0?.author_name}/${rec0?.reviewer_name}/${rec0?.approver_name}`); // 50 编制人认领 if (state === 'drafting' && !claimed) { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await openDemoAndDoc(page); const claimBtn = page.getByRole('button', { name: /认领此文档/ }); if (await claimBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await settle(page, 500); await shot(page, '50-doc-claim.png'); record('50-doc-claim.png', true, '编制人视角「认领此文档」'); await claimBtn.click(); await page.waitForTimeout(1500); } else { record('50-doc-claim.png', false, '认领按钮不可见(职责未分配或状态不符)'); } // 51 提交送审 const submitBtn = page.getByRole('button', { name: /提交送审/ }); if (await submitBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await settle(page, 500); await shot(page, '51-doc-submit-review.png'); record('51-doc-submit-review.png', true, '编制人「提交送审」(DocGovActionBar)'); await submitBtn.click(); await page.waitForTimeout(1500); } else { record('51-doc-submit-review.png', false, '提交送审按钮不可见'); } await context.close(); } else if (state === 'drafting' && claimed) { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await openDemoAndDoc(page); const submitBtn = page.getByRole('button', { name: /提交送审/ }); if (await submitBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await shot(page, '51-doc-submit-review.png'); record('51-doc-submit-review.png', true, '编制人「提交送审」(已认领续跑)'); await submitBtn.click(); await page.waitForTimeout(1500); } await context.close(); } else { record('50-doc-claim.png / 51-doc-submit-review.png', false, `文档已不在编制态(${state}),跳过(沿用已有截图)`); } const rec1 = await docgovState(zhangToken, docId); // 52 审核人审核 if (rec1?.current_state === 'reviewing') { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.lizhuguan.username, USERS.lizhuguan.password); await openDemoAndDoc(page); const passBtn = page.getByRole('button', { name: /审核通过/ }); if (await passBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await settle(page, 500); await shot(page, '52-doc-review.png'); record('52-doc-review.png', true, '审核人视角审核决议(通过/驳回)'); await passBtn.click(); await page.waitForTimeout(1500); } else { record('52-doc-review.png', false, '审核按钮不可见(李主管非该文档审核负责人?)'); } await context.close(); } else { record('52-doc-review.png', false, `文档不在审核态(${rec1?.current_state}),跳过`); } const rec2 = await docgovState(zhangToken, docId); // 53 批准人批准 if (rec2?.current_state === 'approving') { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.wangzong.username, USERS.wangzong.password); await openDemoAndDoc(page); const approveBtn = page.getByRole('button', { name: /终审批准生效/ }); if (await approveBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { await settle(page, 500); await shot(page, '53-doc-approve.png'); record('53-doc-approve.png', true, '批准人视角终审批准'); await approveBtn.click(); await page.waitForTimeout(1500); } else { record('53-doc-approve.png', false, '批准按钮不可见(王总监非该文档批准负责人?)'); } await context.close(); } else { record('53-doc-approve.png', false, `文档不在批准态(${rec2?.current_state}),跳过`); } // 54/55 阶段门禁 const rec3 = await docgovState(zhangToken, docId); if (rec3?.current_state === 'approved') { const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); await ensureDemoProject(page); await page.waitForTimeout(2500); // StageAdvanceBanner 轮询加载 await settle(page, 800); const readyBanner = page.getByText(/必需文档已批准,可推进/); const waitBanner = page.getByText(/等待必需文档批准/); if (await readyBanner.isVisible({ timeout: 10_000 }).catch(() => false)) { await shot(page, '54-stage-gate-ready.png'); record('54-stage-gate-ready.png', true, '阶段门禁就绪 Banner'); const advBtn = page.getByRole('button', { name: /确认推进/ }); if (await advBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await advBtn.click(); await page.waitForTimeout(2000); await settle(page, 800); await shot(page, '55-stage-advance.png'); record('55-stage-advance.png', true, '项目创建人确认推进阶段(已推进)'); } else { record('55-stage-advance.png', false, '确认推进按钮不可见(当前账号非创建人?)'); } } else if (await waitBanner.isVisible({ timeout: 3000 }).catch(() => false)) { await shot(page, '54-stage-gate-ready.png'); record('54-stage-gate-ready.png', false, '门禁仍等待其他必需文档批准(截的是等待态 Banner)'); record('55-stage-advance.png', false, '门禁未就绪,无法推进'); } else { record('54-stage-gate-ready.png', false, 'StageAdvanceBanner 未出现(无 live 工作流实例?)'); record('55-stage-advance.png', false, '同上'); } await context.close(); } else { record('54-stage-gate-ready.png', false, `文档未批准(${rec3?.current_state}),门禁无法就绪`); record('55-stage-advance.png', false, '同上'); } } async function secAdmin() { const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); // 为 62 准备一条待审批注册记录:临时开启审批 → 注册 demo02 const cfg0 = await fetch(`${BASE}/api/v1/auth/public-config`).then((r) => r.json()); const users = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; const demo02 = users.find((u) => u.username === 'demo02'); const needPending = !demo02 || demo02.status !== 'pending_approval'; if (needPending) { await api(adminToken, 'PUT', '/api/v1/admin/config', { allow_registration: true, require_approval: true, }); if (!demo02) { await fetch(`${BASE}/api/v1/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'demo02', password: '123456', name: '演示用户02', department: '研发中心', employee_id: 'EMP-102', }), }).then((r) => r.text()); } } const { context, page } = await newPage(); await uiLoginWorkbench(page, USERS.admin.username, USERS.admin.password); await page.locator('button[title*="系统管理与准入治理"]').click(); await page.getByText('系统管理', { exact: true }).waitFor({ timeout: 15_000 }); await settle(page, 1500); // 60 系统运行总览(默认页) await shot(page, '60-admin-overview.png'); record('60-admin-overview.png', true, '系统运行总览'); // 61 用户与准入治理 + 开通受控员工账号弹窗 await page.getByRole('button', { name: '用户与准入治理' }).click(); await page.getByRole('button', { name: '开通受控员工账号' }).waitFor({ timeout: 15_000 }); await settle(page, 1200); await page.getByRole('button', { name: '开通受控员工账号' }).click(); const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); await form.waitFor({ timeout: 10_000 }); await form.locator('input[placeholder="如 li_si"]').fill('demo03'); await form.locator('input[placeholder="如 李四"]').fill('演示员工03'); const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); await passBox.fill('InitPass123'); await settle(page, 400); await shot(page, '61-admin-users.png'); record('61-admin-users.png', true, '账号列表 + 开通受控员工账号弹窗(示例已填,未提交)'); // 关闭弹窗(不提交) const formCancel = form.getByRole('button', { name: /取消|关闭/ }); if (await formCancel.first().isVisible({ timeout: 2000 }).catch(() => false)) { await formCancel.first().click(); } else { await page.keyboard.press('Escape'); } await page.waitForTimeout(500); // 62 注册审批中心 await page.getByRole('button', { name: /注册审批中心/ }).click(); await page.getByText('新员工注册审批与职位指派中心').waitFor({ timeout: 10_000 }); await settle(page, 800); await shot(page, '62-admin-approvals.png'); const hasPending = await page.getByText('演示用户02').isVisible({ timeout: 3000 }).catch(() => false); record('62-admin-approvals.png', true, hasPending ? '注册审批中心(含 demo02 待审核记录)' : '注册审批中心(当前无待审核记录)'); // 63 准入策略 await page.getByRole('button', { name: /系统准入策略/ }).click(); await page.getByText(/保存并立即应用准入策略/).waitFor({ timeout: 10_000 }); await settle(page, 500); await shot(page, '63-admin-access-policy.png'); record('63-admin-access-policy.png', true, '准入策略开关'); // 64 RBAC await page.getByRole('button', { name: 'RBAC 角色权限' }).click(); await settle(page, 1500); await shot(page, '64-admin-roles.png'); record('64-admin-roles.png', true, 'RBAC 角色权限矩阵'); // 65 特性功能开关 await page.getByRole('button', { name: '特性功能开关' }).click(); await settle(page, 1200); await shot(page, '65-admin-features.png'); record('65-admin-features.png', true, '特性功能开关'); // 66 知识库连接 await page.getByRole('button', { name: '知识库连接' }).click(); await settle(page, 1200); await shot(page, '66-admin-kb.png'); record('66-admin-kb.png', true, '知识库连接'); // 67 工作流定义 await page.getByRole('button', { name: '工作流定义' }).click(); await settle(page, 1200); await shot(page, '67-admin-workflows.png'); record('67-admin-workflows.png', true, '工作流定义'); // 68 文档与模板库 await page.getByRole('button', { name: '文档与模板库' }).click(); await settle(page, 1200); await shot(page, '68-admin-templates.png'); record('68-admin-templates.png', true, '文档与模板库'); await context.close(); // 恢复准入策略为采集前状态 await api(adminToken, 'PUT', '/api/v1/admin/config', { allow_registration: cfg0.allow_registration, require_approval: cfg0.require_approval, }); console.log(`准入策略已恢复为 ${JSON.stringify(cfg0)}`); } // ─── main ─── const SECTIONS = { onboarding: secOnboarding, claim50: secClaim50, workbench: secWorkbench, project: secProject, chat: secChat, doc: secDoc, docgov: secDocgov, admin: secAdmin, }; const args = process.argv.slice(2).filter((a) => !a.startsWith('--')); const selected = args.length === 0 || args.includes('all') ? Object.keys(SECTIONS) : args; browser = await chromium.launch({ headless: true, channel: 'chromium' }); try { for (const s of selected) { if (!SECTIONS[s]) { console.log(`未知 section: ${s}(可选 ${Object.keys(SECTIONS).join('|')}|all)`); continue; } console.log(`\n=== section: ${s} ===`); try { await SECTIONS[s](); } catch (err) { console.error(`[FAIL] section ${s}: ${err.message}`); record(`section:${s}`, false, `异常中断: ${err.message}`); } } } finally { await browser.close(); } fs.writeFileSync( path.join(OUT, '_capture-report.json'), JSON.stringify({ capturedAt: new Date().toISOString(), results }, null, 2), ); const fails = results.filter((r) => !r.ok); console.log(`\n完成:${results.length - fails.length}/${results.length} 项成功,报告见 images/_capture-report.json`);