登录注册:优化协议弹窗与PM后台落点

授权项目负责人登录后,若当前项目具备项目成员、中心管理或审计导出读取权限,优先进入对应管理后台页面,避免仍停留在我的工作台。

抽取登录协议为共享内容模块,注册页新增服务条款和隐私政策弹窗,并在注册成功后展示待管理员审核提示。

补充路由、登录页和注册页静态回归测试,覆盖 PM 管理后台默认落点和协议弹窗行为。
This commit is contained in:
Cheng Zhou
2026-05-13 11:03:46 +08:00
parent 89cecce6b5
commit ab1975d3c6
8 changed files with 392 additions and 57 deletions
+74
View File
@@ -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),
},
];
+10
View File
@@ -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`");
});
});
+29
View File
@@ -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",
+3 -4
View File
@@ -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");
});
+2 -44
View File
@@ -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<typeof form> = {
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();
+36
View File
@@ -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);
});
});
+174 -9
View File
@@ -57,8 +57,19 @@
<p class="form-subtitle">步骤 {{ currentStep }}/3 · {{ stepDescription }}</p>
</div>
<section v-if="registrationSubmitted" class="approval-notice" aria-live="polite">
<div class="approval-icon">
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="approval-title">注册申请已提交</h3>
<p class="approval-message">待管理员审核通过后方可登录</p>
<p class="approval-hint">管理员审核通过前请勿重复提交注册申请</p>
</section>
<!-- 注册表单 -->
<form class="register-form" @submit.prevent="handleSubmit">
<form v-else class="register-form" @submit.prevent="handleSubmit">
<!-- 步骤 1: 基本信息 -->
<div v-show="currentStep === 1" class="step-content">
<!-- 邮箱 -->
@@ -205,9 +216,13 @@
<span class="checkbox-custom"></span>
<span class="checkbox-label">
我同意
<button type="button" class="link link-button">服务条款</button>
<button type="button" class="link link-button" @click.stop.prevent="openProtocolDialog('terms')">
服务条款
</button>
<button type="button" class="link link-button">隐私政策</button>
<button type="button" class="link link-button" @click.stop.prevent="openProtocolDialog('privacy')">
隐私政策
</button>
</span>
</label>
<span v-if="errors.agreeTerms" class="error-text">{{ errors.agreeTerms }}</span>
@@ -290,19 +305,40 @@
</div>
</div>
</div>
<el-dialog
v-model="protocolDialogVisible"
:title="activeProtocolTitle"
width="860px"
class="register-protocol-dialog"
append-to-body
align-center
>
<div class="protocol-content">
<section v-for="section in activeProtocolSections" :key="section.title" class="protocol-section">
<h3>{{ section.title }}</h3>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
</section>
</div>
<template #footer>
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="closeProtocolDialog">
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { register as apiRegister } from "../api/auth";
import type { RegisterRequest } from "../types/api";
import { TEXT, requiredMessage } from "../locales";
import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol";
const router = useRouter();
const defaultRole: RegisterRequest["role"] = "CRA";
type ProtocolType = "terms" | "privacy";
// 步骤控制
const currentStep = ref(1);
@@ -324,9 +360,13 @@ const errors = reactive<Record<string, string>>({});
const submitting = ref(false);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const protocolDialogVisible = ref(false);
const activeProtocolType = ref<ProtocolType>("terms");
const registrationSubmitted = ref(false);
// 步骤标题
const stepTitle = computed(() => {
if (registrationSubmitted.value) return "注册申请已提交";
switch (currentStep.value) {
case 1: return "基本信息";
case 2: return "安全设置";
@@ -337,6 +377,7 @@ const stepTitle = computed(() => {
// 步骤描述
const stepDescription = computed(() => {
if (registrationSubmitted.value) return "待管理员审核通过后方可登录";
switch (currentStep.value) {
case 1: return "填写您的基本信息";
case 2: return "设置登录密码";
@@ -352,6 +393,14 @@ const passwordHints = computed(() => ({
hasNumber: /[0-9]/.test(form.password),
}));
const activeProtocolTitle = computed(() => (
activeProtocolType.value === "terms" ? "服务条款" : "隐私政策"
));
const activeProtocolSections = computed(() => (
activeProtocolType.value === "terms" ? serviceTermsSections : privacyPolicySections
));
// 验证步骤1
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
@@ -413,6 +462,15 @@ const clearError = (field: string) => {
delete errors[field];
};
const openProtocolDialog = (type: ProtocolType) => {
activeProtocolType.value = type;
protocolDialogVisible.value = true;
};
const closeProtocolDialog = () => {
protocolDialogVisible.value = false;
};
// 下一步
const nextStep = () => {
// 清除之前的错误
@@ -454,10 +512,8 @@ const handleSubmit = async () => {
role: defaultRole,
clinical_department: form.clinical_department,
});
registrationSubmitted.value = true;
ElMessage.success(TEXT.modules.auth.registerSuccess);
setTimeout(() => {
router.push("/login");
}, 3000);
// 清空表单
Object.assign(form, {
email: "",
@@ -467,7 +523,7 @@ const handleSubmit = async () => {
clinical_department: "",
agreeTerms: false,
});
currentStep.value = 1;
currentStep.value = 3;
} finally {
submitting.value = false;
}
@@ -749,6 +805,50 @@ const handleSubmit = async () => {
gap: 20px;
}
.approval-notice {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 18px 12px 10px;
text-align: center;
}
.approval-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
color: #ffffff;
background: linear-gradient(135deg, rgba(52, 211, 153, 0.45), rgba(99, 102, 241, 0.28));
border-radius: 16px;
box-shadow:
inset 1px 1px 1px rgba(255, 255, 255, 0.45),
0 12px 30px rgba(52, 211, 153, 0.16);
}
.approval-title {
margin: 4px 0 0;
color: #ffffff;
font-size: 1.375rem;
font-weight: 600;
}
.approval-message,
.approval-hint {
max-width: 360px;
margin: 0;
color: rgba(255, 255, 255, 0.72);
font-size: 0.9375rem;
line-height: 1.7;
}
.approval-hint {
color: rgba(255, 255, 255, 0.54);
font-size: 0.8125rem;
}
.step-content {
display: flex;
flex-direction: column;
@@ -953,6 +1053,71 @@ const handleSubmit = async () => {
opacity: 0.8;
}
.protocol-content {
max-height: 58vh;
overflow-y: auto;
padding: 2px 8px 2px 0;
}
.protocol-section {
margin-bottom: 24px;
}
.protocol-section:last-child {
margin-bottom: 0;
}
.protocol-section h3 {
margin: 0 0 12px;
color: #2f3946;
font-size: 18px;
font-weight: 700;
line-height: 1.4;
}
.protocol-section p {
margin: 0 0 10px;
color: #5f6b7a;
font-size: 15px;
font-weight: 500;
line-height: 1.8;
}
.protocol-section p:last-child {
margin-bottom: 0;
}
.protocol-confirm-btn {
min-width: 112px;
height: 44px;
border-radius: 22px;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(135deg, #3f5d75, #4a6d87);
border: none;
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
}
:global(.register-protocol-dialog .el-dialog__header) {
padding: 24px 28px 12px;
margin-right: 0;
}
:global(.register-protocol-dialog .el-dialog__title) {
color: #2f3946;
font-size: 20px;
font-weight: 800;
}
:global(.register-protocol-dialog .el-dialog__body) {
padding: 8px 28px 20px;
}
:global(.register-protocol-dialog .el-dialog__footer) {
padding: 0 28px 24px;
}
/* 汇总部分 */
.summary-section {
padding: 16px;