1159 lines
50 KiB
JavaScript
1159 lines
50 KiB
JavaScript
#!/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`);
|