立项配置页初步优化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user