未知(继上次中断)

This commit is contained in:
Cheng Zhou
2026-02-04 10:52:34 +08:00
parent 8e258d21a7
commit 737f84bf54
99 changed files with 5497 additions and 2143 deletions
+267 -44
View File
@@ -1,6 +1,6 @@
<template>
<el-container class="layout-container">
<el-aside :width="isCollapsed ? '72px' : '240px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy">
<div class="aside-logo">
<div class="logo-icon"></div>
<span class="logo-text">{{ TEXT.common.appName }}</span>
@@ -100,9 +100,50 @@
</svg>
</el-icon>
</el-button>
<!-- 移入 Header 的面包屑 -->
<div v-if="breadcrumbs.length" class="header-breadcrumb">
<template v-for="(item, index) in breadcrumbs" :key="index">
<!-- 带下拉的选择器类型 -->
<el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-link">
<span class="breadcrumb-item-text is-link-text">{{ item.label }}</span>
<el-icon class="breadcrumb-dropdown-icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="breadcrumb-dropdown-menu">
<template v-if="item.type === 'study'">
<el-dropdown-item v-for="s in studies" :key="s.id" :command="{ type: 'study', value: s }" :class="{ active: study.currentStudy?.id === s.id }">
{{ s.name }}
</el-dropdown-item>
</template>
<template v-else-if="item.type === 'site'">
<el-dropdown-item :command="{ type: 'site', value: null }" :class="{ active: !study.currentSite }">
{{ TEXT.common.labels.allSites || '所有中心' }}
</el-dropdown-item>
<el-dropdown-item v-for="s in sites" :key="s.id" :command="{ type: 'site', value: s }" :class="{ active: study.currentSite?.id === s.id }">
{{ s.name }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 普通链接或静态文本 -->
<div
v-else
class="breadcrumb-item-wrapper"
:class="{ 'is-link': item.path && index < breadcrumbs.length - 1 }"
@click="item.path && index < breadcrumbs.length - 1 && router.push(item.path)"
>
<span class="breadcrumb-item-text" :class="{ 'is-link-text': index > 0 && index < breadcrumbs.length - 1 }">{{ item.label }}</span>
</div>
<span v-if="index < breadcrumbs.length - 1" class="breadcrumb-separator">/</span>
</template>
</div>
</div>
<div class="header-right">
<StudySelector />
<el-dropdown trigger="click" @command="onCommand" class="user-profile">
<span class="dropdown-trigger compact">
<el-avatar
@@ -131,6 +172,7 @@
</el-dropdown>
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
@@ -146,16 +188,18 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
import { fetchStudies } from "../api/studies";
import { fetchSites } from "../api/sites";
import { TEXT } from "../locales";
import {
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
const auth = useAuthStore();
const study = useStudyStore();
@@ -189,6 +233,121 @@ const activeMenu = computed(() => {
return path;
});
const studies = ref<any[]>([]);
const sites = ref<any[]>([]);
const loadStudies = async () => {
try {
const { data } = await fetchStudies();
studies.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {}
};
const loadSites = async () => {
if (!study.currentStudy) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {
sites.value = [];
}
};
const breadcrumbs = computed(() => {
const items: any[] = [];
if (route.path.startsWith("/admin")) {
items.push({
label: TEXT.menu.admin,
path: "/admin/users"
});
} else if (study.currentStudy) {
// 1. 项目名
items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
path: "/project/overview",
hasDropdown: true,
type: 'study'
});
// 2. 中心名 (逻辑增强:详情页优先显示具体中心,列表页显示筛选中心)
items.push({
label: study.viewContext?.siteName || study.currentSite?.name || "所有中心",
hasDropdown: true,
type: 'site'
});
// 3. 模块名 (逻辑增强:当在详情页时显示所属模块)
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" },
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
"/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" },
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
"/knowledge/notes": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/notes" },
};
let matchedModule = null;
for (const prefix in moduleMap) {
if (route.path.startsWith(prefix)) {
matchedModule = moduleMap[prefix];
break;
}
}
if (matchedModule) {
// 如果当前路径不是模块主路径,说明在子页面/详情页,需要把模块名展示出来
if (route.path !== matchedModule.path) {
items.push({
label: matchedModule.label,
path: matchedModule.path
});
}
}
}
// 4. 当前页面标题
const title = route.meta?.title as string | undefined;
if (title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) {
// 检查 title 是否已经作为模块名添加过了
const isModuleTitle = items.some(item => item.label === title);
if (!isModuleTitle) {
items.push({
label: title,
path: route.path
});
}
}
return items;
});
const handleBreadcrumbCommand = (cmd: any) => {
if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value);
router.push("/project/overview");
} else if (cmd.type === 'site') {
study.setCurrentSite(cmd.value);
// 刷新当前页面或根据业务逻辑处理
ElMessage.success(`已切换至: ${cmd.value?.name || '所有中心'}`);
}
};
watch(() => study.currentStudy, () => {
loadSites();
}, { immediate: true });
watch(() => route.path, () => {
study.setViewContext(null);
});
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
@@ -200,6 +359,10 @@ const onLogout = () => {
router.replace("/login");
};
onMounted(() => {
loadStudies();
});
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
@@ -216,12 +379,12 @@ const onCommand = (cmd: string) => {
}
.layout-aside {
background-color: #ffffff;
background-color: #2c3e50;
display: flex;
flex-direction: column;
box-shadow: none;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
z-index: 10;
border-right: 1px solid var(--ctms-border-color);
border-right: none;
transition: width 0.18s ease;
overflow: hidden;
}
@@ -249,7 +412,7 @@ const onCommand = (cmd: string) => {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
padding: 0 16px;
background-color: transparent;
transition: padding 0.18s ease;
}
@@ -257,17 +420,17 @@ const onCommand = (cmd: string) => {
.logo-icon {
width: 28px;
height: 28px;
background: var(--ctms-primary);
border-radius: 10px;
background: #ffffff;
border-radius: 8px;
margin-right: 12px;
box-shadow: 0 6px 14px rgba(63, 93, 117, 0.18);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.logo-text {
color: var(--ctms-text-main);
color: #ffffff;
font-size: 18px;
font-weight: 800;
letter-spacing: 1px;
letter-spacing: 0.5px;
transition: opacity 0.18s ease, transform 0.18s ease;
}
@@ -280,63 +443,68 @@ const onCommand = (cmd: string) => {
}
.menu-divider {
padding: 16px 24px 8px;
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
padding: 16px 16px 8px;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 1.5px;
letter-spacing: 1px;
}
:deep(.el-menu-item) {
.layout-aside :deep(.el-menu-item) {
height: 44px;
line-height: 44px;
margin: 4px 12px;
border-radius: 12px;
color: var(--ctms-text-regular) !important;
margin: 4px 10px;
border-radius: 8px;
color: #f1f5f9 !important;
transition: var(--ctms-transition);
}
:deep(.el-menu-item:hover) {
color: var(--ctms-text-main) !important;
background-color: var(--ctms-bg-muted) !important;
.layout-aside :deep(.el-menu-item:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.08) !important;
}
:deep(.el-menu-item.is-active) {
color: var(--ctms-primary) !important;
background-color: var(--ctms-primary-light) !important;
.layout-aside :deep(.el-menu-item.is-active) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.12) !important;
box-shadow: none;
}
:deep(.el-menu-item.is-active::before) {
.layout-aside :deep(.el-menu-item.is-active::before) {
content: "";
position: absolute;
left: 8px;
top: 12px;
bottom: 12px;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
background-color: var(--ctms-primary);
border-radius: 999px;
background-color: #3b82f6;
border-radius: 0 4px 4px 0;
}
:deep(.el-sub-menu__title) {
color: var(--ctms-text-regular) !important;
margin: 4px 12px;
border-radius: 12px;
.layout-aside :deep(.el-sub-menu__title) {
color: #f1f5f9 !important;
margin: 4px 10px;
border-radius: 8px;
height: 44px;
line-height: 44px;
}
:deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: var(--ctms-text-main) !important;
.layout-aside :deep(.el-sub-menu__title:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.08) !important;
}
:deep(.el-menu--inline) {
.layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: #ffffff !important;
}
.layout-aside :deep(.el-menu--inline) {
background-color: transparent !important;
margin: 0 !important;
}
:deep(.el-menu--inline .el-menu-item) {
.layout-aside :deep(.el-menu--inline .el-menu-item) {
padding-left: 48px !important;
margin: 2px 12px;
}
@@ -350,7 +518,53 @@ const onCommand = (cmd: string) => {
box-shadow: none;
height: 64px !important;
z-index: 9;
border-bottom: 1px solid var(--ctms-border-color);
border-bottom: 1px solid #f1f5f9;
}
.header-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
color: var(--ctms-text-regular);
font-size: 14px;
margin-left: 20px;
user-select: none;
}
.breadcrumb-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.breadcrumb-item-wrapper.is-link {
cursor: pointer;
}
.breadcrumb-item-wrapper.is-link:hover {
opacity: 0.7;
}
.is-link-text {
color: #3b82f6;
font-weight: 500;
}
.breadcrumb-item-text {
font-weight: 400;
}
.breadcrumb-dropdown-icon {
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.breadcrumb-separator {
margin: 0 4px;
color: #cbd5e1;
font-weight: 300;
}
.header-right {
@@ -422,7 +636,7 @@ const onCommand = (cmd: string) => {
}
.content-wrapper {
padding: 28px;
padding: 16px;
min-height: calc(100vh - 64px);
}
@@ -441,4 +655,13 @@ const onCommand = (cmd: string) => {
opacity: 0;
transform: translateX(15px);
}
.breadcrumb-dropdown-menu {
min-width: 200px;
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item.active) {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
font-weight: 600;
}
</style>