6cefa620e4
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
765 lines
26 KiB
Vue
765 lines
26 KiB
Vue
<template>
|
||
<el-container class="layout-container">
|
||
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }">
|
||
<div class="aside-logo">
|
||
<div class="logo-icon"></div>
|
||
<span class="logo-text">{{ TEXT.common.appName }}</span>
|
||
</div>
|
||
<el-menu
|
||
:default-active="activeMenu"
|
||
:collapse="isCollapsed"
|
||
:collapse-transition="true"
|
||
class="aside-menu"
|
||
@select="handleMenuSelect"
|
||
>
|
||
<el-menu-item v-if="!isAdmin" index="/workbench">
|
||
<el-icon><Monitor /></el-icon>
|
||
<span>{{ TEXT.menu.workbench }}</span>
|
||
</el-menu-item>
|
||
|
||
<el-menu-item-group v-if="auth.user" class="menu-group">
|
||
<template #title>
|
||
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
||
</template>
|
||
<el-menu-item v-if="isAdmin" index="/admin/users">
|
||
<el-icon><User /></el-icon>
|
||
<span>{{ TEXT.menu.accountManagement }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item index="/admin/projects">
|
||
<el-icon><Suitcase /></el-icon>
|
||
<span>{{ TEXT.menu.projectManagement }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="isAdmin" index="/admin/audit-logs">
|
||
<el-icon><Document /></el-icon>
|
||
<span>{{ TEXT.menu.auditLogs }}</span>
|
||
</el-menu-item>
|
||
<el-sub-menu v-if="isAdmin" index="permissions">
|
||
<template #title>
|
||
<el-icon><Key /></el-icon>
|
||
<span>{{ TEXT.menu.permissionManagement }}</span>
|
||
</template>
|
||
<el-menu-item index="/admin/permissions/system">系统级权限</el-menu-item>
|
||
<el-menu-item index="/admin/permissions/project">项目权限配置</el-menu-item>
|
||
<el-menu-item index="/admin/permissions/monitoring">权限监控</el-menu-item>
|
||
</el-sub-menu>
|
||
</el-menu-item-group>
|
||
|
||
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
|
||
<template #title>
|
||
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
|
||
</template>
|
||
<el-menu-item v-if="canAccessProjectPath('/project/overview')" index="/project/overview">
|
||
<el-icon><House /></el-icon>
|
||
<span>{{ TEXT.menu.projectOverview }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/project/milestones')" index="/project/milestones">
|
||
<el-icon><Calendar /></el-icon>
|
||
<span>{{ TEXT.menu.projectMilestones }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/fees/contracts')" index="/fees/contracts">
|
||
<el-icon><Coin /></el-icon>
|
||
<span>{{ TEXT.menu.feeContracts }}</span>
|
||
</el-menu-item>
|
||
<el-sub-menu v-if="canAccessAnyProjectPath(['/drug/shipments', '/materials/equipment'])" index="materials">
|
||
<template #title>
|
||
<el-icon><Box /></el-icon>
|
||
<span>{{ TEXT.menu.materialManagement }}</span>
|
||
</template>
|
||
<el-menu-item v-if="canAccessProjectPath('/drug/shipments')" index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/materials/equipment')" index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
|
||
</el-sub-menu>
|
||
<el-menu-item v-if="canAccessProjectPath('/file-versions')" index="/file-versions">
|
||
<el-icon><Files /></el-icon>
|
||
<span>{{ TEXT.menu.fileVersionManagement }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/startup/feasibility-ethics')" index="/startup/feasibility-ethics">
|
||
<el-icon><Calendar /></el-icon>
|
||
<span>{{ TEXT.menu.startupFeasibilityEthics }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/startup/meeting-auth')" index="/startup/meeting-auth">
|
||
<el-icon><CircleCheck /></el-icon>
|
||
<span>{{ TEXT.menu.startupMeetingAuth }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/subjects')" index="/subjects">
|
||
<el-icon><User /></el-icon>
|
||
<span>{{ TEXT.menu.subjects }}</span>
|
||
</el-menu-item>
|
||
<el-sub-menu v-if="canAccessAnyProjectPath(['/risk-issues/sae', '/risk-issues/pd', '/risk-issues/monitoring-visits'])" index="risk-issues">
|
||
<template #title>
|
||
<el-icon><Flag /></el-icon>
|
||
<span>{{ TEXT.menu.riskIssues }}</span>
|
||
</template>
|
||
<el-menu-item v-if="canAccessProjectPath('/risk-issues/sae')" index="/risk-issues/sae">{{ TEXT.menu.riskIssueSae }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/risk-issues/pd')" index="/risk-issues/pd">{{ TEXT.menu.riskIssuePd }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/risk-issues/monitoring-visits')" index="/risk-issues/monitoring-visits">{{ TEXT.menu.riskIssueMonitoringVisits }}</el-menu-item>
|
||
</el-sub-menu>
|
||
<el-menu-item v-if="canAccessProjectPath('/monitoring-audit')" index="/monitoring-audit">
|
||
<el-icon><ChatDotRound /></el-icon>
|
||
<span>{{ TEXT.menu.monitoringAudit }}</span>
|
||
</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/etmf')" index="/etmf">
|
||
<el-icon><Flag /></el-icon>
|
||
<span>{{ TEXT.menu.etmf }}</span>
|
||
</el-menu-item>
|
||
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/notes', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
|
||
<template #title>
|
||
<el-icon><Notebook /></el-icon>
|
||
<span>{{ TEXT.menu.sharedLibrary }}</span>
|
||
</template>
|
||
<el-menu-item v-if="canAccessProjectPath('/knowledge/medical-consult')" index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/knowledge/notes')" index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
|
||
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
|
||
</el-sub-menu>
|
||
</el-menu-item-group>
|
||
</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="TEXT.common.labels.collapseSidebar">
|
||
<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>
|
||
|
||
<!-- 移入 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">
|
||
<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"
|
||
>
|
||
{{ userDisplayInitial }}
|
||
</el-avatar>
|
||
<span class="username">{{ userDisplayName }}</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>{{ TEXT.menu.profile }}</span>
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="logout">
|
||
<el-icon><SwitchButton /></el-icon>
|
||
<span>{{ TEXT.menu.logout }}</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 }">
|
||
<div class="ctms-route-shell">
|
||
<component :is="Component" />
|
||
</div>
|
||
</router-view>
|
||
</div>
|
||
</el-main>
|
||
</el-container>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch, watchEffect } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { useAuthStore } from "../store/auth";
|
||
import { useStudyStore } from "../store/study";
|
||
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, Key
|
||
} from "@element-plus/icons-vue";
|
||
import { ElMessage } from "element-plus";
|
||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||
|
||
const auth = useAuthStore();
|
||
const study = useStudyStore();
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||
const canAccessProjectPath = (path: string) =>
|
||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
||
|
||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||
watchEffect(() => {
|
||
const width = !hasSidebar.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
|
||
document.body.style.setProperty('--ctms-sidebar-width', width);
|
||
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
|
||
if (hasSidebar.value) {
|
||
document.body.classList.add('has-sidebar');
|
||
} else {
|
||
document.body.classList.remove('has-sidebar');
|
||
}
|
||
});
|
||
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("/finance/contracts")) return "/fees/contracts";
|
||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||
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("/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";
|
||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||
if (path.startsWith("/projects/")) return "/admin/projects";
|
||
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||
if (path.startsWith("/admin/permissions/")) return path;
|
||
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" },
|
||
"/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" },
|
||
"/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" },
|
||
"/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 = async (cmd: any) => {
|
||
if (cmd.type === 'study') {
|
||
study.setCurrentStudy(cmd.value);
|
||
await study.loadCurrentStudyPermissions().catch(() => {});
|
||
router.push("/project/overview");
|
||
} else if (cmd.type === 'site') {
|
||
study.setCurrentSite(cmd.value);
|
||
// 刷新当前页面或根据业务逻辑处理
|
||
ElMessage.success(`已切换至: ${cmd.value?.name || '所有中心'}`);
|
||
}
|
||
};
|
||
|
||
const handleMenuSelect = (index: string) => {
|
||
if (!index || index === route.path) {
|
||
return;
|
||
}
|
||
router.push(index);
|
||
};
|
||
|
||
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");
|
||
};
|
||
|
||
const onLogout = () => {
|
||
auth.logout();
|
||
study.clearCurrentStudy();
|
||
router.replace("/login");
|
||
};
|
||
|
||
onMounted(async () => {
|
||
if (auth.token && !auth.user) {
|
||
try {
|
||
await auth.fetchMe();
|
||
} catch {
|
||
onLogout();
|
||
return;
|
||
}
|
||
}
|
||
loadStudies();
|
||
});
|
||
|
||
const onCommand = (cmd: string) => {
|
||
if (cmd === "logout") {
|
||
onLogout();
|
||
} else if (cmd === "profile") {
|
||
router.push("/profile");
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.layout-container {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
background-color: transparent;
|
||
}
|
||
|
||
.layout-aside {
|
||
background-color: #2c3e50;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.08);
|
||
z-index: 10;
|
||
border-right: none;
|
||
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: 56px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
background-color: transparent;
|
||
transition: padding 0.18s ease;
|
||
}
|
||
|
||
.logo-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
background: #ffffff;
|
||
border-radius: 8px;
|
||
margin-right: 12px;
|
||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.logo-text {
|
||
color: #ffffff;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.5px;
|
||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||
}
|
||
|
||
.aside-menu {
|
||
border-right: none;
|
||
background-color: transparent;
|
||
flex: 1;
|
||
padding: 4px 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.menu-divider {
|
||
display: block;
|
||
padding: 8px 14px 2px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.layout-aside :deep(.menu-group .el-menu-item-group__title) {
|
||
padding: 0 !important;
|
||
}
|
||
|
||
.layout-aside :deep(.el-menu-item) {
|
||
height: 34px;
|
||
line-height: 34px;
|
||
margin: 1px 8px;
|
||
border-radius: 7px;
|
||
color: #f1f5f9 !important;
|
||
transition: var(--ctms-transition);
|
||
}
|
||
|
||
.layout-aside :deep(.el-menu-item:hover) {
|
||
color: #ffffff !important;
|
||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||
}
|
||
|
||
.layout-aside :deep(.el-menu-item.is-active) {
|
||
color: #ffffff !important;
|
||
background-color: rgba(255, 255, 255, 0.12) !important;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.layout-aside :deep(.el-menu-item.is-active::before) {
|
||
content: "";
|
||
position: absolute;
|
||
left: 0;
|
||
top: 8px;
|
||
bottom: 8px;
|
||
width: 3px;
|
||
background-color: #94b8d1;
|
||
border-radius: 0 4px 4px 0;
|
||
}
|
||
|
||
.layout-aside :deep(.el-sub-menu__title) {
|
||
color: #f1f5f9 !important;
|
||
margin: 1px 8px;
|
||
border-radius: 7px;
|
||
height: 34px;
|
||
line-height: 34px;
|
||
}
|
||
|
||
.layout-aside :deep(.el-sub-menu__title:hover) {
|
||
color: #ffffff !important;
|
||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.layout-aside :deep(.el-menu--inline .el-menu-item) {
|
||
padding-left: 40px !important;
|
||
margin: 0 10px;
|
||
}
|
||
|
||
.layout-header {
|
||
background-color: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 10px;
|
||
box-shadow: none;
|
||
height: 52px !important;
|
||
z-index: 9;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
}
|
||
|
||
.main-container {
|
||
background: #f7f8fb;
|
||
}
|
||
|
||
.header-breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--ctms-text-regular);
|
||
font-size: 13px;
|
||
margin-left: 14px;
|
||
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 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.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: 36px;
|
||
}
|
||
|
||
.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;
|
||
background: #f7f8fb;
|
||
}
|
||
|
||
.content-wrapper {
|
||
padding: 6px 8px;
|
||
min-height: calc(100vh - 52px);
|
||
min-height: calc(100dvh - 52px);
|
||
background: #f7f8fb;
|
||
}
|
||
|
||
.ctms-route-shell {
|
||
min-height: 100%;
|
||
}
|
||
|
||
/* 过渡动画 */
|
||
.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);
|
||
}
|
||
.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>
|