未知(继上次中断)

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
+161 -91
View File
@@ -1,29 +1,36 @@
<template>
<el-card class="kpi-card" :class="[`type-${type}`]" shadow="hover">
<div class="kpi-header">
<div class="kpi-title-wrapper">
<div class="kpi-icon-wrapper" v-if="icon">
<el-icon class="kpi-icon"><component :is="icon" /></el-icon>
<div class="kpi-card" :class="[`type-${type}`]">
<!-- 顶部标签区域 -->
<div class="kpi-badge">
<div class="kpi-badge-icon" v-if="icon">
<el-icon><component :is="icon" /></el-icon>
</div>
<span class="kpi-badge-text">{{ badgeText }}</span>
</div>
<!-- 标题区域 -->
<div class="kpi-main">
<h3 class="kpi-title">{{ title }}</h3>
<p class="kpi-subtitle" v-if="subtext">{{ subtext }}</p>
</div>
<!-- 底部数值区域 -->
<div class="kpi-footer">
<div class="kpi-value-wrapper">
<div v-if="loading" class="kpi-loading">
<el-skeleton animated :style="{ '--el-skeleton-color': 'rgba(255,255,255,0.2)', '--el-skeleton-to-color': 'rgba(255,255,255,0.4)' }">
<template #template>
<el-skeleton-item variant="h1" style="width: 80px; height: 32px" />
</template>
</el-skeleton>
</div>
<span class="kpi-title">{{ title }}</span>
<template v-else>
<span class="kpi-value">{{ value }}</span>
<span v-if="unit" class="kpi-unit">{{ unit }}</span>
</template>
</div>
<slot name="header-suffix"></slot>
</div>
<div class="kpi-content">
<div v-if="loading" class="kpi-loading">
<el-skeleton animated>
<template #template>
<el-skeleton-item variant="h1" style="width: 60%" />
</template>
</el-skeleton>
</div>
<div v-else class="kpi-value-container">
<span class="kpi-value">{{ value }}</span>
<span v-if="unit" class="kpi-unit">{{ unit }}</span>
</div>
<div v-if="subtext" class="kpi-subtext">{{ subtext }}</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
@@ -37,123 +44,186 @@ interface Props {
icon?: any;
unit?: string;
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
badge?: string;
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
});
// 根据类型生成默认徽章文字
const badgeText = computed(() => {
if (props.badge) return props.badge;
const badgeMap: Record<string, string> = {
primary: '统计',
success: '支出',
warning: '待办',
danger: '警告',
info: '信息',
default: '概览',
};
return badgeMap[props.type] || '概览';
});
</script>
<style scoped>
.kpi-card {
border: 1px solid var(--ctms-border-color);
transition: all 0.3s ease;
height: 100%;
border-radius: 12px;
overflow: hidden;
position: relative;
--el-card-padding: 12px 16px;
}
.kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
padding: 20px;
border-radius: 16px;
height: 100%;
background-color: transparent;
transition: background-color 0.3s ease;
min-height: 140px;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-sizing: border-box;
}
.kpi-card.type-primary::before { background-color: var(--el-color-primary); }
.kpi-card.type-success::before { background-color: var(--el-color-success); }
.kpi-card.type-warning::before { background-color: var(--el-color-warning); }
.kpi-card.type-danger::before { background-color: var(--el-color-danger); }
.kpi-card.type-info::before { background-color: var(--el-color-info); }
/* 默认样式 - 深灰色 */
.kpi-card.type-default {
background: linear-gradient(135deg, #2d2d2d 0%, #1a1a1a 100%);
color: #ffffff;
}
/* 主要样式 - 蓝色渐变 */
.kpi-card.type-primary {
background: linear-gradient(135deg, #4ECDC4 0%, #44A8B3 50%, #2C8C99 100%);
color: #ffffff;
}
/* 成功样式 - 黄/橙色渐变 */
.kpi-card.type-success {
background: linear-gradient(135deg, #F7DC6F 0%, #F4C430 50%, #E6B800 100%);
color: #1a1a1a;
}
/* 警告样式 - 红/珊瑚色渐变 */
.kpi-card.type-warning {
background: linear-gradient(135deg, #FF6B6B 0%, #EE5A52 50%, #DC4040 100%);
color: #ffffff;
}
/* 危险样式 - 深红色渐变 */
.kpi-card.type-danger {
background: linear-gradient(135deg, #C0392B 0%, #96281B 100%);
color: #ffffff;
}
/* 信息样式 - 深灰色 */
.kpi-card.type-info {
background: linear-gradient(135deg, #3d3d3d 0%, #2a2a2a 100%);
color: #ffffff;
}
/* 悬停效果 */
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25);
}
.kpi-header {
display: flex;
justify-content: space-between;
/* 顶部徽章 */
.kpi-badge {
display: inline-flex;
align-items: center;
margin-bottom: 8px;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
width: fit-content;
margin-bottom: 12px;
}
.kpi-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
.type-success .kpi-badge {
background: rgba(0, 0, 0, 0.12);
}
.kpi-icon-wrapper {
.kpi-badge-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background-color: var(--ctms-bg-color-page);
color: var(--ctms-text-secondary);
font-size: 12px;
opacity: 0.9;
}
.type-primary .kpi-icon-wrapper { color: var(--el-color-primary); background-color: var(--el-color-primary-light-9); }
.type-success .kpi-icon-wrapper { color: var(--el-color-success); background-color: var(--el-color-success-light-9); }
.type-warning .kpi-icon-wrapper { color: var(--el-color-warning); background-color: var(--el-color-warning-light-9); }
.type-danger .kpi-icon-wrapper { color: var(--el-color-danger); background-color: var(--el-color-danger-light-9); }
.type-info .kpi-icon-wrapper { color: var(--el-color-info); background-color: var(--el-color-info-light-9); }
.kpi-title {
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
.kpi-badge-text {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
.kpi-icon {
font-size: 16px;
}
.kpi-content {
/* 主内容区域 */
.kpi-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.kpi-value-container {
.kpi-title {
font-size: 18px;
font-weight: 600;
margin: 0;
line-height: 1.3;
letter-spacing: -0.01em;
}
.kpi-subtitle {
font-size: 13px;
margin: 0;
opacity: 0.75;
font-weight: 400;
}
/* 底部数值区域 */
.kpi-footer {
margin-top: auto;
padding-top: 16px;
}
.kpi-value-wrapper {
display: flex;
align-items: baseline;
gap: 4px;
}
.kpi-value {
font-size: 32px;
font-size: 36px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
font-family: var(--el-font-family);
letter-spacing: -0.02em;
line-height: 1;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.kpi-unit {
font-size: 14px;
color: var(--ctms-text-secondary);
font-size: 16px;
font-weight: 500;
margin-left: 2px;
}
.kpi-subtext {
color: var(--ctms-text-secondary);
font-size: 13px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ctms-border-color-lighter);
opacity: 0.8;
margin-left: 4px;
}
.kpi-loading {
height: 38px;
height: 36px;
display: flex;
align-items: center;
}
/* 响应式调整 */
@media (max-width: 768px) {
.kpi-card {
min-height: 120px;
padding: 16px;
}
.kpi-title {
font-size: 16px;
}
.kpi-value {
font-size: 28px;
}
}
</style>
+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>