立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
+94 -40
View File
@@ -39,6 +39,10 @@
<el-icon><House /></el-icon>
<span>{{ TEXT.menu.projectOverview }}</span>
</el-menu-item>
<el-menu-item index="/project/milestones">
<el-icon><Calendar /></el-icon>
<span>{{ TEXT.menu.projectMilestones }}</span>
</el-menu-item>
<el-sub-menu index="fees">
<template #title>
<el-icon><Coin /></el-icon>
@@ -47,10 +51,15 @@
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/drug/shipments">
<el-icon><Box /></el-icon>
<span>{{ TEXT.menu.drugShipments }}</span>
</el-menu-item>
<el-sub-menu index="materials">
<template #title>
<el-icon><Box /></el-icon>
<span>{{ TEXT.menu.materialManagement }}</span>
</template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
<el-menu-item index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
<el-menu-item index="/materials/others">{{ TEXT.menu.materialOthers }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/file-versions">
<el-icon><Files /></el-icon>
<span>{{ TEXT.menu.fileVersionManagement }}</span>
@@ -67,13 +76,22 @@
<el-icon><User /></el-icon>
<span>{{ TEXT.menu.subjects }}</span>
</el-menu-item>
<el-menu-item index="/monitoring">
<el-sub-menu index="risk-issues">
<template #title>
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.riskIssues }}</span>
</template>
<el-menu-item index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
<el-menu-item index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
<el-menu-item index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitoring-audit">
<el-icon><ChatDotRound /></el-icon>
<span>{{ TEXT.menu.monitoring }}</span>
<span>{{ TEXT.menu.monitoringAudit }}</span>
</el-menu-item>
<el-menu-item index="/audit">
<el-menu-item index="/etmf">
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.audit }}</span>
<span>{{ TEXT.menu.etmf }}</span>
</el-menu-item>
<el-sub-menu index="knowledge">
<template #title>
@@ -152,9 +170,9 @@
shape="square"
class="user-avatar-comp"
>
{{ (auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback).charAt(0).toUpperCase() }}
{{ userDisplayInitial }}
</el-avatar>
<span class="username">{{ auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback }}</span>
<span class="username">{{ userDisplayName }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
@@ -208,22 +226,36 @@ const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
);
const userDisplayInitial = computed(() => {
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
return source.charAt(0).toUpperCase();
});
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith("/project/milestones")) return "/project/milestones";
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
if (path.startsWith("/fees/special")) return "/fees/special";
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
if (path.startsWith("/finance/special")) return "/fees/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
if (path.startsWith("/materials/others")) return "/materials/others";
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth";
}
if (path.startsWith("/subjects")) return "/subjects";
if (path.startsWith("/monitoring")) return "/monitoring";
if (path.startsWith("/audit")) return "/audit";
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
if (path.startsWith("/monitoring-audit") || path.startsWith("/monitoring") || path.startsWith("/audit")) return "/monitoring-audit";
if (path.startsWith("/etmf")) return "/etmf";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
@@ -284,8 +316,16 @@ const breadcrumbs = computed(() => {
const moduleMap: Record<string, { label: string, path: string }> = {
"/fees/contracts": { label: TEXT.menu.feeContracts, path: "/fees/contracts" },
"/fees/special": { label: TEXT.menu.feeSpecials, path: "/fees/special" },
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
"/materials/others": { label: TEXT.menu.materialOthers, path: "/materials/others" },
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
"/risk-issues/monitoring-visits": { label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
"/monitoring-audit": { label: TEXT.menu.monitoringAudit, path: "/monitoring-audit" },
"/etmf": { label: TEXT.menu.etmf, path: "/etmf" },
"/startup/feasibility": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
"/startup/ethics": { label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics" },
"/startup/kickoff": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
@@ -359,7 +399,15 @@ const onLogout = () => {
router.replace("/login");
};
onMounted(() => {
onMounted(async () => {
if (auth.token && !auth.user) {
try {
await auth.fetchMe();
} catch {
onLogout();
return;
}
}
loadStudies();
});
@@ -382,7 +430,7 @@ const onCommand = (cmd: string) => {
background-color: #2c3e50;
display: flex;
flex-direction: column;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
box-shadow: 0 0 12px rgba(0, 0, 0, 0.08);
z-index: 10;
border-right: none;
transition: width 0.18s ease;
@@ -409,10 +457,10 @@ const onCommand = (cmd: string) => {
}
.aside-logo {
height: 64px;
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
padding: 0 14px;
background-color: transparent;
transition: padding 0.18s ease;
}
@@ -438,12 +486,12 @@ const onCommand = (cmd: string) => {
border-right: none;
background-color: transparent;
flex: 1;
padding: 12px 0;
padding: 4px 0;
overflow-y: auto;
}
.menu-divider {
padding: 16px 16px 8px;
padding: 8px 14px 2px;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
@@ -452,10 +500,10 @@ const onCommand = (cmd: string) => {
}
.layout-aside :deep(.el-menu-item) {
height: 44px;
line-height: 44px;
margin: 4px 10px;
border-radius: 8px;
height: 34px;
line-height: 34px;
margin: 1px 8px;
border-radius: 7px;
color: #f1f5f9 !important;
transition: var(--ctms-transition);
}
@@ -475,19 +523,19 @@ const onCommand = (cmd: string) => {
content: "";
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
top: 8px;
bottom: 8px;
width: 3px;
background-color: #3b82f6;
background-color: #94b8d1;
border-radius: 0 4px 4px 0;
}
.layout-aside :deep(.el-sub-menu__title) {
color: #f1f5f9 !important;
margin: 4px 10px;
border-radius: 8px;
height: 44px;
line-height: 44px;
margin: 1px 8px;
border-radius: 7px;
height: 34px;
line-height: 34px;
}
.layout-aside :deep(.el-sub-menu__title:hover) {
@@ -505,8 +553,8 @@ const onCommand = (cmd: string) => {
}
.layout-aside :deep(.el-menu--inline .el-menu-item) {
padding-left: 48px !important;
margin: 2px 12px;
padding-left: 40px !important;
margin: 0 10px;
}
.layout-header {
@@ -514,20 +562,24 @@ const onCommand = (cmd: string) => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
padding: 0 10px;
box-shadow: none;
height: 64px !important;
height: 52px !important;
z-index: 9;
border-bottom: 1px solid #f1f5f9;
}
.main-container {
background: #f7f8fb;
}
.header-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
color: var(--ctms-text-regular);
font-size: 14px;
margin-left: 20px;
font-size: 13px;
margin-left: 14px;
user-select: none;
}
@@ -570,7 +622,7 @@ const onCommand = (cmd: string) => {
.header-right {
display: flex;
align-items: center;
gap: 20px;
gap: 12px;
}
.header-left {
@@ -603,7 +655,7 @@ const onCommand = (cmd: string) => {
border: 1px solid var(--ctms-border-color);
color: var(--ctms-text-regular);
transition: var(--ctms-transition);
height: 40px;
height: 36px;
}
.dropdown-trigger.compact:hover {
@@ -633,11 +685,13 @@ const onCommand = (cmd: string) => {
.layout-main {
padding: 0;
overflow-y: auto;
background: #f7f8fb;
}
.content-wrapper {
padding: 16px;
min-height: calc(100vh - 64px);
padding: 6px 8px;
min-height: calc(100vh - 52px);
background: #f7f8fb;
}
/* 过渡动画 */
+84 -5
View File
@@ -4,11 +4,14 @@
<div class="lock-backdrop" />
<div class="lock-panel">
<h2 class="lock-title">已锁定</h2>
<p class="lock-desc">30 分钟无操作请输入密码解锁</p>
<p class="lock-desc">30 分钟无操作将锁定2 小时无操作将自动退出登录</p>
<p class="lock-countdown">剩余自动登出{{ autoLogoutCountdown }}</p>
<el-input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="new-password"
name="lock-password"
placeholder="请输入登录密码"
show-password
@keyup.enter="onUnlock"
@@ -24,11 +27,16 @@
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { UNLOCK_MAX_ATTEMPTS, unlockWithPassword, forceLogout } from "../session/sessionManager";
import {
AUTO_LOGOUT_TIMEOUT_HOURS,
UNLOCK_MAX_ATTEMPTS,
unlockWithPassword,
forceLogout,
} from "../session/sessionManager";
const auth = useAuthStore();
const session = useSessionStore();
@@ -36,6 +44,41 @@ const password = ref("");
const loading = ref(false);
const errorMessage = ref("");
const passwordInput = ref<{ focus?: () => void } | null>(null);
const nowTs = ref(Date.now());
let countdownTimer: number | null = null;
const pad2 = (value: number) => String(value).padStart(2, "0");
const autoLogoutCountdown = computed(() => {
const fallbackDeadlineAt =
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
const remainMs = Math.max(0, deadlineAt - nowTs.value);
const remainSec = Math.floor(remainMs / 1000);
const hours = Math.floor(remainSec / 3600);
const minutes = Math.floor((remainSec % 3600) / 60);
const seconds = remainSec % 60;
if (hours > 0) {
return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
}
return `${pad2(minutes)}:${pad2(seconds)}`;
});
const startCountdown = () => {
if (countdownTimer) {
window.clearInterval(countdownTimer);
}
nowTs.value = Date.now();
countdownTimer = window.setInterval(() => {
nowTs.value = Date.now();
}, 1000);
};
const stopCountdown = () => {
if (!countdownTimer) return;
window.clearInterval(countdownTimer);
countdownTimer = null;
};
const focusPassword = () => {
nextTick(() => {
@@ -43,10 +86,23 @@ const focusPassword = () => {
});
};
const resolveUnlockEmail = async () => {
if (session.lockEmail) return session.lockEmail;
if (auth.user?.email) return auth.user.email;
const cachedEmail = localStorage.getItem("ctms_last_login_email");
if (cachedEmail) return cachedEmail;
try {
const me = await auth.fetchMe();
return me?.email || "";
} catch {
return "";
}
};
const onUnlock = async () => {
if (loading.value) return;
errorMessage.value = "";
const email = auth.user?.email;
const email = await resolveUnlockEmail();
if (!email) {
ElMessage.error("无法获取用户信息,请重新登录");
forceLogout();
@@ -63,7 +119,7 @@ const onUnlock = async () => {
password.value = "";
} catch (err: any) {
session.incrementUnlockAttempt();
const message = err?.response?.data?.detail || err?.response?.data?.message || "密码错误";
const message = err?.response?.data?.detail || err?.response?.data?.message || err?.message || "密码错误";
errorMessage.value = message;
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
ElMessage.error("错误次数过多,请重新登录");
@@ -80,17 +136,33 @@ const onLogout = () => {
};
onMounted(() => {
password.value = "";
errorMessage.value = "";
focusPassword();
if (session.locked) {
startCountdown();
}
});
watch(
() => session.locked,
(value) => {
if (value) {
password.value = "";
errorMessage.value = "";
focusPassword();
startCountdown();
return;
}
password.value = "";
errorMessage.value = "";
stopCountdown();
}
);
onUnmounted(() => {
stopCountdown();
});
</script>
<style scoped>
@@ -135,6 +207,13 @@ watch(
font-size: 14px;
}
.lock-countdown {
margin: -4px 0 0;
color: #1d4ed8;
font-size: 13px;
font-weight: 600;
}
.lock-actions {
display: flex;
gap: 12px;