Files
ctms/frontend/src/components/Layout.vue
T
2025-12-30 14:07:57 +08:00

429 lines
12 KiB
Vue

<template>
<el-container class="layout-container">
<el-aside :width="isCollapsed ? '72px' : '240px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<div class="aside-logo">
<div class="logo-icon"></div>
<span class="logo-text">CTMS</span>
</div>
<el-menu
router
:default-active="activeMenu"
:collapse="isCollapsed"
:collapse-transition="true"
class="aside-menu"
>
<el-menu-item v-if="!isAdmin" index="/workbench">
<el-icon><Monitor /></el-icon>
<span>我的工作台</span>
</el-menu-item>
<template v-if="isAdmin">
<div class="menu-divider">管理后台</div>
<el-menu-item index="/admin/users">
<el-icon><User /></el-icon>
<span>账号管理</span>
</el-menu-item>
<el-menu-item index="/admin/projects">
<el-icon><Suitcase /></el-icon>
<span>项目管理</span>
</el-menu-item>
<el-menu-item v-if="isAdmin" index="/admin/audit-logs">
<el-icon><Document /></el-icon>
<span>审计日志</span>
</el-menu-item>
</template>
<template v-if="study.currentStudy">
<div class="menu-divider">当前项目</div>
<el-menu-item index="/study/home">
<el-icon><House /></el-icon>
<span>项目概览</span>
</el-menu-item>
<el-menu-item index="/study/milestones">
<el-icon><Calendar /></el-icon>
<span>伦理与启动</span>
</el-menu-item>
<el-menu-item index="/study/subjects">
<el-icon><User /></el-icon>
<span>受试者</span>
</el-menu-item>
<el-menu-item index="/study/aes">
<el-icon><WarningFilled /></el-icon>
<span>不良事件</span>
</el-menu-item>
<el-menu-item index="/study/issues">
<el-icon><Flag /></el-icon>
<span>风险与问题</span>
</el-menu-item>
<el-menu-item index="/study/data-queries">
<el-icon><ChatDotRound /></el-icon>
<span>数据问题</span>
</el-menu-item>
<el-menu-item index="/study/verifications">
<el-icon><CircleCheck /></el-icon>
<span>数据核查</span>
</el-menu-item>
<el-sub-menu index="imp">
<template #title>
<el-icon><Box /></el-icon>
<span>药品管理</span>
</template>
<el-menu-item index="/study/imp/inventory">库存</el-menu-item>
<el-menu-item index="/study/imp/products">产品</el-menu-item>
<el-menu-item index="/study/imp/batches">批次</el-menu-item>
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
</el-sub-menu>
<el-menu-item index="/study/finance">
<el-icon><Coin /></el-icon>
<span>费用管理</span>
</el-menu-item>
<el-menu-item index="/study/faq">
<el-icon><Notebook /></el-icon>
<span>知识库</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header class="layout-header">
<div class="header-left">
<el-button class="collapse-btn" @click="toggleCollapse" aria-label="收缩侧边栏">
<el-icon class="collapse-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8" />
<line x1="9.5" y1="5" x2="9.5" y2="19" stroke="currentColor" stroke-width="1.8" />
</svg>
</el-icon>
</el-button>
</div>
<div class="header-right">
<StudySelector />
<el-dropdown trigger="click" @command="onCommand" class="user-profile">
<span class="dropdown-trigger compact">
<el-avatar
:size="28"
:src="auth.user?.avatar_url"
shape="square"
class="user-avatar-comp"
>
{{ (auth.user?.username || auth.user?.email || "U").charAt(0).toUpperCase() }}
</el-avatar>
<span class="username">{{ auth.user?.username || auth.user?.email || "用户" }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu class="user-dropdown-menu">
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
import {
Monitor, User, Suitcase, House, Calendar, WarningFilled, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton
} from "@element-plus/icons-vue";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
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 activeMenu = computed(() => {
const path = route.path;
if (path.startsWith("/study/milestones/")) return "/study/milestones";
if (path.startsWith("/study/subjects/")) return "/study/subjects";
if (path.startsWith("/study/aes/")) return "/study/aes";
if (path.startsWith("/study/issues/")) return "/study/issues";
if (path.startsWith("/study/data-queries/")) return "/study/data-queries";
if (path.startsWith("/study/finance/")) return "/study/finance";
if (path.startsWith("/study/faq/")) return "/study/faq";
if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
return path;
});
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
};
const onLogout = () => {
auth.logout();
study.clearCurrentStudy();
router.replace("/login");
};
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "profile") {
router.push("/profile");
}
};
</script>
<style scoped>
.layout-container {
height: 100vh;
background-color: transparent;
}
.layout-aside {
background-color: #ffffff;
display: flex;
flex-direction: column;
box-shadow: none;
z-index: 10;
border-right: 1px solid var(--ctms-border-color);
transition: width 0.18s ease;
overflow: hidden;
}
.aside-menu {
transition: padding 0.18s ease;
}
.layout-aside.collapsed .logo-text {
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
}
.layout-aside.collapsed .aside-logo {
justify-content: center;
padding: 0;
}
.layout-aside.collapsed .menu-divider {
display: none;
}
.aside-logo {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
background-color: transparent;
transition: padding 0.18s ease;
}
.logo-icon {
width: 28px;
height: 28px;
background: var(--ctms-primary);
border-radius: 10px;
margin-right: 12px;
box-shadow: 0 6px 14px rgba(63, 93, 117, 0.18);
}
.logo-text {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 800;
letter-spacing: 1px;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.aside-menu {
border-right: none;
background-color: transparent;
flex: 1;
padding: 12px 0;
overflow-y: auto;
}
.menu-divider {
padding: 16px 24px 8px;
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
text-transform: uppercase;
letter-spacing: 1.5px;
}
:deep(.el-menu-item) {
height: 44px;
line-height: 44px;
margin: 4px 12px;
border-radius: 12px;
color: var(--ctms-text-regular) !important;
transition: var(--ctms-transition);
}
:deep(.el-menu-item:hover) {
color: var(--ctms-text-main) !important;
background-color: var(--ctms-bg-muted) !important;
}
:deep(.el-menu-item.is-active) {
color: var(--ctms-primary) !important;
background-color: var(--ctms-primary-light) !important;
box-shadow: none;
}
:deep(.el-menu-item.is-active::before) {
content: "";
position: absolute;
left: 8px;
top: 12px;
bottom: 12px;
width: 3px;
background-color: var(--ctms-primary);
border-radius: 999px;
}
:deep(.el-sub-menu__title) {
color: var(--ctms-text-regular) !important;
margin: 4px 12px;
border-radius: 12px;
height: 44px;
line-height: 44px;
}
:deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: var(--ctms-text-main) !important;
}
:deep(.el-menu--inline) {
background-color: transparent !important;
margin: 0 !important;
}
:deep(.el-menu--inline .el-menu-item) {
padding-left: 48px !important;
margin: 2px 12px;
}
.layout-header {
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
box-shadow: none;
height: 64px !important;
z-index: 9;
border-bottom: 1px solid var(--ctms-border-color);
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.header-left {
display: flex;
align-items: center;
}
.collapse-btn {
border: 1px solid var(--ctms-border-color);
color: var(--ctms-text-regular);
}
.collapse-icon {
font-size: 18px;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
}
.dropdown-trigger.compact {
display: flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #ffffff;
border: 1px solid var(--ctms-border-color);
color: var(--ctms-text-regular);
transition: var(--ctms-transition);
height: 40px;
}
.dropdown-trigger.compact:hover {
background: var(--ctms-bg-muted);
border-color: var(--ctms-border-color-hover);
}
.user-avatar-comp {
color: var(--ctms-primary) !important;
background-color: transparent !important;
border-radius: 10px !important;
font-weight: 700;
flex-shrink: 0;
font-size: 14px;
}
.username {
font-size: 14px;
font-weight: 600;
margin: 0 10px 0 8px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layout-main {
padding: 0;
overflow-y: auto;
}
.content-wrapper {
padding: 28px;
min-height: calc(100vh - 64px);
}
/* 过渡动画 */
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-15px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(15px);
}
</style>