Files
ctms/frontend/src/components/WebLayout.vue
T
Cheng Zhou b491b6a146
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
发布候选:整合桌面端界面与发布稳定化里程碑
2026-07-01 10:53:24 +08:00

2536 lines
79 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-container class="layout-container web-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-group v-if="isDesktop && desktopFavoriteRoutes.length" class="menu-group desktop-menu-group">
<template #title>
<span class="menu-divider">桌面收藏</span>
</template>
<el-menu-item v-for="item in desktopFavoriteRoutes" :key="`favorite:${item.path}`" :index="item.path">
<el-icon><StarFilled /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="isDesktop && desktopRecentRoutes.length" class="menu-group desktop-menu-group">
<template #title>
<span class="menu-divider">最近访问</span>
</template>
<el-menu-item v-for="item in desktopRecentRoutes" :key="`recent:${item.path}`" :index="item.path">
<el-icon><Clock /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu-item-group>
<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-menu-item v-if="isAdmin" index="/admin/system-monitoring">
<el-icon><DataAnalysis /></el-icon>
<span>{{ TEXT.menu.systemMonitoring }}</span>
</el-menu-item>
<el-sub-menu v-if="isAdmin" index="system-settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/admin/email-settings">邮件服务</el-menu-item>
</el-sub-menu>
<el-sub-menu v-if="canAccessAdminPermissions" 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-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('/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/precautions', '/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/precautions')" index="/knowledge/precautions">{{ 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="breadcrumbKey(item, index)">
<!-- 带下拉的选择器类型 -->
<el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-link is-study">
<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>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 同级导航下拉在同级模块/页面之间横跳 -->
<el-dropdown v-else-if="item.navDropdown && item.siblings?.length" trigger="click" :hide-on-click="true" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-nav" :class="{ 'is-current': index === breadcrumbs.length - 1 }">
<span class="breadcrumb-item-text" :class="{ 'is-link-text': index < breadcrumbs.length - 1 }">{{ item.label }}</span>
<el-icon class="breadcrumb-dropdown-icon breadcrumb-nav-icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="breadcrumb-dropdown-menu">
<el-dropdown-item
v-for="sib in item.siblings"
:key="sib.path"
:command="{ type: 'nav', value: sib.path }"
:class="{ active: (item.activePath || item.path) === sib.path || item.label === sib.label }"
>
{{ sib.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 普通链接或静态文本 -->
<div
v-else
class="breadcrumb-item-wrapper"
:class="{ 'is-link': item.path && index < breadcrumbs.length - 1, 'is-current': 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>
</template>
</div>
</div>
<!-- 顶栏中间区常驻上下文信息 -->
<div class="header-center">
<template v-if="headerProjectInfo">
<div class="header-context-rail">
<span
v-if="headerProjectInfo.statusLabel"
class="header-status-pill"
:class="`is-${headerProjectInfo.statusTone}`"
>
<span class="header-status-dot"></span>
{{ headerProjectInfo.statusLabel }}
</span>
<span v-if="headerProjectInfo.isLocked" class="header-meta-item header-locked">
<el-icon><Lock /></el-icon>
{{ TEXT.common.labels.locked }}
</span>
<span
v-for="headerMetric in headerProjectInfo.metrics"
:key="headerMetric.key"
class="header-meta-item header-metric"
:class="`is-${headerMetric.key}`"
>
<span class="header-meta-label">{{ headerMetric.label }}</span>
<span class="header-meta-value">{{ headerMetric.value }}</span>
</span>
</div>
</template>
</div>
<div class="header-right">
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>搜索命令</span>
<kbd>K</kbd>
</button>
<el-tooltip v-if="isDesktop" :content="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" placement="bottom">
<button class="desktop-icon-button" type="button" @click="toggleCurrentFavorite">
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
</button>
</el-tooltip>
<el-popover v-if="isDesktop" placement="bottom-end" width="320" trigger="click" popper-class="desktop-connection-popover">
<template #reference>
<button class="desktop-connection-button" type="button">
<span class="connection-dot" :class="desktopConnectionClass"></span>
<span class="connection-label">{{ desktopConnectionLabel }}</span>
</button>
</template>
<div class="desktop-connection-panel">
<div class="connection-panel-title">在线桌面客户端</div>
<div class="connection-panel-row">
<span>服务器</span>
<code>{{ desktopServerUrl || "未配置" }}</code>
</div>
<div class="connection-panel-row">
<span>客户端</span>
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
</div>
<el-button size="small" @click="openDesktopPreferences">系统偏好</el-button>
</div>
</el-popover>
<el-tooltip v-if="isDesktop" content="系统偏好" placement="bottom">
<button class="desktop-icon-button" type="button" @click="openDesktopPreferences">
<el-icon><Monitor /></el-icon>
</button>
</el-tooltip>
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
<el-badge
v-if="headerReminderTotal > 0"
:value="headerReminderBadgeValue"
class="header-reminder-badge"
>
<el-icon><Bell /></el-icon>
</el-badge>
<el-icon v-else><Bell /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="header-reminder-menu">
<div class="header-reminder-panel">
<div class="header-reminder-panel-head">
<div>
<div class="header-reminder-title">{{ TEXT.common.labels.projectReminders }}</div>
<div class="header-reminder-subtitle">{{ TEXT.common.labels.projectRemindersSubtitle }}</div>
</div>
<span v-if="headerReminderTotal > 0" class="header-reminder-total">{{ headerReminderTotal }}</span>
</div>
<template v-if="headerReminderItems.length">
<el-dropdown-item
v-for="item in headerReminderItems"
:key="item.key"
:command="item.command"
class="header-reminder-item"
>
<span class="header-reminder-item-mark" :class="`is-${item.tone}`"></span>
<span class="header-reminder-item-main">
<span class="header-reminder-item-title">{{ item.title }}</span>
<span class="header-reminder-item-desc">{{ item.description }}</span>
</span>
<span class="header-reminder-item-count">{{ item.count }}</span>
</el-dropdown-item>
</template>
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<el-dropdown-item
v-if="hasAnyRiskReminderAccess"
command="riskIssues"
divided
class="header-reminder-footer"
>
{{ TEXT.common.actions.view }}{{ TEXT.menu.riskIssues }}
</el-dropdown-item>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<span v-if="accountProjectRoleLabel" class="header-account-role">
<el-icon><UserFilled /></el-icon>
{{ accountProjectRoleLabel }}
</span>
<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 v-if="isDesktop" command="desktopPreferences">
<el-icon><Monitor /></el-icon>
<span>系统偏好</span>
</el-dropdown-item>
<el-dropdown-item command="logout" :disabled="loggingOut" divided>
<el-icon><SwitchButton /></el-icon>
<span>{{ loggingOut ? "正在退出..." : 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, route: currentRoute }">
<transition name="fade-transform" mode="out-in">
<div :key="currentRoute.fullPath" class="ctms-route-shell">
<component v-if="Component" :is="Component" />
</div>
</transition>
</router-view>
</div>
</el-main>
</el-container>
</el-container>
<el-dialog
v-model="profileDialogVisible"
class="profile-settings-dialog"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:before-close="beforeProfileDialogClose"
width="980px"
align-center
destroy-on-close
@closed="profileDialogDirty = false"
>
<ProfileSettings
@close-request="requestProfileDialogClose"
@dirty-change="profileDialogDirty = $event"
@saved="profileDialogVisible = false"
/>
</el-dialog>
<DesktopCommandPalette
v-if="isDesktop"
v-model="commandPaletteVisible"
:commands="desktopCommands"
/>
<el-dialog
v-if="isDesktop"
v-model="desktopPreferencesVisible"
class="desktop-preferences-dialog"
:show-close="false"
:close-on-click-modal="true"
width="720px"
align-center
destroy-on-close
>
<DesktopPreferences @close-request="desktopPreferencesVisible = false" />
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, 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 { fetchProjectOverview } from "../api/overview";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
import type { DesktopCommand } from "../types/desktopCommands";
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const route = useRoute();
const isDesktop = isTauriRuntime();
const desktopMetadata = getAppMetadata();
const isAdmin = computed(() => !!auth.user?.is_admin);
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
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));
const loggingOut = ref(false);
const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{
centerActual: number;
enrollmentActual: number;
enrollmentTarget: number;
updatedAt: string;
} | null>(null);
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const headerClockNow = ref(new Date());
let headerClockTimer: number | undefined;
let desktopMenuUnlisten: (() => void) | undefined;
/* 动态设置 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 isAdminContext = computed(() => route.path.startsWith("/admin"));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const formatHeaderProgress = (actual: number, target?: number | null) =>
target && target > 0 ? `${actual}/${target}` : String(actual);
const formatHeaderDateTime = (date: Date) => {
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value));
const loadHeaderOverviewStats = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value) {
headerOverviewStats.value = null;
return;
}
try {
const { data } = await fetchProjectOverview(studyId);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
const centers = Array.isArray((data as any)?.centers) ? (data as any).centers : [];
headerOverviewStats.value = {
centerActual: centers.length,
enrollmentActual: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_actual), 0),
enrollmentTarget: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_target), 0),
updatedAt: typeof (data as any)?.updated_at === "string" ? (data as any).updated_at : "",
};
} catch {
if (study.currentStudy?.id === studyId) {
headerOverviewStats.value = null;
}
}
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() =>
headerReminderItems.value.reduce((sum, item) => sum + item.count, 0)
);
const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
const headerProjectInfo = computed(() => {
if (isAdminContext.value || !study.currentStudy) return null;
const s = study.currentStudy;
const status = (s.status || "").toUpperCase();
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
const statusTone = status === "ACTIVE" ? "active" : status === "CLOSED" ? "closed" : "draft";
const plannedSiteCount = s.planned_site_count;
const plannedEnrollmentCount = s.planned_enrollment_count;
const overviewStats = headerOverviewStats.value;
const centerActual = overviewStats?.centerActual || 0;
const enrollmentActual = overviewStats?.enrollmentActual || 0;
const enrollmentTarget = overviewStats?.enrollmentTarget || toHeaderNumber(plannedEnrollmentCount);
const updatedAt = headerClockText.value;
const metrics = [
plannedSiteCount || centerActual ? {
key: "plannedSiteCount",
label: TEXT.common.labels.plannedSites,
value: formatHeaderProgress(centerActual || toHeaderNumber(plannedSiteCount), plannedSiteCount),
} : null,
plannedEnrollmentCount || enrollmentActual || enrollmentTarget ? {
key: "plannedEnrollmentCount",
label: TEXT.common.labels.plannedEnrollment,
value: formatHeaderProgress(enrollmentActual || toHeaderNumber(plannedEnrollmentCount), enrollmentTarget || plannedEnrollmentCount),
} : null,
updatedAt ? {
key: "updatedAt",
label: TEXT.common.labels.dataUpdatedAt,
value: updatedAt,
} : null,
].filter(Boolean) as { key: string; label: string; value: string | number }[];
return {
statusLabel,
statusTone,
isLocked: Boolean((s as any).is_locked),
metrics,
};
});
const accountProjectRoleLabel = computed(() => {
if (isAdminContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
});
type DesktopNavigationItem = {
label: string;
path: string;
group: string;
keywords?: string[];
};
const activeMenu = computed(() => getActiveLayoutPath(route.path));
const studies = ref<any[]>([]);
const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
const items: DesktopNavigationItem[] = [];
if (auth.user) {
if (isAdmin.value) {
items.push({ label: TEXT.menu.accountManagement, path: "/admin/users", group: TEXT.menu.admin, keywords: ["user", "account"] });
}
items.push({ label: TEXT.menu.projectManagement, path: "/admin/projects", group: TEXT.menu.admin, keywords: ["project"] });
if (isAdmin.value) {
items.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs", group: TEXT.menu.admin, keywords: ["audit"] });
items.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring", group: TEXT.menu.admin, keywords: ["monitoring"] });
items.push({ label: "邮件服务", path: "/admin/email-settings", group: TEXT.menu.admin, keywords: ["email"] });
}
if (canAccessAdminPermissions.value) {
items.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system", group: TEXT.menu.admin, keywords: ["permission"] });
}
}
if (study.currentStudy && hasAnyProjectModuleAccess.value) {
const projectItems: DesktopNavigationItem[] = [
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
{ label: TEXT.menu.feeContracts, path: "/fees/contracts", group: TEXT.menu.currentProject, keywords: ["fee", "contract"] },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", group: TEXT.menu.materialManagement, keywords: ["drug", "shipment"] },
{ label: TEXT.menu.materialEquipment, path: "/materials/equipment", group: TEXT.menu.materialManagement, keywords: ["material", "equipment"] },
{ label: TEXT.menu.fileVersionManagement, path: "/file-versions", group: TEXT.menu.currentProject, keywords: ["document", "file", "version"] },
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", group: TEXT.menu.currentProject, keywords: ["startup", "ethics"] },
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", group: TEXT.menu.currentProject, keywords: ["meeting", "training"] },
{ label: TEXT.menu.subjects, path: "/subjects", group: TEXT.menu.currentProject, keywords: ["subject"] },
{ label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae", group: TEXT.menu.riskIssues, keywords: ["sae", "risk"] },
{ label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd", group: TEXT.menu.riskIssues, keywords: ["pd", "risk"] },
{ label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits", group: TEXT.menu.riskIssues, keywords: ["monitoring", "visit"] },
{ label: TEXT.menu.etmf, path: "/etmf", group: TEXT.menu.currentProject, keywords: ["etmf"] },
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "medical"] },
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
];
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
}
return items;
});
const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return;
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
};
const currentDesktopRoutePreference = computed(() => {
const activePath = activeMenu.value;
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activePath);
if (!item) return null;
return { path: item.path, title: item.label, group: item.group };
});
const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
});
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
const updateDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
};
const openCommandPalette = () => {
if (!isDesktop) return;
commandPaletteVisible.value = true;
};
const openDesktopPreferences = () => {
if (!isDesktop) return;
desktopPreferencesVisible.value = true;
};
const toggleCurrentFavorite = () => {
const current = currentDesktopRoutePreference.value;
if (!current) return;
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute(current);
refreshDesktopRoutePreferences();
};
const refreshCurrentDesktopView = () => {
const handled = dispatchDesktopRefreshCurrentView();
if (!handled) router.go(0);
};
const handleDesktopMenuCommand = (command: string) => {
if (command === "ctms.desktop.commandPalette") {
openCommandPalette();
return;
}
if (command === "ctms.desktop.preferences") {
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings");
return;
}
if (command === "ctms.desktop.refresh") {
refreshCurrentDesktopView();
return;
}
if (command === "ctms.desktop.back") {
router.back();
return;
}
if (command === "ctms.desktop.forward") {
router.forward();
}
};
const desktopCommands = computed<DesktopCommand[]>(() => {
const navigationCommands: DesktopCommand[] = desktopNavigationItems.value.map((item) => ({
id: `route:${item.path}`,
title: item.label,
group: item.group,
keywords: item.keywords,
visible: true,
run: () => {
void router.push(item.path);
},
}));
const projectCommands: DesktopCommand[] = studies.value.map((item) => ({
id: `study:${item.id}`,
title: `切换项目:${item.name}`,
group: "项目",
keywords: [item.name, item.project_no, item.protocol_no].filter(Boolean),
visible: Boolean(item?.id),
run: async () => {
study.setCurrentStudy(item);
await study.loadCurrentStudyPermissions().catch(() => {});
await router.push("/project/overview");
},
}));
return [
{
id: "desktop:refresh",
title: "刷新当前视图",
group: "桌面",
shortcut: "⌘R",
visible: true,
run: refreshCurrentDesktopView,
},
{
id: "desktop:preferences",
title: "打开系统偏好",
group: "桌面",
shortcut: "⌘,",
visible: true,
run: openDesktopPreferences,
},
{
id: "desktop:server-settings",
title: "服务器设置",
group: "桌面",
visible: true,
run: () => {
void router.push("/desktop/server-settings");
},
},
{
id: "desktop:update",
title: "检查桌面更新",
group: "桌面",
visible: true,
run: async () => {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
},
},
{
id: "desktop:favorite-current",
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
group: "桌面",
visible: Boolean(currentDesktopRoutePreference.value),
run: toggleCurrentFavorite,
},
...navigationCommands,
...projectCommands,
];
});
const loadStudies = async () => {
try {
const { data } = await fetchStudies();
studies.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {}
};
const breadcrumbs = computed(() => {
const items: any[] = [];
if (route.path.startsWith("/admin")) {
// 顶级管理后台菜单(按权限过滤),供末级面包屑横跳
const topItems: { label: string; path: string }[] = [];
if (isAdmin.value) topItems.push({ label: TEXT.menu.accountManagement, path: "/admin/users" });
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs" });
if (isAdmin.value) topItems.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring" });
if (isAdmin.value) topItems.push({ label: "邮件服务", path: "/admin/email-settings" });
if (canAccessAdminPermissions.value) topItems.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system" });
// 权限管理子页,供「权限管理」父级与末级横跳
const permItems = [
{ label: "系统级权限", path: "/admin/permissions/system" },
{ label: "项目权限配置", path: "/admin/permissions/project" },
];
// 首项「管理后台」为分组标签,不可点击
items.push({ label: TEXT.menu.admin });
// 第二级:当前所属的顶级模块,带下拉横跳到其它顶级模块
const curTop = topItems.find((p) => route.path === p.path || route.path.startsWith(p.path + "/"))
|| (route.path.startsWith("/admin/projects") ? topItems.find((p) => p.path === "/admin/projects") : undefined)
|| (route.path.startsWith("/admin/permissions") ? topItems.find((p) => p.path === "/admin/permissions/system") : undefined);
// 权限管理子页:模块级横跳顶级模块,末级横跳权限子页
if (route.path.startsWith("/admin/permissions/")) {
const cur = permItems.find((p) => route.path.startsWith(p.path));
items.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system", siblings: topItems, navDropdown: true, activePath: "/admin/permissions/system" });
items.push({ label: cur?.label || (route.meta?.title as string), path: route.path, siblings: permItems, navDropdown: true, activePath: cur?.path });
return items;
}
// 项目下钻页:项目管理⌄(模块下拉) -> (中心管理时)项目名 -> 中心管理
if (route.path.startsWith("/admin/projects/")) {
items.push({ label: TEXT.menu.projectManagement, path: "/admin/projects", siblings: topItems, navDropdown: true, activePath: "/admin/projects" });
// 中心管理等子页补上项目名层级(项目详情页本身由 viewContext 提供项目名作为末项)
if (route.path.includes("/sites")) {
const pid = String(route.params.projectId || "");
const proj = studies.value.find((s) => String(s.id) === pid);
if (proj?.name) {
items.push({ label: proj.name, path: `/admin/projects/${pid}` });
}
items.push({ label: TEXT.modules.adminSites.title, path: route.path });
return items;
}
// 项目详情:项目名由 viewContext 作为末项提供,继续走通用逻辑
} else {
// 顶级页面:模块名带同级下拉,可在管理后台各模块间横跳
items.push({
label: curTop?.label || (route.meta?.title as string),
path: route.path,
siblings: topItems,
navDropdown: true,
activePath: curTop?.path,
});
return items;
}
} else if (study.currentStudy) {
// 1. 项目名(可下拉切换项目)
items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
path: "/project/overview",
hasDropdown: true,
type: 'study'
});
// 3. 模块级面包屑:依据侧边栏导航树,顶级模块为二级,子菜单项为三级
type NavNode = { label: string; path: string; match?: string[]; children?: NavNode[] };
const navTree: NavNode[] = [
{ label: TEXT.menu.projectOverview, path: "/project/overview" },
{ label: TEXT.menu.projectMilestones, path: "/project/milestones" },
{ label: TEXT.menu.feeContracts, path: "/fees/contracts" },
{ label: TEXT.menu.materialManagement, path: "/drug/shipments", children: [
{ label: TEXT.menu.drugShipments, path: "/drug/shipments" },
{ label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
]},
{ label: TEXT.menu.fileVersionManagement, path: "/file-versions", match: ["/file-versions", "/documents", "/trial/"] },
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", match: ["/startup/feasibility", "/startup/ethics"] },
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", match: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"] },
{ label: TEXT.menu.subjects, path: "/subjects" },
{ label: TEXT.menu.riskIssues, path: "/risk-issues/sae", children: [
{ label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
{ label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
{ label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits" },
]},
{ label: TEXT.menu.etmf, path: "/etmf" },
{ label: TEXT.menu.sharedLibrary, path: "/knowledge/medical-consult", children: [
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files" },
]},
];
const nodeMatches = (n: NavNode) =>
(n.match || [n.path]).some((p) => route.path.startsWith(p));
// 定位当前所属的顶级模块与子项
let matchedTop: NavNode | null = null;
let matchedChild: NavNode | null = null;
for (const top of navTree) {
if (top.children) {
const child = top.children.find((c) => nodeMatches(c));
if (child) { matchedTop = top; matchedChild = child; break; }
} else if (nodeMatches(top)) {
matchedTop = top; break;
}
}
if (matchedTop) {
// 二级:顶级模块(下拉列出所有有权访问的顶级模块)
const topSiblings = navTree
.filter((n) => (n.children ? n.children.some((c) => canAccessProjectPath(c.path)) : canAccessProjectPath(n.path)))
.map((n) => ({ label: n.label, path: n.path }));
items.push({
label: matchedTop.label,
path: matchedTop.path,
siblings: topSiblings.length > 1 ? topSiblings : undefined,
navDropdown: topSiblings.length > 1,
activePath: matchedTop.path,
});
// 三级:子菜单项(下拉列出同分组内有权访问的兄弟项)
if (matchedChild && matchedTop.children) {
const childSiblings = matchedTop.children
.filter((c) => canAccessProjectPath(c.path))
.map((c) => ({ label: c.label, path: c.path }));
items.push({
label: matchedChild.label,
path: matchedChild.path,
siblings: childSiblings.length > 1 ? childSiblings : undefined,
navDropdown: childSiblings.length > 1,
activePath: matchedChild.path,
});
}
}
}
// 4. 当前页面标题
const hasContextPageTitle = Boolean(study.viewContext?.pageTitle);
const title = route.meta?.title as string | undefined;
if (!hasContextPageTitle && title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview) {
// 检查 title 是否已经作为模块名添加过了
const isModuleTitle = items.some(item => item.label === title);
if (!isModuleTitle) {
items.push({
label: title,
path: route.path
});
}
}
if (study.viewContext?.pageTitle) {
items.push({ label: study.viewContext.pageTitle, path: route.path });
}
return items;
});
const breadcrumbKey = (item: any, index: number) => {
const type = item.type || "item";
const path = item.path || "";
const label = item.label || "";
return `${index}:${type}:${path}:${label}`;
};
const handleBreadcrumbCommand = async (cmd: any) => {
if (cmd.type === 'nav') {
if (cmd.value && cmd.value !== route.path) router.push(cmd.value);
return;
}
if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value);
await study.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
}
};
const handleMenuSelect = (index: string) => {
if (!index || index === route.path) {
return;
}
router.push(index);
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
return;
}
if (cmd === "riskIssues") {
const path = canReadRiskIssueAes.value
? "/risk-issues/sae"
: canReadMonitoringIssues.value
? "/risk-issues/monitoring-visits"
: "";
if (path) router.push(path);
}
};
watch(() => route.path, () => {
study.setViewContext(null);
if (isDesktop) {
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences();
}
}
});
watch(
() => [study.currentStudy?.id, route.path] as const,
() => {
loadHeaderOverviewStats();
loadHeaderReminders();
refreshDesktopRoutePreferences();
},
);
watch(
() => desktopNavigationItems.value.map((item) => item.path).join("|"),
() => refreshDesktopRoutePreferences(),
);
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
};
const logoutImmediately = async () => {
await auth.logout();
study.clearCurrentStudy();
router.replace("/login");
};
const onLogout = async () => {
if (loggingOut.value) return;
const confirmed = await ElMessageBox.confirm(
"退出后需要重新登录才能继续访问项目数据。",
"确认退出登录",
{
type: "warning",
confirmButtonText: "退出登录",
cancelButtonText: TEXT.common.actions.cancel,
distinguishCancelAndClose: true,
},
).catch(() => null);
if (!confirmed) return;
loggingOut.value = true;
try {
await forceLogout(LOGOUT_REASON_MANUAL);
} finally {
window.setTimeout(() => {
loggingOut.value = false;
}, 300);
}
};
onMounted(async () => {
headerClockNow.value = new Date();
headerClockTimer = window.setInterval(() => {
headerClockNow.value = new Date();
}, 1000);
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
}
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
}
if (auth.token && !auth.user) {
try {
await auth.fetchMe();
} catch {
await logoutImmediately();
return;
}
}
loadStudies();
loadHeaderOverviewStats();
loadHeaderReminders();
});
onBeforeUnmount(() => {
if (headerClockTimer) {
window.clearInterval(headerClockTimer);
}
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
desktopMenuUnlisten?.();
});
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "profile") {
profileDialogVisible.value = true;
} else if (cmd === "desktopPreferences") {
openDesktopPreferences();
}
};
const confirmDiscardProfileChanges = async () => {
if (!profileDialogDirty.value) return true;
const confirmed = await ElMessageBox.confirm(
"当前修改尚未保存,关闭后将丢失这些改动。",
"确认关闭个人中心",
{
type: "warning",
confirmButtonText: "关闭",
cancelButtonText: "继续编辑",
}
).catch(() => false);
return Boolean(confirmed);
};
const requestProfileDialogClose = async () => {
if (await confirmDiscardProfileChanges()) {
profileDialogVisible.value = false;
}
};
const beforeProfileDialogClose = async (done: () => void) => {
if (await confirmDiscardProfileChanges()) {
done();
}
};
useDesktopShortcuts(
() => isDesktop,
{
openCommandPalette,
closeActiveLayer: () => {
if (commandPaletteVisible.value) {
commandPaletteVisible.value = false;
return true;
}
if (desktopPreferencesVisible.value) {
desktopPreferencesVisible.value = false;
return true;
}
return false;
},
navigateBack: () => router.back(),
navigateForward: () => router.forward(),
refreshCurrentView: refreshCurrentDesktopView,
},
);
</script>
<style scoped>
.layout-container {
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
background-color: transparent;
}
.web-layout-container {
background: #f3f6f9;
}
.web-layout-container .layout-aside {
background:
linear-gradient(180deg, #26384c 0%, #1f2d3d 100%);
box-shadow: none;
border-right: 1px solid rgba(15, 23, 42, 0.2);
}
.web-layout-container .aside-logo {
height: 60px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.web-layout-container .layout-header {
height: 52px !important;
padding: 0 16px;
background: #ffffff;
box-shadow: none;
border-bottom: 1px solid #e2e8f0;
}
.web-layout-container .layout-header::after {
display: none;
}
.web-layout-container .main-container,
.web-layout-container .layout-main,
.web-layout-container .content-wrapper {
background: #f3f6f9;
}
.web-layout-container .content-wrapper {
min-height: calc(100vh - 52px);
min-height: calc(100dvh - 52px);
padding: 12px 14px;
}
.layout-aside {
background-color: #2c3e50;
display: flex;
flex-direction: column;
box-shadow: 4px 0 18px rgba(15, 23, 42, 0.12);
z-index: 10;
border-right: none;
transition: width 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms ease;
overflow: hidden;
}
.aside-menu {
transition: padding 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.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 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.logo-icon {
width: 28px;
height: 28px;
background: #ffffff;
border-radius: 8px;
margin-right: 12px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.26);
}
.logo-text {
color: #ffffff;
font-size: 18px;
font-weight: 800;
letter-spacing: 0.5px;
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.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;
position: relative;
overflow: hidden;
transform: translateX(0);
transition:
background-color 180ms ease,
color 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 180ms ease;
will-change: transform;
}
.layout-aside :deep(.el-menu-item:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.09) !important;
transform: translateX(2px);
}
.layout-aside :deep(.el-menu-item:active) {
transform: translateX(1px) scale(0.985);
}
.layout-aside :deep(.el-menu-item .el-icon),
.layout-aside :deep(.el-sub-menu__title .el-icon) {
transition:
color 180ms ease,
opacity 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.layout-aside :deep(.el-menu-item.is-active) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.14) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 22px rgba(15, 23, 42, 0.14);
}
.layout-aside :deep(.el-menu-item.is-active .el-icon) {
transform: translateX(1px) scale(1.04);
}
.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;
transform: scaleY(1);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms ease;
}
.layout-aside :deep(.el-menu-item:not(.is-active)::before) {
content: "";
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background-color: #94b8d1;
border-radius: 0 4px 4px 0;
opacity: 0;
transform: scaleY(0.35);
}
.layout-aside :deep(.el-sub-menu__title) {
color: #f1f5f9 !important;
margin: 1px 8px;
border-radius: 7px;
height: 34px;
line-height: 34px;
position: relative;
overflow: hidden;
transform: translateX(0);
transition:
background-color 180ms ease,
color 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 180ms ease;
}
.layout-aside :deep(.el-sub-menu__title:hover) {
color: #ffffff !important;
background-color: rgba(255, 255, 255, 0.09) !important;
transform: translateX(2px);
}
.layout-aside :deep(.el-sub-menu__title:active) {
transform: translateX(1px) scale(0.985);
}
.layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title) {
color: #ffffff !important;
}
.layout-aside :deep(.el-sub-menu.is-active .el-sub-menu__title .el-icon) {
transform: translateX(1px) scale(1.04);
}
.layout-aside :deep(.el-sub-menu__icon-arrow) {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.layout-aside :deep(.el-menu--inline) {
background-color: transparent !important;
margin: 2px 0 4px !important;
}
.layout-aside :deep(.el-menu--inline .el-menu-item) {
padding-left: 40px !important;
margin: 1px 10px;
color: rgba(241, 245, 249, 0.88) !important;
}
.layout-aside :deep(.el-menu--inline .el-menu-item:hover) {
color: #ffffff !important;
}
.layout-aside :deep(.el-menu--inline .el-menu-item.is-active) {
color: #ffffff !important;
}
.layout-header {
position: sticky;
top: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.94) 100%);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 1px 0 0 rgba(226, 232, 240, 0.9),
0 1px 0 rgba(148, 163, 184, 0.28),
0 10px 26px rgba(15, 23, 42, 0.08);
height: 48px !important;
z-index: 30;
border-bottom: 1px solid rgba(203, 213, 225, 0.78);
isolation: isolate;
}
.layout-header::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -10px;
height: 10px;
pointer-events: none;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0));
opacity: 0.72;
z-index: -1;
}
.main-container {
background: #f7f8fb;
min-width: 0;
}
.header-breadcrumb {
display: flex;
align-items: center;
flex: 1 1 auto;
gap: 2px;
color: var(--ctms-text-regular);
font-size: 12.5px;
margin-left: 8px;
user-select: none;
min-width: 0;
max-width: 52vw;
overflow: hidden;
}
.breadcrumb-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 7px;
color: #64748b;
transition:
background-color 0.16s ease,
border-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
}
.breadcrumb-item-wrapper.is-link {
cursor: pointer;
}
.breadcrumb-item-wrapper.is-link:hover {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(183, 200, 220, 0.8);
color: #315f9d;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 3px 9px rgba(38, 63, 102, 0.07);
}
.breadcrumb-item-wrapper.is-study {
color: #4f83e8;
font-weight: 700;
padding: 0 6px 0 7px;
border-color: rgba(198, 214, 235, 0.92);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(243, 247, 253, 0.94) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 2px 7px rgba(38, 63, 102, 0.05);
}
.breadcrumb-item-wrapper.is-study:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(241, 246, 255, 0.82) 100%);
border-color: rgba(160, 188, 234, 0.7);
}
.breadcrumb-item-wrapper.is-current {
color: #172033;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(243, 247, 253, 0.94) 100%);
border-color: rgba(206, 218, 233, 0.92);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 2px 7px rgba(38, 63, 102, 0.05);
}
/* 同级导航下拉:克制的可交互态 */
.breadcrumb-item-wrapper.is-nav {
cursor: pointer;
}
.breadcrumb-item-wrapper.is-nav:hover {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(183, 200, 220, 0.8);
}
.breadcrumb-item-wrapper.is-nav:hover .breadcrumb-nav-icon {
color: #64748b;
}
.breadcrumb-item-wrapper.is-nav.is-current .breadcrumb-item-text {
color: #172033;
font-weight: 700;
}
.breadcrumb-nav-icon {
opacity: 0.5;
transition: transform 0.16s ease, opacity 0.16s ease, color 0.16s ease;
}
.breadcrumb-item-wrapper.is-nav:hover .breadcrumb-nav-icon {
opacity: 1;
transform: translateY(1px);
}
.is-link-text {
color: inherit;
font-weight: inherit;
}
.breadcrumb-item-text {
font-weight: inherit;
min-width: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0;
}
.breadcrumb-dropdown-icon {
font-size: 12px;
color: currentColor;
opacity: 0.55;
margin-top: 1px;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.breadcrumb-item-wrapper:hover .breadcrumb-dropdown-icon {
opacity: 0.85;
transform: translateY(1px);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.desktop-command-trigger {
display: inline-grid;
grid-template-columns: auto minmax(0, auto) auto;
align-items: center;
gap: 7px;
height: 32px;
max-width: 176px;
padding: 0 8px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
color: #475569;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.desktop-command-trigger:hover,
.desktop-icon-button:hover,
.desktop-connection-button:hover {
border-color: #b7c8dd;
background: #f8fbff;
color: #0f172a;
}
.desktop-command-trigger span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desktop-command-trigger kbd {
padding: 1px 5px;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #f8fafc;
color: #64748b;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.desktop-icon-button,
.desktop-connection-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.desktop-icon-button {
width: 32px;
}
.desktop-connection-button {
gap: 7px;
padding: 0 9px;
border-color: #d7e2f0;
background: #ffffff;
}
.connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #f59e0b;
}
.connection-dot.is-connected {
background: #16a34a;
}
.connection-label {
color: #334155;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.desktop-connection-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.connection-panel-title {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.connection-panel-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
gap: 10px;
color: #64748b;
font-size: 12px;
}
.connection-panel-row code {
overflow-wrap: anywhere;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.header-left {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* 顶栏中间区:常驻上下文信息 */
.header-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 0 12px;
overflow: hidden;
}
.header-context-rail {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 100%;
height: 30px;
padding: 1px 0;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
box-shadow: none;
overflow: visible;
white-space: nowrap;
}
.header-status-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 13px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 13.5px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
letter-spacing: 0;
overflow: hidden;
transition:
transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.24s ease,
border-color 0.24s ease;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
inset 0 -1px 0 rgba(15, 23, 42, 0.04);
}
/* 状态药丸高光扫光:缓慢横扫一道柔光,营造高级质感 */
.header-status-pill::after {
content: "";
position: absolute;
top: 0;
left: -60%;
width: 45%;
height: 100%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.55) 50%,
transparent 100%
);
transform: skewX(-18deg);
opacity: 0;
pointer-events: none;
}
.header-status-pill:hover {
transform: translateY(-1px);
}
.header-status-pill.is-active {
border-color: rgba(96, 188, 146, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.92) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #e6fbf0 0%, #cdeedd 55%, #b9e7d2 100%);
color: #1d6e4f;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(30, 109, 80, 0.12),
0 2px 10px rgba(34, 145, 100, 0.18);
}
.header-status-pill.is-active::after {
animation: header-pill-sheen 4.4s ease-in-out 1.2s infinite;
}
.header-status-pill.is-active:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(30, 109, 80, 0.14),
0 6px 18px rgba(34, 145, 100, 0.28);
}
.header-status-pill.is-closed {
border-color: rgba(232, 175, 96, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.94) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fff7e9 0%, #fbe6bf 55%, #f8dca6 100%);
color: #8f520c;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(143, 82, 12, 0.1),
0 2px 10px rgba(214, 152, 56, 0.16);
}
.header-status-pill.is-closed:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(143, 82, 12, 0.12),
0 6px 18px rgba(214, 152, 56, 0.26);
}
.header-status-pill.is-draft {
border-color: rgba(196, 209, 224, 0.96);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.95) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fbfdff 0%, #eef3f9 55%, #e4ecf5 100%);
color: #4f667e;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(83, 106, 130, 0.08),
0 2px 9px rgba(120, 138, 163, 0.14);
}
.header-status-pill.is-draft:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
inset 0 -1px 0 rgba(83, 106, 130, 0.1),
0 6px 16px rgba(120, 138, 163, 0.2);
}
.header-status-dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow:
0 0 0 3px color-mix(in srgb, currentColor 16%, transparent),
0 1px 2px rgba(15, 23, 42, 0.14);
}
/* 进行中状态点呼吸光环:周期性向外扩散并淡出 */
.header-status-pill.is-active .header-status-dot::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: currentColor;
animation: header-dot-pulse 2.4s ease-out infinite;
}
@keyframes header-pill-sheen {
0%,
78% {
left: -60%;
opacity: 0;
}
82% {
opacity: 0.9;
}
100% {
left: 130%;
opacity: 0;
}
}
@keyframes header-dot-pulse {
0% {
transform: scale(1);
opacity: 0.55;
}
70%,
100% {
transform: scale(2.8);
opacity: 0;
}
}
.header-meta-item {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
height: 28px;
padding: 0 11px;
border: 1px solid rgba(213, 224, 237, 0.94);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(238, 245, 252, 0.86) 100%);
font-size: 12px;
line-height: 1;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(148, 163, 184, 0.08);
transition:
border-color 0.22s ease,
background 0.22s ease,
box-shadow 0.22s ease,
transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 指标项左侧彩色强调条:随类型变色,悬浮时点亮 */
.header-meta-item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 13px;
border-radius: 0 3px 3px 0;
transform: translateY(-50%) scaleY(0.55);
opacity: 0;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.header-meta-item.is-plannedSiteCount::before {
background: linear-gradient(180deg, #5fb4ec 0%, #3f8fd6 100%);
}
.header-meta-item.is-plannedEnrollmentCount::before {
background: linear-gradient(180deg, #6cc79c 0%, #3fa777 100%);
}
.header-meta-item.is-updatedAt::before {
background: linear-gradient(180deg, #b6a4ec 0%, #8b78d6 100%);
}
.header-meta-item:hover {
border-color: rgba(160, 182, 210, 0.98);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(244, 249, 254, 0.95) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(148, 163, 184, 0.1),
0 5px 16px rgba(38, 63, 102, 0.12);
transform: translateY(-1px);
}
.header-meta-item:hover::before {
opacity: 1;
transform: translateY(-50%) scaleY(1);
}
.header-meta-item.is-updatedAt {
padding: 0 12px;
font-variant-numeric: tabular-nums;
color: #52677f;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(241, 246, 252, 0.9) 100%);
}
.header-meta-item .el-icon {
font-size: 13px;
color: #94a3b8;
}
.header-meta-label {
color: #778aa3;
margin-right: 0;
font-weight: 560;
}
.header-meta-value {
color: #506179;
font-weight: 720;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
transition: color 0.22s ease;
}
.header-meta-item.is-plannedSiteCount:hover .header-meta-value {
color: #2f7fc4;
}
.header-meta-item.is-plannedEnrollmentCount:hover .header-meta-value {
color: #2f8f63;
}
.header-meta-item.is-updatedAt:hover .header-meta-value {
color: #6a5bbf;
}
.header-role {
font-weight: 600;
color: var(--ctms-text-primary);
}
.header-role .el-icon {
color: var(--el-color-primary);
}
.header-account-role {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 9px;
border: 1px solid #d7e6ff;
border-radius: 7px;
background: #eef5ff;
color: #244f86;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.header-account-role .el-icon {
font-size: 14px;
color: #4d76a8;
}
.header-reminder-dropdown {
display: flex;
align-items: center;
}
.header-reminder-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.header-reminder-button:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
color: #334155;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.header-reminder-button .el-icon {
font-size: 17px;
}
.header-reminder-badge {
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-reminder-badge :deep(.el-badge__content) {
height: 16px;
min-width: 16px;
padding: 0 4px;
border: 2px solid #ffffff;
background: #dc2626;
font-size: 10px;
line-height: 12px;
}
.header-reminder-menu {
padding: 0 !important;
}
.header-reminder-panel {
width: 312px;
padding: 10px;
}
.header-reminder-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 4px 4px 10px;
}
.header-reminder-title {
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 20px;
}
.header-reminder-subtitle {
color: #94a3b8;
font-size: 12px;
line-height: 18px;
}
.header-reminder-total {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
font-size: 12px;
font-weight: 700;
}
.header-reminder-item {
width: 100%;
height: auto;
padding: 8px 7px !important;
border-radius: 7px;
}
.header-reminder-item :deep(.el-dropdown-menu__item) {
width: 100%;
}
.header-reminder-item-mark {
width: 7px;
height: 28px;
border-radius: 999px;
flex: 0 0 auto;
}
.header-reminder-item-mark.is-danger {
background: #ef4444;
}
.header-reminder-item-mark.is-warning {
background: #f59e0b;
}
.header-reminder-item-main {
display: inline-flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
margin: 0 10px;
}
.header-reminder-item-title {
color: #1e293b;
font-size: 13px;
font-weight: 650;
line-height: 18px;
}
.header-reminder-item-desc {
overflow: hidden;
color: #7c8da3;
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-reminder-item-count {
color: #0f172a;
font-size: 16px;
font-weight: 750;
}
.header-reminder-empty {
padding: 18px 8px 20px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.header-reminder-footer {
justify-content: center;
color: #2563eb !important;
font-size: 13px;
font-weight: 600;
}
.header-locked {
color: #b45309;
}
.header-locked .el-icon {
color: #d97706;
}
@media (max-width: 1280px) {
.header-metric {
display: none;
}
}
@media (max-width: 1100px) {
.header-center {
display: none;
}
}
.collapse-btn {
border: 1px solid transparent;
color: var(--ctms-text-secondary);
background: transparent;
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
}
.collapse-btn:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
color: var(--ctms-text-main);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.collapse-btn:active {
transform: scale(0.95);
background: rgba(0, 0, 0, 0.04);
}
.collapse-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
}
.dropdown-trigger.compact {
display: flex;
align-items: center;
padding: 2px 8px 2px 3px;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--ctms-text-regular);
transition: all 0.2s ease;
height: 32px;
gap: 6px;
}
.dropdown-trigger.compact:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.user-avatar-comp {
color: #ffffff !important;
background-color: var(--ctms-primary) !important;
border-radius: 6px !important;
font-weight: 700;
flex-shrink: 0;
font-size: 12px;
width: 24px !important;
height: 24px !important;
}
.username {
font-size: 13px;
font-weight: 500;
margin: 0 2px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
}
.layout-main {
padding: 0;
overflow-y: auto;
background: #f7f8fb;
position: relative;
}
.content-wrapper {
padding: 6px 8px;
min-height: calc(100vh - 48px);
min-height: calc(100dvh - 48px);
background: #f7f8fb;
}
.ctms-route-shell {
min-height: 100%;
transform-origin: top center;
}
/* 过渡动画 */
.fade-transform-enter-active,
.fade-transform-leave-active {
transition:
opacity 180ms ease,
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
filter 180ms ease;
}
.fade-transform-enter-from {
opacity: 0;
filter: blur(3px);
transform: translateY(8px) scale(0.995);
}
.fade-transform-leave-to {
opacity: 0;
filter: blur(2px);
transform: translateY(-4px) scale(0.998);
}
@media (prefers-reduced-motion: reduce) {
.layout-aside,
.aside-menu,
.aside-logo,
.logo-text,
.layout-aside :deep(.el-menu-item),
.layout-aside :deep(.el-sub-menu__title),
.layout-aside :deep(.el-menu-item .el-icon),
.layout-aside :deep(.el-sub-menu__title .el-icon),
.layout-aside :deep(.el-sub-menu__icon-arrow),
.fade-transform-enter-active,
.fade-transform-leave-active {
transition-duration: 1ms !important;
}
.layout-aside :deep(.el-menu-item:hover),
.layout-aside :deep(.el-sub-menu__title:hover),
.fade-transform-enter-from,
.fade-transform-leave-to {
filter: none;
transform: none;
}
.header-status-pill,
.header-meta-item {
transition-duration: 1ms !important;
}
.header-status-pill:hover,
.header-meta-item:hover {
transform: none;
}
.header-status-pill.is-active::after,
.header-status-pill.is-active .header-status-dot::before {
animation: none;
}
}
.breadcrumb-dropdown-menu {
min-width: 216px;
padding: 6px;
border: 1px solid rgba(203, 216, 232, 0.92);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 254, 0.98) 100%);
box-shadow:
0 18px 44px rgba(24, 42, 68, 0.16),
0 5px 14px rgba(24, 42, 68, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item) {
position: relative;
min-height: 40px;
margin: 1px 0;
padding: 0 14px;
border-radius: 8px;
color: #3f526a;
font-size: 14px;
font-weight: 560;
letter-spacing: 0;
line-height: 40px;
transition:
background-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease;
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item:hover),
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item:focus) {
background: #f1f6fd;
color: #254f82;
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item.active) {
color: #2f5f86;
background:
linear-gradient(180deg, #eef6ff 0%, #e4f0fb 100%);
font-weight: 760;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(47, 95, 134, 0.07);
}
.breadcrumb-dropdown-menu :deep(.el-dropdown-menu__item.active::before) {
content: "";
position: absolute;
left: 6px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 999px;
background: #2f5f86;
}
:global(.profile-settings-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
}
:global(.profile-settings-dialog .el-dialog__header) {
display: none;
}
:global(.profile-settings-dialog .el-dialog__body) {
padding: 0;
}
:global(.desktop-preferences-dialog) {
border-radius: 14px;
overflow: hidden;
}
:global(.desktop-preferences-dialog .el-dialog__header) {
display: none;
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px;
background: #f8fafc;
}
</style>