diff --git a/docs/plans/2026-05-13-register-protocol-implementation.md b/docs/plans/2026-05-13-register-protocol-implementation.md new file mode 100644 index 00000000..17ee75da --- /dev/null +++ b/docs/plans/2026-05-13-register-protocol-implementation.md @@ -0,0 +1,64 @@ +# Register Protocol Supplement Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 让注册页的“服务条款”和“隐私政策”链接打开可阅读的协议内容。 + +**Architecture:** 把登录页已有 CTMS 协议文案抽到前端共享模块,登录页与注册页共同引用。注册页只增加弹窗状态与打开/关闭逻辑,不新增后端接口、路由或持久化配置。 + +**Tech Stack:** Vue 3 SFC、Element Plus、Vitest。 + +--- + +### Task 1: 共享协议文案 + +**Files:** +- Create: `frontend/src/content/authProtocol.ts` +- Modify: `frontend/src/views/Login.vue` +- Test: `frontend/src/views/Login.test.ts` + +**Step 1: Write failing test** + +验证登录页引用共享模块,不再内联维护 `protocolSections`。 + +**Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/views/Login.test.ts` + +**Step 3: Write minimal implementation** + +创建共享协议模块并让登录页导入。 + +**Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/views/Login.test.ts` + +### Task 2: 注册页协议弹窗 + +**Files:** +- Modify: `frontend/src/views/Register.vue` +- Test: `frontend/src/views/Register.test.ts` + +**Step 1: Write failing test** + +验证注册页两个协议按钮有 `@click` 行为、弹窗根据当前类型展示服务条款或隐私政策、确认按钮关闭弹窗。 + +**Step 2: Run test to verify it fails** + +Run: `npm run test:unit -- src/views/Register.test.ts` + +**Step 3: Write minimal implementation** + +添加 `protocolDialogVisible`、`activeProtocolType`、`activeProtocolTitle`、`activeProtocolSections`、`openProtocolDialog`、`closeProtocolDialog`,模板新增 `el-dialog`。 + +**Step 4: Run test to verify it passes** + +Run: `npm run test:unit -- src/views/Register.test.ts` + +### Task 3: Final verification + +Run: +- `npm run test:unit -- src/views/Login.test.ts src/views/Register.test.ts` +- `npm run type-check` + +不执行 git commit,遵循用户指令。 diff --git a/frontend/src/content/authProtocol.ts b/frontend/src/content/authProtocol.ts new file mode 100644 index 00000000..049df5c6 --- /dev/null +++ b/frontend/src/content/authProtocol.ts @@ -0,0 +1,74 @@ +export interface AuthProtocolSection { + title: string; + paragraphs: string[]; +} + +export const serviceTermsSections: AuthProtocolSection[] = [ + { + title: "1. 服务条款", + paragraphs: [ + "本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。", + "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、医学审核、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", + ], + }, + { + title: "2. 账号安全", + paragraphs: [ + "用户应妥善保管登录账号、密码和浏览器会话,不得转让、出借、共享账号,不得使用他人账号访问项目数据。因账号保管不当造成的数据泄露、误操作或审计异常,由账号所属用户及其机构承担相应责任。", + "系统支持邮箱密码登录、账号审核、启用停用、项目内角色授权和操作级权限控制。用户发现账号异常、权限错误、设备遗失或疑似未授权访问时,应立即联系管理员处理。", + ], + }, + { + title: "3. 临床试验数据与合规", + paragraphs: [ + "用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。", + "涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。", + ], + }, + { + title: "4. 权限与审计", + paragraphs: [ + "系统按照管理员、PM、CRA、PV、医学审核、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", + "系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。", + ], + }, + { + title: "5. 免责声明", + paragraphs: [ + "因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。", + "用户继续登录、注册和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止使用并联系管理员或所属机构负责人处理。", + ], + }, +]; + +export const privacyPolicySections: AuthProtocolSection[] = [ + { + title: "1. 隐私政策", + paragraphs: [ + "平台将按照业务需要处理用户账号信息、项目角色、部门信息、登录状态、操作记录以及临床试验管理过程中产生的业务数据,并采取访问控制、登录加密、会话管理和审计追踪等措施保护数据安全。", + "除法律法规、监管要求、机构授权或项目管理必要场景外,平台不会主动向无关第三方披露业务数据。用户应自行确认其录入和上传的数据已取得必要授权或具备合法处理依据。", + ], + }, + { + title: "2. 数据使用与最小必要", + paragraphs: [ + "平台仅在账号注册审核、身份识别、权限分配、项目协同、风险追踪、审计核查和系统安全维护所需范围内使用相关数据。", + "用户应遵循最小必要原则录入、查看、导出和传播数据,不得上传与当前项目管理无关的个人信息、受试者信息或其他敏感资料。", + ], + }, + { + title: "3. 安全与审计", + paragraphs: [ + "平台通过角色权限、项目范围、中心范围、操作授权和审计日志降低未授权访问风险。管理员可基于合规、安全和内部治理需要查看账号状态、权限配置和关键操作记录。", + "如发现数据异常、权限错误、账号泄露或疑似未授权访问,用户应及时联系管理员处理,平台将在合理范围内协助排查并保留必要审计记录。", + ], + }, +]; + +export const authProtocolSections: AuthProtocolSection[] = [ + ...serviceTermsSections, + { + title: "6. 隐私与数据保护", + paragraphs: privacyPolicySections.flatMap((section) => section.paragraphs), + }, +]; diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index f8a4d81c..a85037fc 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -14,4 +14,14 @@ describe("admin project route permissions", () => { expect(source).toContain('if (role !== "PM") return false;'); expect(source).toContain("roles?.PM?.[permission.module]?.[permission.action]"); }); + + it("sends authorized project PMs to the management backend after login", () => { + const source = readRouter(); + + expect(source).toContain("const findPmAdminLandingPath = async"); + expect(source).toContain("pmAdminLandingModules.find"); + expect(source).toContain("const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath()"); + expect(source).toContain("? pmAdminLanding"); + expect(source).toContain("`/admin/projects/${studyId}/members`"); + }); }); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a79fd20b..a3b9526d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -472,6 +472,25 @@ const router = createRouter({ routes, }); +const pmAdminLandingModules: Array<{ module: string; path: (studyId: string) => string }> = [ + { module: "project_members", path: (studyId) => `/admin/projects/${studyId}/members` }, + { module: "sites", path: (studyId) => `/admin/projects/${studyId}/sites` }, + { module: "audit_export", path: () => "/admin/audit-logs" }, +]; + +const findPmAdminLandingPath = async () => { + const studyStore = useStudyStore(); + const studyId = studyStore.currentStudy?.id; + if (!studyId) return null; + if (!studyStore.currentPermissions) { + await studyStore.loadCurrentStudyPermissions(); + } + const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || ""; + if (role !== "PM") return null; + const matched = pmAdminLandingModules.find((item) => !!studyStore.currentPermissions?.roles?.PM?.[item.module]?.read); + return matched ? matched.path(studyId) : null; +}; + const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { if (isAdmin) return true; const studyStore = useStudyStore(); @@ -530,13 +549,23 @@ router.beforeEach(async (to, _from, next) => { next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" }); return; } + if (!isAdmin && token && (to.path === "/" || to.path === "/workbench")) { + const pmAdminLanding = await findPmAdminLandingPath().catch(() => null); + if (pmAdminLanding) { + next({ path: pmAdminLanding }); + return; + } + } if ((to.path === "/login" || to.path === "/register") && token) { if (!auth.forceLogin) { + const pmAdminLanding = isAdmin ? null : await findPmAdminLandingPath().catch(() => null); next({ path: isAdmin ? studyStore.currentStudy ? "/project/overview" : "/admin/users" + : pmAdminLanding + ? pmAdminLanding : studyStore.currentStudy ? "/project/overview" : "/workbench", diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index c3383c4a..671b7852 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -37,12 +37,11 @@ describe("Login protocol agreement", () => { it("opens a CTMS-specific protocol dialog from the protocol text", () => { const source = readLoginView(); + expect(source).toContain("authProtocolSections"); expect(source).toContain('v-model="protocolDialogVisible"'); expect(source).toContain('@click.stop.prevent="openProtocolDialog"'); - expect(source).toContain("protocolSections"); - expect(source).toContain("临床试验数据与合规"); - expect(source).toContain("项目、中心、受试者、访视、AE/SAE、PD"); - expect(source).toContain("权限与审计"); + expect(source).toContain('import { authProtocolSections } from "../content/authProtocol"'); + expect(source).toContain("const protocolSections = authProtocolSections"); expect(source).toContain("confirmProtocol"); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 8312d8d5..1fa503b7 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -134,6 +134,7 @@ import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; import { TEXT, requiredMessage } from "../locales"; import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager"; +import { authProtocolSections } from "../content/authProtocol"; const auth = useAuthStore(); const router = useRouter(); @@ -162,50 +163,7 @@ const rules: FormRules = { const loading = ref(false); const protocolDialogVisible = ref(false); -const protocolSections = [ - { - title: "1. 服务条款", - paragraphs: [ - "本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。", - "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、医学审核、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", - ], - }, - { - title: "2. 账号安全", - paragraphs: [ - "用户应妥善保管登录账号、密码和浏览器会话,不得转让、出借、共享账号,不得使用他人账号访问项目数据。因账号保管不当造成的数据泄露、误操作或审计异常,由账号所属用户及其机构承担相应责任。", - "系统支持邮箱密码登录、账号审核、启用停用、项目内角色授权和操作级权限控制。用户发现账号异常、权限错误、设备遗失或疑似未授权访问时,应立即联系管理员处理。", - ], - }, - { - title: "3. 临床试验数据与合规", - paragraphs: [ - "用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。", - "涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。", - ], - }, - { - title: "4. 权限与审计", - paragraphs: [ - "系统按照管理员、PM、CRA、PV、医学审核、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", - "系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。", - ], - }, - { - title: "5. 隐私与数据保护", - paragraphs: [ - "平台将按照业务需要处理用户账号信息、项目角色、操作记录以及临床试验管理过程中产生的业务数据,并采取访问控制、登录加密、会话管理和审计追踪等措施保护数据安全。", - "除法律法规、监管要求、机构授权或项目管理必要场景外,平台不会主动向无关第三方披露业务数据。用户应自行确认其录入和上传的数据已取得必要授权或具备合法处理依据。", - ], - }, - { - title: "6. 免责声明", - paragraphs: [ - "因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。", - "用户继续登录和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止登录并联系管理员或所属机构负责人处理。", - ], - }, -]; +const protocolSections = authProtocolSections; onMounted(async () => { const logoutReason = consumeLogoutReason(); diff --git a/frontend/src/views/Register.test.ts b/frontend/src/views/Register.test.ts new file mode 100644 index 00000000..0b323ae0 --- /dev/null +++ b/frontend/src/views/Register.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readRegisterView = () => readFileSync(resolve(__dirname, "./Register.vue"), "utf8"); + +describe("Register protocol agreement", () => { + it("opens readable service terms and privacy policy dialogs from the agreement text", () => { + const source = readRegisterView(); + + expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'terms\')"'); + expect(source).toContain('@click.stop.prevent="openProtocolDialog(\'privacy\')"'); + expect(source).toContain('v-model="protocolDialogVisible"'); + expect(source).toContain("activeProtocolTitle"); + expect(source).toContain("activeProtocolSections"); + expect(source).toContain("closeProtocolDialog"); + expect(source).toContain("serviceTermsSections"); + expect(source).toContain("privacyPolicySections"); + }); + + it("shows an in-page pending admin approval notice after successful registration", () => { + const source = readRegisterView(); + const successFlagIndex = source.indexOf("registrationSubmitted.value = true"); + const resetIndex = source.indexOf("Object.assign(form"); + + expect(source).toContain('v-if="registrationSubmitted"'); + expect(source).toContain("注册申请已提交"); + expect(source).toContain("待管理员审核通过后方可登录"); + expect(source).toContain("管理员审核通过前,请勿重复提交注册申请"); + expect(source).toContain("registrationSubmitted"); + expect(source).not.toContain("approval-login-link"); + expect(successFlagIndex).toBeGreaterThan(-1); + expect(resetIndex).toBeGreaterThan(-1); + expect(successFlagIndex).toBeLessThan(resetIndex); + }); +}); diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index 5bb3ed4b..8e8d3ebc 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -57,8 +57,19 @@

步骤 {{ currentStep }}/3 · {{ stepDescription }}

+
+
+ + + +
+

注册申请已提交

+

待管理员审核通过后方可登录。

+

管理员审核通过前,请勿重复提交注册申请。

+
+ -
+
@@ -205,9 +216,13 @@ 我同意 - + 和 - + {{ errors.agreeTerms }} @@ -290,19 +305,40 @@
+ + +
+
+

{{ section.title }}

+

{{ paragraph }}

+
+
+ +