917ab7ccf1
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
635 lines
18 KiB
Vue
635 lines
18 KiB
Vue
<template>
|
||
<div class="login-wrapper">
|
||
<div class="login-bg-shape shape-1"></div>
|
||
<div class="login-bg-shape shape-2"></div>
|
||
|
||
<div class="login-container">
|
||
<div class="login-card">
|
||
<div class="login-header">
|
||
<div class="logo-box">
|
||
<svg
|
||
width="28"
|
||
height="28"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
class="logo-icon"
|
||
>
|
||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||
<path d="M2 17l10 5 10-5"></path>
|
||
<path d="M2 12l10 5 10-5"></path>
|
||
</svg>
|
||
</div>
|
||
<h2 class="sys-title">CTMS 临床试验管理系统</h2>
|
||
<p class="sys-subtitle">Log in to your account</p>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="rules"
|
||
@keyup.enter="onSubmit"
|
||
label-position="top"
|
||
class="login-form"
|
||
>
|
||
<el-form-item label="邮箱" prop="email">
|
||
<el-input
|
||
id="email"
|
||
v-model="form.email"
|
||
type="email"
|
||
placeholder="请输入邮箱"
|
||
size="large"
|
||
name="username"
|
||
autocomplete="username"
|
||
class="login-input"
|
||
>
|
||
<template #prefix>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||
<circle cx="12" cy="7" r="4"></circle>
|
||
</svg>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="密码" prop="password">
|
||
<el-input
|
||
id="password"
|
||
v-model="form.password"
|
||
type="password"
|
||
placeholder="请输入密码"
|
||
show-password
|
||
size="large"
|
||
name="password"
|
||
autocomplete="current-password"
|
||
class="login-input"
|
||
>
|
||
<template #prefix>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||
</svg>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
|
||
<div class="login-options">
|
||
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
|
||
<span class="remember-text">记住密码</span>
|
||
</el-checkbox>
|
||
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
|
||
<span class="protocol-text">
|
||
我已阅读并同意
|
||
<button type="button" class="protocol-link" @click.stop.prevent="openProtocolDialog">
|
||
《用户协议》
|
||
</button>
|
||
登录即代表同意当前协议
|
||
</span>
|
||
</el-checkbox>
|
||
</div>
|
||
|
||
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
|
||
登 录
|
||
</el-button>
|
||
</el-form>
|
||
|
||
<p class="footer-text">
|
||
还没有账户?
|
||
<RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<el-dialog
|
||
v-model="protocolDialogVisible"
|
||
title="用户协议"
|
||
width="860px"
|
||
class="protocol-dialog"
|
||
append-to-body
|
||
align-center
|
||
>
|
||
<div class="protocol-content">
|
||
<section v-for="section in protocolSections" :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="confirmProtocol">
|
||
确 定
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref, onMounted } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||
import { useAuthStore } from "../store/auth";
|
||
import { useStudyStore } from "../store/study";
|
||
import { TEXT, requiredMessage } from "../locales";
|
||
import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager";
|
||
|
||
const auth = useAuthStore();
|
||
const router = useRouter();
|
||
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
||
|
||
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
||
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
||
|
||
const formRef = ref<FormInstance>();
|
||
const form = reactive({
|
||
email: "",
|
||
password: "",
|
||
rememberPassword: false,
|
||
agreeProtocol: false,
|
||
});
|
||
|
||
const rules: FormRules<typeof form> = {
|
||
email: [
|
||
{ required: true, message: requiredMessage(TEXT.modules.auth.emailLabel), trigger: "blur" },
|
||
{ type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
|
||
],
|
||
password: [{ required: true, message: requiredMessage(TEXT.modules.auth.passwordLabel), trigger: "blur" }],
|
||
};
|
||
|
||
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: [
|
||
"因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。",
|
||
"用户继续登录和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止登录并联系管理员或所属机构负责人处理。",
|
||
],
|
||
},
|
||
];
|
||
|
||
onMounted(async () => {
|
||
const logoutReason = consumeLogoutReason();
|
||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||
ElMessage.warning("长时间未操作,已自动退出登录,请重新登录");
|
||
}
|
||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
||
await tryLoadBrowserCredential();
|
||
});
|
||
|
||
const tryLoadBrowserCredential = async () => {
|
||
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
||
|
||
try {
|
||
const credential = (await navigator.credentials.get({
|
||
password: true,
|
||
mediation: "optional",
|
||
} as CredentialRequestOptions)) as StoredPasswordCredential | null;
|
||
if (!credential?.password) return;
|
||
form.email = credential.id || form.email;
|
||
form.password = credential.password;
|
||
} catch {
|
||
// 浏览器可能不支持读取密码凭据,保留用户手动输入流程。
|
||
}
|
||
};
|
||
|
||
const tryStoreBrowserCredential = async (email: string, password: string) => {
|
||
if (!form.rememberPassword) {
|
||
localStorage.removeItem(REMEMBER_PASSWORD_KEY);
|
||
return;
|
||
}
|
||
|
||
localStorage.setItem(REMEMBER_PASSWORD_KEY, "true");
|
||
const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor })
|
||
.PasswordCredential;
|
||
if (!navigator.credentials?.store || !PasswordCredential) return;
|
||
|
||
try {
|
||
await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email }));
|
||
} catch {
|
||
// 浏览器或用户可能拒绝保存凭据,不影响登录主流程。
|
||
}
|
||
};
|
||
|
||
const openProtocolDialog = () => {
|
||
protocolDialogVisible.value = true;
|
||
};
|
||
|
||
const confirmProtocol = () => {
|
||
form.agreeProtocol = true;
|
||
protocolDialogVisible.value = false;
|
||
};
|
||
|
||
const onSubmit = async () => {
|
||
if (!formRef.value) return;
|
||
const valid = await formRef.value.validate();
|
||
if (!valid) return;
|
||
if (!form.agreeProtocol) {
|
||
ElMessage.warning("请先阅读并同意用户协议");
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
await auth.login(form.email, form.password);
|
||
await tryStoreBrowserCredential(form.email, form.password);
|
||
const studyStore = useStudyStore();
|
||
if (!studyStore.currentStudy) {
|
||
if (auth.user?.role === "ADMIN") {
|
||
await studyStore.ensureDefaultActiveStudy();
|
||
} else {
|
||
await studyStore.ensureDefaultStudy();
|
||
}
|
||
}
|
||
if (studyStore.currentStudy) {
|
||
router.push("/project/overview");
|
||
} else if (auth.user?.role === "ADMIN") {
|
||
router.push("/admin/users");
|
||
} else {
|
||
router.push("/workbench");
|
||
}
|
||
} catch (error: any) {
|
||
const status = error?.response?.status;
|
||
const detail = error?.response?.data?.detail || error?.response?.data?.message;
|
||
if (!status || status >= 500) {
|
||
ElMessage.error("服务暂时不可用,请稍后重试");
|
||
} else {
|
||
ElMessage.error(detail || TEXT.modules.auth.authFailed);
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.login-wrapper {
|
||
position: relative;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: #f8fafc;
|
||
overflow: hidden;
|
||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
|
||
.login-bg-shape {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(80px);
|
||
z-index: 0;
|
||
opacity: 0.6;
|
||
animation: float 10s ease-in-out infinite alternate;
|
||
}
|
||
|
||
.shape-1 {
|
||
width: 400px;
|
||
height: 400px;
|
||
background: #c0cdd7;
|
||
top: -100px;
|
||
left: -100px;
|
||
}
|
||
|
||
.shape-2 {
|
||
width: 500px;
|
||
height: 500px;
|
||
background: #dde5ec;
|
||
bottom: -150px;
|
||
right: -100px;
|
||
animation-delay: -5s;
|
||
}
|
||
|
||
@keyframes float {
|
||
0% {
|
||
transform: translate(0, 0) scale(1);
|
||
}
|
||
|
||
100% {
|
||
transform: translate(30px, 50px) scale(1.1);
|
||
}
|
||
}
|
||
|
||
.login-container {
|
||
position: relative;
|
||
z-index: 10;
|
||
width: 100%;
|
||
max-width: 440px;
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.login-card {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(20px);
|
||
border-radius: 20px;
|
||
padding: 48px 40px 28px;
|
||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.login-header {
|
||
text-align: center;
|
||
margin-bottom: 36px;
|
||
}
|
||
|
||
.logo-box {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 56px;
|
||
height: 56px;
|
||
background: linear-gradient(135deg, #3f5d75, #24496f);
|
||
color: #ffffff;
|
||
border-radius: 16px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 8px 20px -6px rgba(63, 93, 117, 0.4);
|
||
}
|
||
|
||
.sys-title {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
margin: 0 0 8px;
|
||
letter-spacing: -0.5px;
|
||
}
|
||
|
||
.sys-subtitle {
|
||
font-size: 14px;
|
||
color: #64748b;
|
||
margin: 0;
|
||
}
|
||
|
||
.login-form :deep(.el-form-item) {
|
||
margin-bottom: 22px;
|
||
}
|
||
|
||
.login-form :deep(.el-form-item__label) {
|
||
font-weight: 600;
|
||
color: #475569;
|
||
font-size: 14px;
|
||
padding-bottom: 8px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.login-form :deep(.el-form-item__error) {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.login-input :deep(.el-input__wrapper) {
|
||
box-shadow: 0 0 0 1px #e2e8f0;
|
||
border-radius: 10px;
|
||
padding: 2px 14px;
|
||
background-color: #ffffff;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.login-input :deep(.el-input__wrapper:hover) {
|
||
box-shadow: 0 0 0 1px #cbd5e1;
|
||
}
|
||
|
||
.login-input :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 2px #3f5d75 !important;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.login-input :deep(.el-input__prefix) {
|
||
color: #94a3b8;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.login-input :deep(.el-input__inner) {
|
||
height: 44px;
|
||
font-size: 15px;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.login-input :deep(.el-input__inner:-webkit-autofill),
|
||
.login-input :deep(.el-input__inner:-webkit-autofill:hover),
|
||
.login-input :deep(.el-input__inner:-webkit-autofill:focus) {
|
||
-webkit-box-shadow: 0 0 0 100px white inset !important;
|
||
-webkit-text-fill-color: #1e293b !important;
|
||
transition: background-color 5000s ease-in-out 0s;
|
||
}
|
||
|
||
.login-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin: -4px 0 26px;
|
||
}
|
||
|
||
.remember-checkbox,
|
||
.protocol-checkbox {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
min-height: 24px;
|
||
}
|
||
|
||
.remember-checkbox :deep(.el-checkbox__input),
|
||
.protocol-checkbox :deep(.el-checkbox__input) {
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.remember-checkbox :deep(.el-checkbox__label),
|
||
.protocol-checkbox :deep(.el-checkbox__label) {
|
||
display: block;
|
||
padding-left: 10px;
|
||
line-height: 20px;
|
||
white-space: normal;
|
||
}
|
||
|
||
.remember-text {
|
||
color: #52657b;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 18px;
|
||
}
|
||
|
||
.protocol-text {
|
||
color: #475569;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 18px;
|
||
}
|
||
|
||
.protocol-link {
|
||
appearance: none;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #4da3ff;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.protocol-link:hover {
|
||
color: #2f8fff;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.protocol-content {
|
||
max-height: 58vh;
|
||
overflow-y: auto;
|
||
padding: 2px 8px 2px 0;
|
||
}
|
||
|
||
.protocol-section {
|
||
margin-bottom: 26px;
|
||
}
|
||
|
||
.protocol-section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.protocol-section h3 {
|
||
margin: 0 0 14px;
|
||
color: #2f3946;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.protocol-section p {
|
||
margin: 0 0 10px;
|
||
color: #5f6b7a;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
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(.protocol-dialog .el-dialog__header) {
|
||
padding: 24px 28px 12px;
|
||
margin-right: 0;
|
||
}
|
||
|
||
:global(.protocol-dialog .el-dialog__title) {
|
||
color: #2f3946;
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
:global(.protocol-dialog .el-dialog__body) {
|
||
padding: 22px 36px 10px;
|
||
}
|
||
|
||
:global(.protocol-dialog .el-dialog__footer) {
|
||
padding: 18px 28px 24px;
|
||
}
|
||
|
||
.login-btn {
|
||
width: 100%;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
letter-spacing: 2px;
|
||
background: linear-gradient(135deg, #3f5d75, #4a6d87);
|
||
border: none;
|
||
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.login-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 12px 20px -6px rgba(63, 93, 117, 0.4);
|
||
background: linear-gradient(135deg, #365067, #3f5d75);
|
||
}
|
||
|
||
.login-btn:active {
|
||
transform: translateY(1px);
|
||
box-shadow: 0 4px 8px -2px rgba(63, 93, 117, 0.3);
|
||
}
|
||
|
||
.footer-text {
|
||
text-align: center;
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
margin: 18px 0 0;
|
||
}
|
||
|
||
.footer-link {
|
||
color: #3f5d75;
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.footer-link:hover {
|
||
color: #365067;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.login-card {
|
||
padding: 36px 24px 24px;
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.sys-title {
|
||
font-size: 22px;
|
||
}
|
||
|
||
:global(.protocol-dialog) {
|
||
width: calc(100vw - 32px) !important;
|
||
}
|
||
|
||
:global(.protocol-dialog .el-dialog__body) {
|
||
padding: 18px 22px 8px;
|
||
}
|
||
}
|
||
</style>
|