diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 9df0c430..03b71bc9 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -14,6 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done | | Cursor-Auto | 2026-09-18-wave7-roadmap-orchestration | docs/wave7-roadmap-orchestration | - | roadmap-orchestration | done | | Cursor-Auto | 2026-09-18-wave6-doc-drift | docs/wave6-doc-drift | - | doc-drift | done | | Cursor-Auto | 2026-09-18-wave5-deadcode-decisions | fix/wave5-deadcode-decisions | - | deadcode-cleanup | done | @@ -140,7 +141,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-18 | Wave 7 编排确认已合入本地 dev(纯文档,无容器冒烟;见 [决策记录](./short-term/2026-09-18-wave7-roadmap-orchestration.md)) | - | +| Kimi | merging | 2026-09-18 | feature/user-manual 合入 dev(纯文档,说明书四格式);此前 Wave 7 编排已合入 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-18-user-manual.md b/.doc/ops/short-term/2026-09-18-user-manual.md new file mode 100644 index 00000000..134faca6 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-user-manual.md @@ -0,0 +1,58 @@ +--- +title: "系统使用说明书(含真实截图,md/word/pdf/html 四格式)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Kimi +branch: feature/user-manual +worktree: .worktrees/feature-user-manual +mutex: docs +--- + +# 系统使用说明书(含真实截图,md/word/pdf/html 四格式) + +## 目标 + +产出覆盖「从 0 进入系统 → 登录/注册 → 管理员角色权限配置 → 日常使用 → 审批全流程」的终端用户说明书,配 dev 环境真实操作截图(含真实 PI 对话),交付 Markdown / HTML / PDF / Word 四种格式于 `docs/user-manual/`。 + +## 范围 + +- dev 环境(127.0.0.1:8080)Playwright 实操截图约 30 张(登录、工作台、立项、对话、文档、审批链、管理台 8 分组) +- `docs/user-manual/manual.md` 十章中文说明书(内容以代码为准) +- `docs/user-manual/scripts/`(截图与格式生成脚本,可重跑) +- `docs/user-manual/dist/`(html/pdf/docx 产物) + +## 非目标 + +- 不合入 dev、不 push(用户未要求) +- 不改动任何产品代码 +- 不覆盖 control-center/web(空壳)与 engineering-dashboard(工程内部) + +## 步骤 + +1. [x] 分支 + 登记 +2. [x] 环境预检(健康、登录凭证、中文字体、chromium) +3. [x] 截图脚本 `capture-screenshots.mjs` 并采集 +4. [x] 真实 PI 对话截图 +5. [x] 三责审批链 + 阶段门禁截图(zhanggong/lizhuguan/wangzong) +6. [x] 撰写 manual.md +7. [x] build-formats.mjs 生成 html/pdf/docx(pandoc 缺失时 .doc 兜底) +8. [x] 校验(图片引用、PDF 渲染抽查、docx 媒体嵌入) +9. [x] 提交 + 更新 CURRENT + +## 验收标准 + +- [x] 四种格式文件齐备且可打开 +- [x] manual.md 图片引用全部存在,无孤图 +- [x] PDF 中文无乱码、图片正常嵌入 +- [x] 说明书章节覆盖:进入系统/登录/注册/管理员配置角色权限/项目使用/对话/文档/审批/FAQ + +## 相关链接 + +- Checklist: - +- Handoff: - +- Test report: - + +## 证据 / 备注 + +- 演示账号(dev 种子,密码 123456):zhanggong / lizhuguan / wangzong / guest;管理员 admin(密码取 configs/.env.dev) diff --git a/docs/user-manual/README.md b/docs/user-manual/README.md new file mode 100644 index 00000000..68a03acd --- /dev/null +++ b/docs/user-manual/README.md @@ -0,0 +1,27 @@ +# 系统使用说明书(水木东方 · 医疗器械 AI 协同工作台) + +面向终端用户与系统管理员的完整使用说明书,覆盖:从 0 进入系统、登录/注册、管理员配置角色与权限、项目使用、AI 对话、文档工作台、审批全流程、FAQ。全部截图为 dev 环境真实操作采集(含真实 PI Agent 对话)。 + +## 文件说明 + +| 路径 | 说明 | +|------|------| +| [manual.md](./manual.md) | 说明书主文档(Markdown 源,SSOT) | +| [images/](./images/) | 全部 UI 截图(PNG,编号对应章节) | +| [dist/系统使用说明书.html](./dist/系统使用说明书.html) | HTML 版(单文件,图片已嵌入,浏览器直接打开) | +| [dist/系统使用说明书.pdf](./dist/系统使用说明书.pdf) | PDF 版(A4) | +| [dist/系统使用说明书.docx](./dist/系统使用说明书.docx) | Word 版 | +| [scripts/capture-screenshots.mjs](./scripts/capture-screenshots.mjs) | 截图采集脚本(Playwright,需 dev 环境运行在 8080) | +| [scripts/build-formats.mjs](./scripts/build-formats.mjs) | 格式生成脚本(pandoc + playwright) | + +## 重新生成 + +```bash +# 重新采集截图(dev 环境需在运行,会创建「说明书演示」前缀的演示数据) +node docs/user-manual/scripts/capture-screenshots.mjs + +# 重新生成 HTML / PDF / DOCX +node docs/user-manual/scripts/build-formats.mjs +``` + +依赖:pandoc(`brew install pandoc`)、playwright(复用仓库 `web/node_modules`,chromium 已安装)。 diff --git a/docs/user-manual/images/01-login.png b/docs/user-manual/images/01-login.png new file mode 100644 index 00000000..72b5d9a3 Binary files /dev/null and b/docs/user-manual/images/01-login.png differ diff --git a/docs/user-manual/images/02-login-register.png b/docs/user-manual/images/02-login-register.png new file mode 100644 index 00000000..b7256c40 Binary files /dev/null and b/docs/user-manual/images/02-login-register.png differ diff --git a/docs/user-manual/images/03-first-login-password.png b/docs/user-manual/images/03-first-login-password.png new file mode 100644 index 00000000..443c9ebc Binary files /dev/null and b/docs/user-manual/images/03-first-login-password.png differ diff --git a/docs/user-manual/images/10-workbench-overview.png b/docs/user-manual/images/10-workbench-overview.png new file mode 100644 index 00000000..40cec24f Binary files /dev/null and b/docs/user-manual/images/10-workbench-overview.png differ diff --git a/docs/user-manual/images/11-topbar.png b/docs/user-manual/images/11-topbar.png new file mode 100644 index 00000000..e218704b Binary files /dev/null and b/docs/user-manual/images/11-topbar.png differ diff --git a/docs/user-manual/images/12-left-rail.png b/docs/user-manual/images/12-left-rail.png new file mode 100644 index 00000000..ae9a51a3 Binary files /dev/null and b/docs/user-manual/images/12-left-rail.png differ diff --git a/docs/user-manual/images/13-user-settings.png b/docs/user-manual/images/13-user-settings.png new file mode 100644 index 00000000..319d3e71 Binary files /dev/null and b/docs/user-manual/images/13-user-settings.png differ diff --git a/docs/user-manual/images/14-my-rights.png b/docs/user-manual/images/14-my-rights.png new file mode 100644 index 00000000..3ae0312e Binary files /dev/null and b/docs/user-manual/images/14-my-rights.png differ diff --git a/docs/user-manual/images/20-create-project.png b/docs/user-manual/images/20-create-project.png new file mode 100644 index 00000000..9d6a9d5a Binary files /dev/null and b/docs/user-manual/images/20-create-project.png differ diff --git a/docs/user-manual/images/21-project-members.png b/docs/user-manual/images/21-project-members.png new file mode 100644 index 00000000..24a07c00 Binary files /dev/null and b/docs/user-manual/images/21-project-members.png differ diff --git a/docs/user-manual/images/22-workflow-switcher.png b/docs/user-manual/images/22-workflow-switcher.png new file mode 100644 index 00000000..daaf614d Binary files /dev/null and b/docs/user-manual/images/22-workflow-switcher.png differ diff --git a/docs/user-manual/images/23-project-settings.png b/docs/user-manual/images/23-project-settings.png new file mode 100644 index 00000000..9377ea2f Binary files /dev/null and b/docs/user-manual/images/23-project-settings.png differ diff --git a/docs/user-manual/images/30-chat-real.png b/docs/user-manual/images/30-chat-real.png new file mode 100644 index 00000000..58e0e6a8 Binary files /dev/null and b/docs/user-manual/images/30-chat-real.png differ diff --git a/docs/user-manual/images/31-chat-attachment.png b/docs/user-manual/images/31-chat-attachment.png new file mode 100644 index 00000000..acc11e17 Binary files /dev/null and b/docs/user-manual/images/31-chat-attachment.png differ diff --git a/docs/user-manual/images/33-history.png b/docs/user-manual/images/33-history.png new file mode 100644 index 00000000..ad4c6ce4 Binary files /dev/null and b/docs/user-manual/images/33-history.png differ diff --git a/docs/user-manual/images/40-doc-editor.png b/docs/user-manual/images/40-doc-editor.png new file mode 100644 index 00000000..f2bb2801 Binary files /dev/null and b/docs/user-manual/images/40-doc-editor.png differ diff --git a/docs/user-manual/images/41-version-history.png b/docs/user-manual/images/41-version-history.png new file mode 100644 index 00000000..92d09e7d Binary files /dev/null and b/docs/user-manual/images/41-version-history.png differ diff --git a/docs/user-manual/images/42-export.png b/docs/user-manual/images/42-export.png new file mode 100644 index 00000000..8f4a692c Binary files /dev/null and b/docs/user-manual/images/42-export.png differ diff --git a/docs/user-manual/images/43-doc-visibility.png b/docs/user-manual/images/43-doc-visibility.png new file mode 100644 index 00000000..25ff5680 Binary files /dev/null and b/docs/user-manual/images/43-doc-visibility.png differ diff --git a/docs/user-manual/images/50-doc-claim.png b/docs/user-manual/images/50-doc-claim.png new file mode 100644 index 00000000..83259c03 Binary files /dev/null and b/docs/user-manual/images/50-doc-claim.png differ diff --git a/docs/user-manual/images/51-doc-submit-review.png b/docs/user-manual/images/51-doc-submit-review.png new file mode 100644 index 00000000..a3575a3c Binary files /dev/null and b/docs/user-manual/images/51-doc-submit-review.png differ diff --git a/docs/user-manual/images/52-doc-review.png b/docs/user-manual/images/52-doc-review.png new file mode 100644 index 00000000..e0920083 Binary files /dev/null and b/docs/user-manual/images/52-doc-review.png differ diff --git a/docs/user-manual/images/53-doc-approve.png b/docs/user-manual/images/53-doc-approve.png new file mode 100644 index 00000000..ef4cbb34 Binary files /dev/null and b/docs/user-manual/images/53-doc-approve.png differ diff --git a/docs/user-manual/images/54-stage-gate-ready.png b/docs/user-manual/images/54-stage-gate-ready.png new file mode 100644 index 00000000..5c259e35 Binary files /dev/null and b/docs/user-manual/images/54-stage-gate-ready.png differ diff --git a/docs/user-manual/images/55-stage-advance.png b/docs/user-manual/images/55-stage-advance.png new file mode 100644 index 00000000..bf5d37e0 Binary files /dev/null and b/docs/user-manual/images/55-stage-advance.png differ diff --git a/docs/user-manual/images/60-admin-overview.png b/docs/user-manual/images/60-admin-overview.png new file mode 100644 index 00000000..d6686bd0 Binary files /dev/null and b/docs/user-manual/images/60-admin-overview.png differ diff --git a/docs/user-manual/images/61-admin-users.png b/docs/user-manual/images/61-admin-users.png new file mode 100644 index 00000000..b2735111 Binary files /dev/null and b/docs/user-manual/images/61-admin-users.png differ diff --git a/docs/user-manual/images/62-admin-approvals.png b/docs/user-manual/images/62-admin-approvals.png new file mode 100644 index 00000000..1d50c803 Binary files /dev/null and b/docs/user-manual/images/62-admin-approvals.png differ diff --git a/docs/user-manual/images/63-admin-access-policy.png b/docs/user-manual/images/63-admin-access-policy.png new file mode 100644 index 00000000..94c533b1 Binary files /dev/null and b/docs/user-manual/images/63-admin-access-policy.png differ diff --git a/docs/user-manual/images/64-admin-roles.png b/docs/user-manual/images/64-admin-roles.png new file mode 100644 index 00000000..4b0963d4 Binary files /dev/null and b/docs/user-manual/images/64-admin-roles.png differ diff --git a/docs/user-manual/images/65-admin-features.png b/docs/user-manual/images/65-admin-features.png new file mode 100644 index 00000000..e96c1f5a Binary files /dev/null and b/docs/user-manual/images/65-admin-features.png differ diff --git a/docs/user-manual/images/66-admin-kb.png b/docs/user-manual/images/66-admin-kb.png new file mode 100644 index 00000000..1969f1ed Binary files /dev/null and b/docs/user-manual/images/66-admin-kb.png differ diff --git a/docs/user-manual/images/67-admin-workflows.png b/docs/user-manual/images/67-admin-workflows.png new file mode 100644 index 00000000..920b747e Binary files /dev/null and b/docs/user-manual/images/67-admin-workflows.png differ diff --git a/docs/user-manual/images/68-admin-templates.png b/docs/user-manual/images/68-admin-templates.png new file mode 100644 index 00000000..85d18f7d Binary files /dev/null and b/docs/user-manual/images/68-admin-templates.png differ diff --git a/docs/user-manual/images/_capture-report.json b/docs/user-manual/images/_capture-report.json new file mode 100644 index 00000000..de3dacb3 --- /dev/null +++ b/docs/user-manual/images/_capture-report.json @@ -0,0 +1,75 @@ +{ + "capturedAt": "2026-09-18T04:02:39.919Z", + "results": [ + { + "file": "10-workbench-overview.png", + "ok": true, + "note": "三栏工作台总览(张工)" + }, + { + "file": "11-topbar.png", + "ok": true, + "note": "顶栏特写(当前项目 + 项目助手在线)" + }, + { + "file": "12-left-rail.png", + "ok": true, + "note": "左栏项目会话列表特写" + }, + { + "file": "13-user-settings.png", + "ok": true, + "note": "个人设置弹窗" + }, + { + "file": "14-my-rights.png", + "ok": true, + "note": "我的项目权限弹窗" + }, + { + "file": "60-admin-overview.png", + "ok": true, + "note": "系统运行总览" + }, + { + "file": "61-admin-users.png", + "ok": true, + "note": "账号列表 + 开通受控员工账号弹窗(示例已填,未提交)" + }, + { + "file": "62-admin-approvals.png", + "ok": true, + "note": "注册审批中心(含 demo02 待审核记录)" + }, + { + "file": "63-admin-access-policy.png", + "ok": true, + "note": "准入策略开关" + }, + { + "file": "64-admin-roles.png", + "ok": true, + "note": "RBAC 角色权限矩阵" + }, + { + "file": "65-admin-features.png", + "ok": true, + "note": "特性功能开关" + }, + { + "file": "66-admin-kb.png", + "ok": true, + "note": "知识库连接" + }, + { + "file": "67-admin-workflows.png", + "ok": true, + "note": "工作流定义" + }, + { + "file": "68-admin-templates.png", + "ok": true, + "note": "文档与模板库" + } + ] +} \ No newline at end of file diff --git a/docs/user-manual/manual.md b/docs/user-manual/manual.md new file mode 100644 index 00000000..86c182f8 --- /dev/null +++ b/docs/user-manual/manual.md @@ -0,0 +1,472 @@ +# 水木东方 · 医疗器械 AI 协同工作台 — 系统使用说明书 + +| 项目 | 说明 | +|------|------| +| 文档版本 | v1.0 | +| 编写日期 | 2026-09-18 | +| 适用环境 | dev / test / main 三环境通用(截图取自 dev 环境) | +| 适用读者 | 系统管理员、研发总监、技术主管、方案工程师、外部审计/访客 | +| 配套格式 | Markdown(本文)/ HTML / PDF / Word | + +--- + +## 目录 + +1. [系统简介](#1-系统简介) +2. [快速开始:从 0 进入系统](#2-快速开始从-0-进入系统) +3. [界面导览:三栏工作台](#3-界面导览三栏工作台) +4. [管理员指南:用户、角色与权限](#4-管理员指南用户角色与权限) +5. [项目使用:立项与成员配置](#5-项目使用立项与成员配置) +6. [对话与 AI 项目助手](#6-对话与-ai-项目助手) +7. [文档工作台](#7-文档工作台) +8. [审批全流程](#8-审批全流程) +9. [角色权限速查与典型动线](#9-角色权限速查与典型动线) +10. [常见问题 FAQ](#10-常见问题-faq) + +--- + +## 1. 系统简介 + +### 1.1 系统定位 + +**水木东方 · 医疗器械 AI 协同工作台** 是一个面向企业与团队的多用户工作流与对话智能体平台,定位于医疗器械受控研发场景(NMPA / ISO 13485 语境)。它将「AI 对话助手」与「受控文档治理」结合:项目成员通过对话推进项目,AI 助手协助沉淀研发文档,文档经过**编制 → 审核 → 批准**三责分离的审批链后归档生效,阶段全部必需文档批准后项目方可推进到下一阶段。 + +### 1.2 三大核心能力 + +1. **受控 RBAC 权限体系**:系统级角色权限(RBAC)+ 文档级职责角色(编制/审核/批准)+ 项目级成员职责,三层隔离。 +2. **项目助手(AI Agent)**:每个项目会话配备项目助手,支持 SSE 流式对话、意图识别、计划确认卡片、知识库检索推荐。 +3. **三层文档隔离**:交付物分为「工作流受控」「项目共享」「个人私密」三类,受控文档全生命周期留痕,支持版本历史、修订、导出 DOCX/XLSX。 + +### 1.3 主要功能一览 + +| 模块 | 功能 | +|------|------| +| 登录与准入 | 账号密码登录、用户自主注册、注册审批、首次登录强制改密 | +| 工作台 | 三栏布局:项目会话列表 / 对话区 / 交付物文档区 | +| 项目管理 | 立项会话、成员与职责分配、工作流切换、阶段门禁推进 | +| 对话 | 流式对话、附件上传(每轮最多 5 个)、意图计划卡片、历史搜索与导出 | +| 文档 | 块编辑、编辑租约锁、版本历史与对比、评论、可见性覆盖、导出 | +| 审批 | 文档三责审批(编制/审核/批准)、阶段门禁、注册审批、修改建议审批 | +| 系统管理台 | 运行总览、知识库连接、特性开关、设置白名单、工作流定义、RBAC 角色权限、用户与准入治理、文档模板库 | +| 知识库 | 跨环境共享的向量知识库(RAG),检索结果在对话中以推荐卡片呈现 | + +### 1.4 术语表 + +| 术语 | 含义 | +|------|------| +| 项目实例(ProjectInstance) | 一个业务项目激活工作流后生成的运行实例,包含阶段序列与当前活动阶段 | +| 工作流(Workflow) | 预定义的「阶段 → 任务 → 产出文档」模板。系统内置三套正式工作流:**main**(V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价)、**product_manager**(系统需求基线)、**test_manager**(系统验证策划) | +| 阶段门禁(Stage Gate) | 进入下一阶段前,本阶段所有「必需且受审批管理」的文档必须到达「已批准」终态,由项目创建人显式推进 | +| 文档审批(docgov) | 受控文档 编制中 → 审核中 → 批准中 → 已批准 的生命周期,三责分离,含编辑租约锁 | +| 职责三角色 | 编制人(author,唯一可改正文)、审核人(reviewer)、批准人(approver) | +| RAG 知识库 | 跨环境共享的向量检索库,为对话提供引用与案例推荐 | +| SSE 流式对话 | 服务端推送(Server-Sent Events)实时输出 AI 回复,逐字呈现 | +| 任务持久化 | AI 任务运行落库,支持断点恢复、取消与事件回放 | +| PI Agent Runner | 产品容器内的真实 AI 执行器,驱动项目助手对话与文档生成 | + +--- + +## 2. 快速开始:从 0 进入系统 + +### 2.1 访问地址与浏览器要求 + +| 环境 | 访问地址 | 用途 | +|------|----------|------| +| 开发 dev | http://127.0.0.1:8080 | 日常开发验证 | +| 测试 test | http://127.0.0.1:8081 | 发布前验收 | +| 生产 main | http://127.0.0.1:8000 | 正式使用 | + +推荐使用 Chrome / Edge 最新版本,窗口宽度建议 1440px 以上(三栏布局)。 + +### 2.2 账号从哪里来 + +系统不提供匿名访问,账号有三条来源: + +1. **管理员直接开通**(最常见):管理员在管理台「用户与准入治理」中创建受控员工账号,初始密码默认为 `InitPass123`,首次登录强制改密(见 2.5)。 +2. **用户自主注册**:在登录页「用户自主注册」Tab 自行注册。注册后职位为「访客/待指派」,**必须经管理员在注册审批中心核准后才能登录**。是否开放注册、注册是否需要审核,由管理员在「准入策略」中控制。 +3. **环境引导管理员**:系统首次部署时自动创建管理员账号(用户名密码由部署配置决定)。 + +### 2.3 登录 + +打开访问地址,进入登录页。左侧为品牌与能力介绍,右侧为登录表单;左上角胶囊显示当前环境(DEV/MAIN)。 + +![登录页 - 账号密码](images/01-login.png) + +操作步骤: + +1. 在「账号密码登录」Tab 输入用户名与密码; +2. 点击登录; +3. 登录成功后进入三栏工作台。 + +> **注意**:连续登录失败请确认账号状态。新注册未审核的账号登录会提示「账号正在等待管理员审核中」;被停用的账号无法登录,需联系管理员。 + +### 2.4 自主注册 + +![登录页 - 用户自主注册](images/02-login-register.png) + +1. 切换到「用户自主注册」Tab; +2. 填写必填项:用户名、真实姓名、密码(两次输入一致);选填:部门、工号; +3. 提交后等待管理员审核;审核通过后使用注册的用户名密码登录; +4. 审核通过后,管理员还会为你指派职位与角色(见第 4 章)。 + +### 2.5 首次登录强制改密 + +管理员开通的账号使用初始密码首次登录时,系统强制要求修改密码: + +![首次登录强制改密](images/03-first-login-password.png) + +1. 输入新密码并确认; +2. 点击「重置并加密」; +3. 确认后自动进入系统,之后使用新密码登录。 + +--- + +## 3. 界面导览:三栏工作台 + +登录成功后进入工作台。工作台为三栏布局: + +![三栏工作台总览](images/10-workbench-overview.png) + +| 区域 | 内容 | +|------|------| +| 左栏 | 项目会话列表(一项目一会话)、「+ 新建立项会话」按钮、项目成员入口 | +| 中栏 | 对话区:消息流、阶段事件条、分类 Dock、输入区(附件/引用/发送) | +| 右栏 | 交付物文档区:分类 Tab(受控/共享/私密)、文档渲染与块编辑、治理操作条 | + +### 3.1 顶栏 + +![顶栏](images/11-topbar.png) + +顶栏显示:当前项目名与当前阶段、工作流名称与运行状态、项目助手在线状态(绿点)、系统设置与个人信息入口。 + +### 3.2 左栏:项目会话列表 + +![左栏项目会话列表](images/12-left-rail.png) + +- 每个项目对应一个会话卡片,显示项目名、当前阶段徽标、消息数; +- 点击卡片切换当前项目; +- 「+ 新建立项会话」发起立项(见 5.1); +- 「项目成员」打开成员与职责弹窗(见 5.2)。 + +### 3.3 个人设置 + +点击左下角个人区域打开个人设置弹窗,可修改个人资料(姓名、部门、工号等): + +![个人设置](images/13-user-settings.png) + +### 3.4 我的项目权限 + +「我的项目权限」弹窗展示当前账号在各项目中的职责:待我编制 / 待我审核 / 待我批准的文档清单,以及法定职责说明: + +![我的项目权限](images/14-my-rights.png) + +--- + +## 4. 管理员指南:用户、角色与权限 + +> 本章仅适用于**系统管理员(ADMIN)**角色。管理员登录后,可通过顶栏入口切换到「系统管理台」全屏视图;非管理员访问会被二次鉴权拦截(403 退回工作台)。 + +系统管理台左侧为 8 个分组导航: + +![管理台 - 系统运行总览](images/60-admin-overview.png) + +| 分组 | 用途 | +|------|------| +| 系统运行总览 | 服务健康、版本、数据库与知识库就绪状态 | +| 知识库连接 | 知识库在线状态、服务地址、数据库、连接账号 | +| 特性功能开关 | 环境变量级 / 数据库级 feature flag 管理 | +| 系统设置白名单 | 允许前端读取的系统设置项 | +| 工作流定义 | 内置工作流定义列表(阶段/任务/产出文档结构) | +| RBAC 角色权限 | 角色-权限矩阵,支持自定义角色 | +| 用户与准入治理 | 账号开通、注册审批、准入策略 | +| 文档与模板库 | 文档模板名称、版本、资产标识 | + +### 4.1 开通受控员工账号 + +在「用户与准入治理 → 账号列表」中: + +![账号列表与开通账号](images/61-admin-users.png) + +1. 点击「开通受控员工账号」; +2. 填写用户名、姓名、角色、部门等信息; +3. 系统生成初始密码(默认 `InitPass123`),将其线下告知员工; +4. 员工首次登录会被强制改密(见 2.5)。 + +账号列表还支持:**变更职位与角色**、**启用/停用账号**、**重置初始密码**(重置后该账号下次登录再次强制改密)。 + +### 4.2 注册审批中心 + +当准入策略开启「自主注册」且「注册需审核」时,新注册用户进入待审核队列: + +![注册审批中心](images/62-admin-approvals.png) + +- **通过并指派**:先在下拉框选择授予的业务职位(如「方案工程师」),再点击通过;账号激活,用户可登录并立即拥有该职位对应角色权限; +- **驳回**:注册被拒绝,账号不可用。 + +### 4.3 准入策略 + +![准入策略](images/63-admin-access-policy.png) + +两个开关: + +| 开关 | 说明 | +|------|------| +| 开放自主注册 | 关闭后登录页不再显示「用户自主注册」Tab | +| 注册需要审核 | 开启后新注册账号处于待审核状态,须管理员核准 | + +### 4.4 RBAC 角色权限矩阵 + +「RBAC 角色权限」分组展示角色 × 权限点矩阵: + +![RBAC 角色权限矩阵](images/64-admin-roles.png) + +系统内置 5 个角色(ADMIN 为系统角色,不可删除/改名): + +| 角色 | Code | 定位 | +|------|------|------| +| 系统管理员 | ADMIN | 全量权限,含系统管理台 | +| 研发总监 | DIRECTOR | 立项、指派负责人、可见性配置与覆盖、导出、查看全部工作流 | +| 研发技术主管 | TECH_LEAD | 立项、指派负责人、导出、查看全部工作流 | +| 方案工程师 | ENGINEER | 导出、查看全部工作流 | +| 外部审计/访客 | GUEST | 仅导出(只读审计场景) | + +权限点目录:`project:create`(立项)、`project:assign_lead`(变更责任人)、`system:manage`(系统管理)、`workflow:config_visibility`(可见性配置)、`doc:override_visibility`(可见性覆盖)、`doc:export`(导出)、`workflow:view_all`(查阅全部)、`workflow_defs.publish`(发布工作流定义)、`templates.publish`(发布模板)。 + +管理员还可**新建自定义角色**并勾选权限点组合,或修改/删除非系统角色。 + +### 4.5 其余管理分组 + +| 截图 | 说明 | +|------|------| +| ![特性功能开关](images/65-admin-features.png) | 特性功能开关:控制实验性能力的启用/关闭(数据库级开关即时生效;环境变量级需重启服务)。**注意**:未理解的开关不要随意改动,部分开关影响数据处理链路 | +| ![知识库连接](images/66-admin-kb.png) | 知识库连接:查看共享知识库(RAG)在线状态、服务地址、数据库与连接账号;离线时对话中的案例推荐不可用 | +| ![工作流定义](images/67-admin-workflows.png) | 工作流定义:查看内置工作流的阶段、任务与产出文档模板结构 | +| ![文档与模板库](images/68-admin-templates.png) | 文档与模板库:模板名称、版本列表与资产标识(asset_key),受控文档基于模板生成 | + +--- + +## 5. 项目使用:立项与成员配置 + +### 5.1 新建立项会话 + +拥有 `project:create` 权限的角色(研发总监、技术主管)可立项。点击左栏「+ 新建立项会话」: + +![新建立项会话](images/20-create-project.png) + +1. 填写项目名称、项目类型、背景说明等; +2. 选择工作流(见 5.3); +3. 确认后系统创建项目实例,自动进入第一阶段(如 main 工作流的「V0.1 需求摸底」),并生成本阶段要求的受控文档骨架。 + +### 5.2 项目成员与职责分配 + +立项后,打开「项目成员」弹窗,把成员加入项目并分配职责: + +![项目成员与职责](images/21-project-members.png) + +职责与文档审批链直接挂钩(见第 8 章): + +- **编制人**:可编辑受控文档正文、提交送审; +- **审核人**:对送审文档做审核决议(通过/驳回); +- **批准人**:终审批准,批准后文档归档生效。 + +> **职责模式**:项目工作流计划可配置 `full`(编制/审核/批准三人齐备)、`partial`(部分职责)、`none`(免审批链)以及试点环境的「编制人兼任」(同人三签)模式。 + +### 5.3 工作流切换 + +一个项目可挂接多套工作流(如同时推进商务线 main 与产品线 product_manager)。工作流在项目设置的「选择工作流」步骤中选择(列出全部已发布工作流,单选): + +![选择工作流](images/22-workflow-switcher.png) + +| 工作流 | 阶段序列 | +|--------|----------| +| main | V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价 | +| product_manager | 系统需求基线 | +| test_manager | 系统验证策划 | + +### 5.4 项目设置与职责配置 + +项目设置的「配置职责」步骤中,可将阶段产出文档(如 intake 用户初始需求收集表)纳入人工审批,并通过下拉框指派编制/审核/批准三权责任人: + +![配置职责](images/23-project-settings.png) + +--- + +## 6. 对话与 AI 项目助手 + +### 6.1 发起对话 + +中栏底部为输入区:输入消息,Enter 发送,Shift+Enter 换行。项目助手以 SSE 流式逐字输出回复。 + +下图为与项目助手的真实对话(dev 环境真实 PI Agent 执行): + +![真实对话](images/30-chat-real.png) + +项目助手可以帮你:查询项目进展与风险、沉淀客户需求与技术指标、起草沟通话术与方案要点、推进方案论证与报价核算、编制或修订交付物文档。 + +### 6.2 附件上传 + +每轮对话最多可上传 **5 个附件**(图片、文档等),点击输入区附件图标选择文件: + +![附件上传](images/31-chat-attachment.png) + +### 6.3 意图计划确认卡片 + +当助手识别到需要多步骤执行的任务时,会在对话中给出**执行计划**(步骤清单与选项),你可以直接回复确认(如"好"或"1")让助手按计划执行,或补充说明调整方向。多意图计划的确认/驳回卡片由特性开关控制(当前 dev 环境默认关闭,计划以消息内选项形式呈现,见 6.1 的真实对话截图)。 + +### 6.4 历史记录 + +对话支持历史搜索、日历定位、导出与 Digest 时间线回顾: + +![历史记录](images/33-history.png) + +--- + +## 7. 文档工作台 + +右栏为交付物文档区,顶部分类 Tab 将文档分为三层: + +| 分类 | 说明 | +|------|------| +| 工作流受控 | 工作流要求的正式交付物,走三责审批链,全生命周期留痕 | +| 项目共享 | 项目成员共享的非受控资料 | +| 个人私密 | 仅自己可见的草稿与笔记 | + +### 7.1 块编辑 + +受控文档由「块」组成。双击任意段落进入就地编辑;鼠标右键可唤出快捷菜单(编辑、批注、复制、插块): + +![文档块编辑](images/40-doc-editor.png) + +> **编辑租约锁**:同一文档同一时间仅一人持有编辑租约,避免冲突;他人打开时显示锁定状态。审核中/批准中的文档正文锁定,不可编辑。 + +### 7.2 版本历史与对比 + +文档工具栏可打开版本历史抽屉,查看每次修订版本,支持两版本对比(人工修改以琥珀底+下划线标出,相对最新基线): + +![版本历史](images/41-version-history.png) + +### 7.3 导出 + +交付物支持导出 DOCX / XLSX / Markdown(需要 `doc:export` 权限): + +![导出](images/42-export.png) + +### 7.4 文档可见性 + +默认可见性由工作流配置;有 `doc:override_visibility` 权限的角色(如研发总监)可对单个文档做可见性覆盖: + +![文档可见性](images/43-doc-visibility.png) + +--- + +## 8. 审批全流程 + +系统中有四套审批机制,本章逐一说明。 + +### 8.1 文档三责审批(核心) + +受控文档生命周期: + +```text +编制中(drafting) → 审核中(reviewing) → 批准中(approving) → 已批准(approved) + ↑ ↓ 驳回 + └──── 退回编制 ────┘ +``` + +**第 1 步:编制人认领文档。** 编制人打开受控文档并首次保存正文后,系统自动完成认领(治理操作条显示「编制中 · 已被编制人认领」),获得该文档的编制权: + +![编制中 · 已被编制人认领](images/50-doc-claim.png) + +**第 2 步:编制并提交送审。** 编制完成后点击「提交送审(流转至审核人)」,文档进入「审核中」,正文锁定: + +![提交送审](images/51-doc-submit-review.png) + +**第 3 步:审核人审核。** 审核人在自己的队列中看到待审核文档,可「审核通过(送呈批准人)」或「驳回退回编制」(驳回需说明理由): + +![审核决议](images/52-doc-review.png) + +**第 4 步:批准人终审。** 批准人执行「终审批准生效」;批准后文档到达 `approved` 终态并自动入标杆库。批准人驳回时必须填写理由,可指定回退到编制或审核环节: + +![终审批准](images/53-doc-approve.png) + +> **三责分离守卫**:只有编制人可改正文;审核人/批准人不可编辑;送审只能由编制人发起。试点环境支持「编制人兼任」同人三签模式(同一人员依次完成三签)。 + +### 8.2 阶段门禁推进 + +当前阶段所有「必需且受审批管理」的文档到达已批准后,阶段门禁就绪,顶部出现推进 Banner: + +![阶段门禁就绪](images/54-stage-gate-ready.png) + +**只有项目创建人**可以点击「确认推进」进入下一阶段;门禁未就绪时按钮不可用并列出阻塞项(哪些必需文档未批准): + +![确认推进阶段](images/55-stage-advance.png) + +### 8.3 修改建议审批 + +非编制人员对受控文档可发起「修改建议」(propose),由责任人确认(decide)后合入,全程留痕,不破坏三责分离。 + +### 8.4 注册审批 + +面向管理员,见 4.2。普通用户视角:注册后等待审核,审核通过前登录会收到「等待管理员审核」提示。 + +--- + +## 9. 角色权限速查与典型动线 + +### 9.1 权限速查表 + +| 权限点 | ADMIN | DIRECTOR | TECH_LEAD | ENGINEER | GUEST | +|--------|:-----:|:--------:|:---------:|:--------:|:-----:| +| 立项 project:create | ✓ | ✓ | ✓ | — | — | +| 变更责任人 project:assign_lead | ✓ | ✓ | ✓ | — | — | +| 系统管理 system:manage | ✓ | — | — | — | — | +| 可见性配置 workflow:config_visibility | ✓ | ✓ | — | — | — | +| 可见性覆盖 doc:override_visibility | ✓ | ✓ | ✓ | — | — | +| 导出 doc:export | ✓ | ✓ | ✓ | ✓ | ✓ | +| 查阅全部 workflow:view_all | ✓ | ✓ | ✓ | ✓ | — | + +> 以管理台「RBAC 角色权限」矩阵实时数据为准;自定义角色权限由管理员配置。 + +### 9.2 典型角色的一天 + +**系统管理员**:登录 → 管理台总览看运行状态 → 注册审批中心处理新注册 → 为新员工开通账号并指派角色 → 按需调整特性开关。 + +**研发总监 / 技术主管(项目创建人)**:立项 → 配置成员职责 → 与项目助手对话推进需求 → 关注待批准队列 → 终审批准文档 → 阶段门禁就绪后确认推进。 + +**编制人**:在「我的项目权限」看待我编制清单 → 认领文档 → 块编辑编制内容 → 提交送审 → 如被驳回按意见修改再送审。 + +**审核人**:看待我审核队列 → 审阅送审文档(可批注/提修改建议)→ 通过送呈批准人,或驳回退回编制。 + +**外部审计/访客**:只读查阅被授权的交付物 → 导出留存。 + +--- + +## 10. 常见问题 FAQ + +**Q1:登录提示「账号正在等待管理员审核中」?** +自主注册的账号需要管理员在「注册审批中心」核准。请联系系统管理员。 + +**Q2:忘记密码怎么办?** +联系管理员在「账号列表」中「重置初始密码」,之后用初始密码登录并按提示强制改密。 + +**Q3:登录后看不到管理台入口?** +管理台仅系统管理员可见。其他角色访问会被拦截并退回工作台。 + +**Q4:为什么不能编辑某份受控文档?** +可能原因:你不是该文档编制人;文档处于审核中/批准中(正文锁定);他人持有编辑租约;文档已批准归档(终态只读)。 + +**Q5:「确认推进」按钮不可用?** +阶段门禁未就绪:本阶段还有必需文档未批准。Banner 上会列出阻塞文档清单,先完成其审批链。另外只有项目创建人能推进阶段。 + +**Q6:对话中助手没有推荐案例?** +知识库可能离线。请管理员到「知识库连接」分组检查共享知识库状态。 + +**Q7:附件传不上去?** +每轮对话最多 5 个附件,超出需分轮发送;另请确认文件类型受支持。 + +**Q8:注册 Tab 不见了?** +管理员在「准入策略」中关闭了自主注册。请联系管理员直接开通账号。 + +--- + +*本说明书基于 dev 环境(schema v52)真实操作截图编写。界面与流程以实际环境为准。* diff --git a/docs/user-manual/scripts/.capture-state.json b/docs/user-manual/scripts/.capture-state.json new file mode 100644 index 00000000..9e41bc12 --- /dev/null +++ b/docs/user-manual/scripts/.capture-state.json @@ -0,0 +1,8 @@ +{ + "demoProjectId": 6502, + "uidZhang": 10, + "uidLi": 11, + "uidWang": 12, + "workflowActivated": true, + "intakeDocId": "doc_proj6491_intake" +} \ No newline at end of file diff --git a/docs/user-manual/scripts/build-formats.mjs b/docs/user-manual/scripts/build-formats.mjs new file mode 100644 index 00000000..aa35fb1b --- /dev/null +++ b/docs/user-manual/scripts/build-formats.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// 由 manual.md 生成 HTML / PDF / DOCX 三种格式到 dist/ +// 依赖:pandoc(brew install pandoc)、playwright(复用 web/node_modules) +// 运行:node docs/user-manual/scripts/build-formats.mjs + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MANUAL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DIST = path.join(MANUAL_DIR, 'dist'); +const MD = path.join(MANUAL_DIR, 'manual.md'); +const CSS = path.join(MANUAL_DIR, 'scripts', 'manual.css'); + +fs.mkdirSync(DIST, { recursive: true }); + +function pandoc(args) { + execFileSync('pandoc', args, { cwd: MANUAL_DIR, stdio: 'inherit' }); +} + +// 1) HTML(嵌入图片与样式,单文件可分发) +const htmlPath = path.join(DIST, '系统使用说明书.html'); +pandoc([ + 'manual.md', '-f', 'gfm', '-t', 'html5', '--standalone', + '--embed-resources', `--css=${CSS}`, + '--metadata', 'title=水木东方 · 医疗器械 AI 协同工作台 — 系统使用说明书', + '--toc', '--toc-depth=2', '--shift-heading-level-by=0', + '-o', htmlPath, +]); +console.log('[ok] HTML:', htmlPath); + +// 2) DOCX(pandoc 自动嵌入 images/ 下的图片) +const docxPath = path.join(DIST, '系统使用说明书.docx'); +pandoc(['manual.md', '-f', 'gfm', '--resource-path=.', '-o', docxPath]); +console.log('[ok] DOCX:', docxPath); + +// 3) PDF(主机缺 chromium 系统依赖,改在 playwright docker 镜像内打印) +const pdfPath = path.join(DIST, '系统使用说明书.pdf'); +const REPO = '/home/weichen/projects/Agent-System'; +execFileSync('docker', [ + 'run', '--rm', '-v', `${REPO}:${REPO}`, '-w', `${REPO}/web`, + 'mcr.microsoft.com/playwright:v1.63.0-noble', + 'node', path.join(MANUAL_DIR, 'scripts', 'pdf-print.mjs'), htmlPath, pdfPath, +], { stdio: 'inherit' }); diff --git a/docs/user-manual/scripts/capture-screenshots.mjs b/docs/user-manual/scripts/capture-screenshots.mjs new file mode 100644 index 00000000..0e26c364 --- /dev/null +++ b/docs/user-manual/scripts/capture-screenshots.mjs @@ -0,0 +1,1158 @@ +#!/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`); diff --git a/docs/user-manual/scripts/manual.css b/docs/user-manual/scripts/manual.css new file mode 100644 index 00000000..fd914cd9 --- /dev/null +++ b/docs/user-manual/scripts/manual.css @@ -0,0 +1,36 @@ +/* 系统使用说明书 HTML/PDF 样式 */ +:root { color-scheme: light; } +html { font-size: 15px; } +body { + font-family: "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "PingFang SC", "WenQuanYi Micro Hei", sans-serif; + line-height: 1.75; + color: #1f2329; + max-width: 880px; + margin: 0 auto; + padding: 2rem 2.5rem; +} +h1, h2, h3, h4 { color: #0f2a43; line-height: 1.35; } +h1 { font-size: 1.7rem; border-bottom: 3px solid #1f6feb; padding-bottom: .4rem; } +h2 { font-size: 1.35rem; border-bottom: 1px solid #d0d7de; padding-bottom: .3rem; margin-top: 2.2rem; } +h3 { font-size: 1.1rem; margin-top: 1.6rem; } +h1#toc, nav#TOC { page-break-after: always; } +img { + display: block; + max-width: 100%; + margin: 1rem auto; + border: 1px solid #d0d7de; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,.08); +} +table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .92rem; } +th, td { border: 1px solid #d0d7de; padding: .4rem .6rem; text-align: left; } +th { background: #f0f4f8; } +code { background: #f6f8fa; padding: .1em .35em; border-radius: 4px; font-size: .88em; } +pre { background: #f6f8fa; padding: .8rem 1rem; border-radius: 6px; overflow-x: auto; } +pre code { background: none; padding: 0; } +blockquote { + margin: 1rem 0; padding: .6rem 1rem; + border-left: 4px solid #1f6feb; background: #f0f6ff; color: #344054; +} +hr { border: none; border-top: 1px solid #d0d7de; margin: 2rem 0; } +a { color: #1f6feb; text-decoration: none; } diff --git a/docs/user-manual/scripts/pdf-print.mjs b/docs/user-manual/scripts/pdf-print.mjs new file mode 100644 index 00000000..b1e33fd1 --- /dev/null +++ b/docs/user-manual/scripts/pdf-print.mjs @@ -0,0 +1,18 @@ +// 在 playwright docker 镜像内运行:chromium 打印 HTML → PDF +// 用法:node pdf-print.mjs +import { createRequire } from 'node:module'; +const require = createRequire('/home/weichen/projects/Agent-System/web/package.json'); +const { chromium } = require('playwright'); + +const [htmlPath, pdfPath] = process.argv.slice(2); +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); +await page.pdf({ + path: pdfPath, + format: 'A4', + printBackground: true, + margin: { top: '18mm', bottom: '18mm', left: '15mm', right: '15mm' }, +}); +await browser.close(); +console.log('[ok] PDF:', pdfPath);