Files
Agent-System/docs/user-manual/scripts/capture-screenshots.mjs
T

1159 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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`);