Files
ctms/frontend/src/components/WebLayout.vue
T
Cheng Zhou 1d26646a96
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
2026-07-16 14:14:54 +08:00

2258 lines
70 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"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport, 'is-immersive-workspace': route.meta.immersiveWorkspace }"
@keydown.esc="closeMobileNav"
>
<el-aside
v-if="!route.meta.immersiveWorkspace"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
:inert="!isMobileViewport && isCollapsed"
>
<div class="aside-logo">
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<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="showAdminNavigation" 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="canAccessAuditLogs" 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="hasProjectContext && 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', '/knowledge/collaboration'])" 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-menu-item v-if="canAccessProjectPath('/knowledge/collaboration')" index="/knowledge/collaboration">{{ TEXT.menu.knowledgeCollaboration }}</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header v-if="!route.meta.immersiveWorkspace" class="layout-header">
<div class="header-left">
<el-button
class="collapse-btn"
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
@click="toggleCollapse"
:aria-label="isMobileViewport ? '打开导航菜单' : (isCollapsed ? '显示导航栏' : 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.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-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-tooltip v-if="webFullscreenAvailable" :content="webFullscreenLabel" placement="bottom">
<button
class="header-fullscreen-button"
type="button"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
<el-dropdown
trigger="click"
placement="bottom-end"
class="header-reminder-dropdown"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<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"
:class="{ 'is-read': item.isRead }"
>
<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.timeLabel }}</span>
</el-dropdown-item>
</template>
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
</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>
<AccountConnectionStatus mode="indicator" />
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu class="user-dropdown-menu">
<AccountConnectionStatus mode="details" />
<el-dropdown-item command="workbench">
<el-icon><Suitcase /></el-icon>
<span>工作台</span>
</el-dropdown-item>
<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" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<div class="content-wrapper" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<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>
<button
v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"
type="button"
class="mobile-nav-backdrop"
aria-label="关闭导航菜单"
@click="closeMobileNav"
/>
<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="940px"
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 { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor, FullScreen, ScaleToOriginal
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
toggleDesktopFavoriteRoute,
toggleWebFullscreen,
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 AccountConnectionStatus from "./AccountConnectionStatus.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation";
import { useProjectNotifications } from "../composables/useProjectNotifications";
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(typeof localStorage !== "undefined" && localStorage.getItem("ctms_sidebar_collapsed") === "1");
const isMobileViewport = ref(
typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 767px)").matches,
);
const isMobileNavOpen = ref(false);
let mobileViewportMediaQuery: MediaQueryList | undefined;
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
const canAccessAuditLogs = computed(() =>
isAdmin.value || (
projectRole.value === "PM" &&
isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"])
),
);
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 webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let webFullscreenUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
watchEffect(() => {
const width = !hasSidebar.value || isMobileViewport.value || isCollapsed.value ? '0px' : '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 showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.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 (hasProjectContext.value && 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"] },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "collaboration", "onlyoffice", "协作"] },
];
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
}
return items;
});
const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return;
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" || command === "ctms.desktop.help") {
openCommandPalette();
return;
}
if (command === "ctms.desktop.preferences") {
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.serverSettings") {
openDesktopPreferences();
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);
},
}));
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: openDesktopPreferences,
},
{
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,
];
});
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 (hasProjectContext.value && study.currentStudy) {
// 项目内不提供横向切换项目;需要切换时先回工作台重新选择。
items.push({
label: (study.currentStudy.name || "").replace(/^示例项目[:]/, ''),
path: "/project/overview",
});
// 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" },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration" },
]},
];
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);
}
};
const handleMenuSelect = (index: string) => {
closeMobileNav();
if (!index || index === route.path) {
return;
}
router.push(index);
};
watch(() => route.path, () => {
closeMobileNav();
study.setViewContext(null);
if (isDesktop) {
const current = currentDesktopRoutePreference.value;
if (current) {
refreshDesktopRoutePreferences();
}
}
});
watch(
() => [study.currentStudy?.id, route.path] as const,
() => {
loadHeaderReminders();
refreshDesktopRoutePreferences();
},
);
watch(
() => desktopNavigationItems.value.map((item) => item.path).join("|"),
() => refreshDesktopRoutePreferences(),
);
const closeMobileNav = () => {
isMobileNavOpen.value = false;
};
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
webFullscreenActive.value = isWebFullscreenActive();
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
syncWebFullscreenState();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
syncWebFullscreenState();
}
};
const syncMobileViewport = (matches: boolean) => {
isMobileViewport.value = matches;
if (!matches) closeMobileNav();
};
const toggleCollapse = () => {
if (isMobileViewport.value) {
isMobileNavOpen.value = !isMobileNavOpen.value;
return;
}
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 openWorkbenchEntry = async () => {
study.clearCurrentStudy();
await router.push("/workbench");
};
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 () => {
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
if (typeof window.matchMedia === "function") {
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
syncMobileViewport(mobileViewportMediaQuery.matches);
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
}
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
clearLegacyDesktopRouteHistoryPreference();
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
}
if (auth.token && !auth.user) {
try {
await auth.fetchMe();
} catch {
await logoutImmediately();
return;
}
}
loadStudies();
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
webFullscreenUnlisten?.();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
}
desktopMenuUnlisten?.();
stopHeaderNotificationPolling();
});
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "workbench") {
void openWorkbenchEntry();
} 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 .layout-aside.collapsed {
border-right-width: 0;
}
.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 {
height: 100%;
min-height: 0;
padding: 0;
}
.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);
object-fit: contain;
}
.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 {
height: 100%;
min-height: 0;
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-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-fullscreen-button,
.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-fullscreen-button:hover,
.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-fullscreen-button .el-icon,
.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;
}
:global(.header-reminder-item.is-read) { opacity: 0.62 !important; }
.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-mark.is-info { background: var(--ctms-primary); }
.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: #7c8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.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;
}
.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 {
min-height: 0;
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;
}
.layout-main.is-full-bleed {
overflow: hidden;
}
.content-wrapper.is-full-bleed {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
.content-wrapper.is-full-bleed .ctms-route-shell {
height: 100%;
min-height: 0;
}
.ctms-route-shell {
width: 100%;
height: 100%;
min-height: 0;
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);
}
.mobile-nav-backdrop {
display: none;
}
@media (max-width: 767px) {
.web-layout-container {
position: relative;
width: 100%;
min-width: 0;
overflow: hidden;
}
.web-layout-container .layout-aside {
position: fixed;
z-index: 100;
top: 0;
bottom: 0;
left: 0;
width: 280px !important;
max-width: calc(100vw - 42px);
transform: translateX(-105%);
box-shadow: 14px 0 36px rgba(15, 23, 42, 0.24);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.web-layout-container.mobile-nav-open .layout-aside {
transform: translateX(0);
}
.web-layout-container .main-container {
width: 100%;
min-width: 0;
}
.web-layout-container .layout-header {
position: sticky;
top: 0;
z-index: 30;
width: 100%;
padding: 0 10px;
}
.web-layout-container .content-wrapper {
height: auto;
min-height: calc(100dvh - 52px);
padding: 10px;
}
.header-left {
flex: 1 1 auto;
min-width: 0;
}
.header-breadcrumb {
max-width: none;
margin-left: 4px;
}
.header-breadcrumb > :not(:last-child) {
display: none;
}
.header-breadcrumb .breadcrumb-item-wrapper {
max-width: min(48vw, 210px);
}
.header-right {
gap: 3px;
}
.header-account-role,
.user-profile .username,
.user-profile .el-icon,
.user-profile :deep(.account-connection-status) {
display: none;
}
.dropdown-trigger.compact {
padding-right: 3px;
}
.mobile-nav-backdrop {
position: fixed;
z-index: 90;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: rgba(15, 23, 42, 0.38);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
}
@media (max-width: 420px) {
.web-layout-container .layout-header {
padding: 0 6px;
}
.web-layout-container .content-wrapper {
padding: 8px;
}
.header-breadcrumb .breadcrumb-item-wrapper {
max-width: 42vw;
}
.header-fullscreen-button,
.header-reminder-button,
.collapse-btn,
.dropdown-trigger.compact {
width: 30px;
height: 30px;
}
}
/* 大屏网页端:增加呼吸感,但保留业务表格的全宽工作区。 */
@media (min-width: 1440px) {
.web-layout-container .layout-header {
height: 56px !important;
padding-right: 20px;
padding-left: 20px;
}
.web-layout-container .aside-logo {
height: 64px;
}
.web-layout-container .content-wrapper {
padding: 0;
}
}
@media (min-width: 1920px) {
.web-layout-container .layout-header {
padding-right: 28px;
padding-left: 28px;
}
}
@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;
}
}
.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) {
/* 移除外层卡片外壳,只保留容器行为 */
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
}
:global(.profile-settings-dialog .el-dialog__header) {
display: none;
}
:global(.profile-settings-dialog .el-dialog__body) {
padding: 0;
background: transparent;
}
:global(.desktop-preferences-dialog) {
/* 移除外层卡片外壳,内容区自带圆角阴影 */
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
}
:global(.desktop-preferences-dialog .el-dialog__header) {
display: none;
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 0;
background: transparent;
}
</style>