Files
ctms/frontend/src/components/DesktopLayout.vue
T
2026-07-09 13:53:01 +08:00

2644 lines
74 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>
<div class="desktop-workbench" @click="closeWorkspaceTabMenu">
<aside class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
<div class="sidebar-app-brand">
<img class="sidebar-app-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<h1>{{ TEXT.common.appName }}</h1>
</div>
<button v-if="showSidebarProjectChooser" class="study-switcher-trigger empty" type="button" @click="openDesktopProjectEntry">
选择项目
</button>
</div>
</header>
<div class="sidebar-scroll">
<section v-if="desktopFavoriteRoutes.length" class="nav-section">
<div class="section-label">收藏</div>
<button
v-for="item in desktopFavoriteRoutes"
:key="`favorite:${item.path}`"
type="button"
class="sidebar-link compact"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><StarFilled /></el-icon>
<span>{{ item.title }}</span>
</button>
</section>
<section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ DESKTOP_ADMIN_SECTION_LABEL }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
<button
v-if="!item.children?.length"
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button
type="button"
class="sidebar-link group-title"
:class="{ active: isNavigationGroupActive(item) }"
:aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button>
<div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</template>
</section>
<section v-if="projectNavigationItems.length" class="nav-section">
<div class="section-label">{{ desktopProjectSectionLabel }}</div>
<template v-for="item in projectNavigationItems" :key="item.path">
<button
v-if="!item.children?.length"
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button
type="button"
class="sidebar-link group-title"
:class="{ active: isNavigationGroupActive(item) }"
:aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button>
<div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</template>
</section>
</div>
</aside>
<section class="desktop-main">
<header class="desktop-toolbar">
<div class="toolbar-left" data-tauri-drag-region>
<div class="toolbar-context" data-tauri-drag-region>
<div class="toolbar-title" data-tauri-drag-region>{{ desktopToolbarTitle }}</div>
<div class="toolbar-subtitle" data-tauri-drag-region>{{ desktopToolbarSubtitle }}</div>
</div>
</div>
<div class="toolbar-right">
<button v-if="desktopUpdateNoticeVisible" class="update-notice" type="button" title="打开系统偏好安装更新" @click="openDesktopPreferences">
<el-icon><Download /></el-icon>
<span>{{ desktopUpdateNoticeLabel }}</span>
</button>
<el-popover placement="bottom-end" width="340" trigger="click" popper-class="desktop-activity-popover">
<template #reference>
<button class="icon-button activity-button" type="button" title="活动">
<el-badge v-if="desktopActivityBadgeCount > 0" :value="desktopActivityBadgeValue" class="activity-badge">
<el-icon><Clock /></el-icon>
</el-badge>
<el-icon v-else><Clock /></el-icon>
</button>
</template>
<div class="activity-panel">
<div class="activity-head">
<div>
<div class="panel-title">活动</div>
<div class="panel-subtitle">上传下载导出与更新</div>
</div>
<button
v-if="hasFinishedDesktopActivities"
class="activity-clear"
type="button"
@click="clearFinishedDesktopActivities"
>
清除已结束
</button>
</div>
<div v-if="desktopActivities.length" class="activity-list">
<div v-for="item in desktopActivities" :key="item.id" class="activity-item" :class="`is-${item.status}`">
<span class="activity-mark" :class="`is-${item.status}`"></span>
<span class="activity-body">
<span class="activity-title">{{ item.title }}</span>
<span class="activity-detail">{{ item.detail || desktopActivityStatusLabel(item.status) }}</span>
</span>
<span class="activity-meta">
<span v-if="item.status === 'running' && item.progress !== null">{{ item.progress }}%</span>
<span v-else>{{ desktopActivityStatusLabel(item.status) }}</span>
<small>{{ formatDesktopActivityTime(item.updatedAt) }}</small>
</span>
</div>
</div>
<div v-else class="activity-empty">暂无活动</div>
</div>
</el-popover>
<button class="command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>命令</span>
<kbd>K</kbd>
</button>
<button class="icon-button" type="button" :title="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" @click="toggleCurrentFavorite">
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
</button>
<el-popover placement="bottom-end" width="320" trigger="click" popper-class="desktop-connection-popover">
<template #reference>
<button class="connection-button" type="button">
<span class="connection-dot" :class="desktopConnectionClass"></span>
<span>{{ desktopConnectionLabel }}</span>
</button>
</template>
<div class="connection-panel">
<div class="panel-title">在线桌面客户端</div>
<div class="panel-row">
<span>服务器</span>
<code>{{ desktopServerUrl || "未配置" }}</code>
</div>
<div class="panel-row">
<span>客户端</span>
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
</div>
<el-button size="small" @click="openDesktopPreferences">系统偏好</el-button>
</div>
</el-popover>
<el-dropdown trigger="click" placement="bottom-end" @command="handleReminderCommand">
<button class="icon-button reminder-button" type="button" title="项目提醒">
<el-badge v-if="headerReminderTotal > 0" :value="headerReminderBadgeValue" class="reminder-badge">
<el-icon><Bell /></el-icon>
</el-badge>
<el-icon v-else><Bell /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="reminder-menu">
<div class="reminder-panel">
<div class="reminder-head">
<div>
<div class="panel-title">{{ TEXT.common.labels.projectReminders }}</div>
<div class="panel-subtitle">{{ TEXT.common.labels.projectRemindersSubtitle }}</div>
</div>
<span v-if="headerReminderTotal > 0" class="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="reminder-item"
>
<span class="reminder-mark" :class="`is-${item.tone}`"></span>
<span class="reminder-body">
<span>{{ item.title }}</span>
<small>{{ item.description }}</small>
</span>
<strong>{{ item.count }}</strong>
</el-dropdown-item>
</template>
<div v-else class="reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" placement="bottom-end" @command="onCommand">
<button class="account-button" type="button">
<span class="account-avatar">{{ userDisplayInitial }}</span>
<span class="account-name">{{ userDisplayName }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>{{ TEXT.menu.profile }}</span>
</el-dropdown-item>
<el-dropdown-item command="projectEntry">
<el-icon><Suitcase /></el-icon>
<span>{{ projectEntryMenuLabel }}</span>
</el-dropdown-item>
<el-dropdown-item 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>
</header>
<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">
<div
class="workspace-tabs-list"
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
>
<div
v-for="item in visibleWorkspaceTabs"
:key="item.path"
class="workspace-tab"
:class="{ active: activeMenu === item.path, dragging: draggingWorkspaceTabPath === item.path }"
draggable="true"
@dragstart="startWorkspaceTabDrag(item.path, $event)"
@dragover="allowWorkspaceTabDrop"
@drop.prevent="dropWorkspaceTab(item.path, $event)"
@dragend="draggingWorkspaceTabPath = ''"
@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"
>
<button
class="workspace-tab-action"
type="button"
:title="item.title"
@click="navigateWorkspaceTab(item.path)"
>
<span>{{ item.title }}</span>
</button>
<button class="tab-close" type="button" title="关闭标签" @click.stop="closeWorkspaceTab(item.path)">
×
</button>
</div>
</div>
<el-dropdown
v-if="overflowWorkspaceTabs.length"
class="workspace-tabs-overflow"
popper-class="workspace-tabs-overflow-popper"
trigger="click"
placement="bottom-end"
@command="navigateOverflowWorkspaceTab"
@visible-change="workspaceTabsOverflowOpen = $event"
>
<button
class="workspace-tabs-overflow-trigger"
:class="{ 'is-open': workspaceTabsOverflowOpen }"
type="button"
:aria-label="`查看其余 ${overflowWorkspaceTabs.length} 个标签`"
>
<span>更多</span>
<span class="workspace-tabs-overflow-count">{{ overflowWorkspaceTabs.length }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
<template #dropdown>
<div class="workspace-tabs-overflow-menu">
<div class="workspace-tabs-overflow-header">
<span>其他标签</span>
<span>{{ overflowWorkspaceTabs.length }} </span>
</div>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in overflowWorkspaceTabs"
:key="item.path"
:command="item.path"
:class="{ 'is-current': activeMenu === item.path }"
>
<span class="workspace-tabs-overflow-item-mark" aria-hidden="true"></span>
<span class="workspace-tabs-overflow-label">
<span :title="item.title">{{ item.title }}</span>
</span>
<button
class="workspace-tabs-overflow-close"
type="button"
:aria-label="`关闭标签:${item.title}`"
title="关闭标签"
@click.stop="closeWorkspaceTab(item.path)"
>
×
</button>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
</div>
<main class="desktop-content">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="desktop-route" mode="out-in">
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
<component
:is="Component"
v-if="Component"
:key="desktopRouteCacheKey(currentRoute)"
class="desktop-route-shell"
/>
</KeepAlive>
</transition>
</router-view>
</main>
</section>
</div>
<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-model="commandPaletteVisible" :commands="desktopCommands" />
<el-dialog
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>
<div
v-if="workspaceTabMenu.visible && workspaceTabMenu.tab"
class="workspace-tab-context-menu"
:style="{ left: `${workspaceTabMenu.x}px`, top: `${workspaceTabMenu.y}px` }"
@click.stop
>
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
<button type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
</button>
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
<button type="button" :disabled="workspaceTabs.length <= 1" @click="closeOtherWorkspaceTabs(workspaceTabMenu.tab.path)">
关闭其他标签
</button>
<button type="button" :disabled="!lastClosedWorkspaceTab" @click="restoreLastClosedWorkspaceTab">
重新打开最近关闭
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
Bell,
Box,
Calendar,
CircleCheck,
Clock,
Coin,
DataAnalysis,
Document,
Download,
Files,
Flag,
Folder,
House,
Key,
Monitor,
Notebook,
Refresh,
Search,
Setting,
Star,
StarFilled,
Suitcase,
SwitchButton,
User,
UserFilled,
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
import {
checkDesktopUpdateAndPrompt,
getDesktopUpdateStatus,
listenDesktopUpdateStatus,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import {
clearFinishedDesktopActivities,
getDesktopActivities,
listenDesktopActivities,
type DesktopActivityItem,
type DesktopActivityStatus,
} from "../session/desktopActivityCenter";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
DESKTOP_SHORTCUTS_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
formatDesktopShortcut,
getAppMetadata,
getDesktopServerUrl,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopShortcutPreferences,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
type DesktopShortcutPreferences,
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import type { DesktopCommand } from "../types/desktopCommands";
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import {
buildAdminNavigationItems,
buildProjectNavigationItems,
flattenLayoutNavigationItems,
getActiveLayoutPath,
type LayoutNavigationIcon,
type LayoutNavigationItem,
} from "./layout/navigation";
import {
createWorkspaceTabHistory,
currentWorkspaceTabPath,
moveWorkspaceTabHistory,
recordWorkspaceTabPath,
replaceWorkspaceTabPath,
type WorkspaceTabHistory,
} from "./layout/workspaceTabHistory";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const route = useRoute();
const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
const DESKTOP_WORKSPACE_TAB_MIN_WIDTH = 130;
const DESKTOP_WORKSPACE_TAB_GAP = 6;
const DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING = 20;
const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin);
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 hasAnyProjectModuleAccess = computed(() => projectRouteLandingPaths.some((path) => canAccessProjectPath(path)));
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const showSidebarProjectChooser = computed(() => !study.currentStudy && !isAdminContext.value);
const loggingOut = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const desktopShortcutPreferences = ref<DesktopShortcutPreferences>(readDesktopShortcutPreferences());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
const workspaceTabsElement = ref<HTMLElement | null>(null);
const workspaceTabsAvailableWidth = ref(0);
const workspaceTabsOverflowOpen = ref(false);
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
const workspaceTabMenu = ref<{
visible: boolean;
x: number;
y: number;
tab: DesktopRoutePreference | null;
}>({
visible: false,
x: 0,
y: 0,
tab: null,
});
const expandedNavigationGroups = ref<Set<string>>(new Set());
const collapsedNavigationGroups = ref<Set<string>>(new Set());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
let workspaceTabsResizeObserver: ResizeObserver | undefined;
const iconComponentMap = {
audit: Document,
calendar: Calendar,
check: CircleCheck,
coin: Coin,
dashboard: House,
document: Files,
flag: Flag,
folder: Box,
key: Key,
monitor: DataAnalysis,
notebook: Notebook,
project: Suitcase,
settings: Setting,
subject: User,
users: UserFilled,
};
const iconFor = (icon: LayoutNavigationIcon) => iconComponentMap[icon] || Folder;
const activeMenu = computed(() => getActiveLayoutPath(route.path));
const navigationGroupKey = (item: LayoutNavigationItem) => `${item.group}:${item.label}:${item.path}`;
const isNavigationGroupActive = (item: LayoutNavigationItem) =>
Boolean(item.children?.some((child) => activeMenu.value === child.path));
const isNavigationGroupExpanded = (item: LayoutNavigationItem) => {
const key = navigationGroupKey(item);
return expandedNavigationGroups.value.has(key) || (isNavigationGroupActive(item) && !collapsedNavigationGroups.value.has(key));
};
const toggleNavigationGroup = (item: LayoutNavigationItem) => {
const key = navigationGroupKey(item);
const nextExpanded = new Set(expandedNavigationGroups.value);
const nextCollapsed = new Set(collapsedNavigationGroups.value);
if (isNavigationGroupExpanded(item)) {
nextExpanded.delete(key);
nextCollapsed.add(key);
} else {
nextExpanded.add(key);
nextCollapsed.delete(key);
}
expandedNavigationGroups.value = nextExpanded;
collapsedNavigationGroups.value = nextCollapsed;
};
const adminNavigationItems = computed(() =>
study.currentStudy
? []
: buildAdminNavigationItems({
hasUser: Boolean(auth.user),
isAdmin: isAdmin.value,
canAccessAdminPermissions: canAccessAdminPermissions.value,
}),
);
const projectNavigationItems = computed(() =>
buildProjectNavigationItems({
hasCurrentStudy: Boolean(study.currentStudy),
hasAnyProjectModuleAccess: hasAnyProjectModuleAccess.value,
canAccessProjectPath,
}),
);
const adminDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(adminNavigationItems.value));
const projectDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(projectNavigationItems.value));
const desktopNavigationItems = computed(() =>
[...adminDesktopNavigationItems.value, ...projectDesktopNavigationItems.value],
);
const workspaceTabFallbackPath = computed(() => {
if (isAdminContext.value) return adminDesktopNavigationItems.value[0]?.path || "/admin/projects";
return study.currentStudy ? "/project/overview" : "/desktop/project-entry";
});
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 desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject);
const projectEntryMenuLabel = computed(() => (study.currentStudy ? "返回项目选择" : "选择项目"));
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
const desktopUpdateNoticeVisible = computed(
() =>
Boolean(desktopUpdateStatus.value.pendingUpdate) &&
!desktopUpdateStatus.value.checking &&
!desktopUpdateStatus.value.installing &&
!["postponed", "suppressed"].includes(desktopUpdateStatus.value.lastStatus),
);
const desktopUpdateNoticeLabel = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
return pending ? `新版本 ${pending.version}` : "";
});
const desktopActivityBadgeCount = computed(() =>
desktopActivities.value.filter((item) => item.status === "running" || item.status === "failed").length
);
const desktopActivityBadgeValue = computed(() => (desktopActivityBadgeCount.value > 9 ? "9+" : desktopActivityBadgeCount.value));
const hasFinishedDesktopActivities = computed(() =>
desktopActivities.value.some((item) => item.status === "completed" || item.status === "cancelled" || item.status === "failed")
);
const desktopActivityStatusLabel = (status: DesktopActivityStatus) => {
switch (status) {
case "running":
return "进行中";
case "completed":
return "完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "";
}
};
const formatDesktopActivityTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
if (!item) return null;
return { path: item.path, title: item.label, group: item.group };
});
const visibleWorkspaceTabCapacity = computed(() => {
const tabCount = workspaceTabs.value.length;
const availableWidth = workspaceTabsAvailableWidth.value;
if (!tabCount || availableWidth <= 0) return tabCount;
const maxWithoutOverflow = Math.max(
1,
Math.floor((availableWidth + DESKTOP_WORKSPACE_TAB_GAP) / (DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP)),
);
if (tabCount <= maxWithoutOverflow) return tabCount;
return Math.max(
1,
Math.floor(
(availableWidth - DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH) /
(DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP),
),
);
});
const visibleWorkspaceTabs = computed(() => {
const capacity = visibleWorkspaceTabCapacity.value;
if (workspaceTabs.value.length <= capacity) return workspaceTabs.value;
const firstTabs = workspaceTabs.value.slice(0, capacity);
const activeTab = workspaceTabs.value.find((item) => item.path === activeMenu.value);
if (!activeTab || firstTabs.some((item) => item.path === activeTab.path)) return firstTabs;
return [...firstTabs.slice(0, -1), activeTab];
});
const overflowWorkspaceTabs = computed(() => {
const visiblePaths = new Set(visibleWorkspaceTabs.value.map((item) => item.path));
return workspaceTabs.value.filter((item) => !visiblePaths.has(item.path));
});
const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
});
const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value;
return current?.title || String(route.meta?.title || TEXT.common.appName);
});
const desktopToolbarSubtitle = computed(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return TEXT.common.appName;
const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isAdminRoute) {
const section = current.group && current.group !== TEXT.menu.admin ? current.group : "";
return [DESKTOP_ADMIN_SECTION_LABEL, section].filter(Boolean).join(" / ");
}
const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isProjectRoute) {
const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : "";
const projectName = study.currentStudy?.name || TEXT.menu.currentProject;
return [projectName, section].filter(Boolean).join(" / ");
}
return current.group || TEXT.common.appName;
});
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 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 refreshDesktopRoutePreferences = () => {
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(item.path));
};
const addWorkspaceTab = (routePreference: Pick<DesktopRoutePreference, "path" | "title" | "group">) => {
const item = { ...routePreference, updatedAt: new Date().toISOString() };
const existingIndex = workspaceTabs.value.findIndex((tab) => tab.path === item.path);
if (existingIndex >= 0) {
const next = [...workspaceTabs.value];
next[existingIndex] = { ...next[existingIndex], ...item };
workspaceTabs.value = next;
return;
}
workspaceTabs.value = [...workspaceTabs.value, item];
};
const setWorkspaceTabHistory = (tabPath: string, history: WorkspaceTabHistory) => {
workspaceTabHistories.value = { ...workspaceTabHistories.value, [tabPath]: history };
};
const removeWorkspaceTabHistory = (tabPath: string) => {
if (!workspaceTabHistories.value[tabPath]) return;
const next = { ...workspaceTabHistories.value };
delete next[tabPath];
workspaceTabHistories.value = next;
};
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
const replaceWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, replaceWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
const workspaceTabDestination = (tabPath: string) =>
currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath);
const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;
const navigateWorkspaceTab = (path: string) => {
if (activeMenu.value === path) return;
void router.push(workspaceTabDestination(path));
};
const navigateCurrentWorkspaceTabHistory = (offset: -1 | 1) => {
const tabPath = activeMenu.value;
const movement = moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset);
if (!movement) return;
setWorkspaceTabHistory(tabPath, movement.history);
void router.push(movement.path);
};
const navigateOverflowWorkspaceTab = (path: string) => {
navigateWorkspaceTab(path);
};
const updateWorkspaceTabsAvailableWidth = () => {
const element = workspaceTabsElement.value;
workspaceTabsAvailableWidth.value = element
? Math.max(0, element.clientWidth - DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING)
: 0;
};
const observeWorkspaceTabsElement = (element: HTMLElement | null) => {
workspaceTabsResizeObserver?.disconnect();
if (!element) {
workspaceTabsAvailableWidth.value = 0;
return;
}
updateWorkspaceTabsAvailableWidth();
if (typeof ResizeObserver === "undefined") return;
workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);
workspaceTabsResizeObserver.observe(element);
};
const closeWorkspaceTabMenu = () => {
if (!workspaceTabMenu.value.visible) return;
workspaceTabMenu.value = { visible: false, x: 0, y: 0, tab: null };
};
const openWorkspaceTabMenu = (tab: DesktopRoutePreference, event: MouseEvent) => {
workspaceTabMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 196),
y: Math.min(event.clientY, window.innerHeight - 184),
tab,
};
};
const navigateWorkspaceTabFromMenu = (path: string) => {
navigateWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
refreshDesktopRoutePreferences();
closeWorkspaceTabMenu();
};
const startWorkspaceTabDrag = (path: string, event: DragEvent) => {
draggingWorkspaceTabPath.value = path;
event.dataTransfer?.setData("text/plain", path);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
};
const allowWorkspaceTabDrop = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
};
const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
const sourcePath = draggingWorkspaceTabPath.value || event.dataTransfer?.getData("text/plain") || "";
draggingWorkspaceTabPath.value = "";
if (!sourcePath || sourcePath === targetPath) return;
const sourceIndex = workspaceTabs.value.findIndex((tab) => tab.path === sourcePath);
const targetIndex = workspaceTabs.value.findIndex((tab) => tab.path === targetPath);
if (sourceIndex < 0 || targetIndex < 0) return;
const next = [...workspaceTabs.value];
const [moved] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, moved);
workspaceTabs.value = next;
};
const closeWorkspaceTab = (path: string) => {
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
if (index >= 0) {
lastClosedWorkspaceTab.value = workspaceTabs.value[index];
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path);
}
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
removeWorkspaceTabHistory(path);
if (activeMenu.value !== path) return;
const next =
workspaceTabs.value[index] ||
workspaceTabs.value[index - 1] ||
desktopFavoriteRoutes.value.find((item) => item.path !== path);
if (next) {
router.push(workspaceTabDestination(next.path));
} else {
router.push(workspaceTabFallbackPath.value);
}
};
const closeWorkspaceTabFromMenu = (path: string) => {
closeWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const closeOtherWorkspaceTabs = (path: string) => {
const target = workspaceTabs.value.find((tab) => tab.path === path);
if (!target) return;
workspaceTabs.value = [target];
const targetHistory = workspaceTabHistories.value[path];
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
closeWorkspaceTabMenu();
};
const restoreLastClosedWorkspaceTab = () => {
const tab = lastClosedWorkspaceTab.value;
if (!tab) return;
addWorkspaceTab(tab);
if (lastClosedWorkspaceTabHistory.value) {
setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value);
}
void router.push(workspaceTabDestination(tab.path));
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
closeWorkspaceTabMenu();
};
const updateDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
};
const updateDesktopShortcutPreferences = (event?: Event) => {
desktopShortcutPreferences.value =
(event as CustomEvent<DesktopShortcutPreferences> | undefined)?.detail || readDesktopShortcutPreferences();
};
const openCommandPalette = () => {
commandPaletteVisible.value = true;
};
const openDesktopPreferences = () => {
desktopPreferencesVisible.value = true;
};
const openDesktopProjectEntry = async () => {
study.clearCurrentStudy();
workspaceTabs.value = [];
workspaceTabHistories.value = {};
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
await router.push("/desktop/project-entry");
};
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") {
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.refresh") {
refreshCurrentDesktopView();
return;
}
if (command === "ctms.desktop.back") {
navigateCurrentWorkspaceTabHistory(-1);
return;
}
if (command === "ctms.desktop.forward") {
navigateCurrentWorkspaceTabHistory(1);
}
};
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 workspaceTabCommands: DesktopCommand[] = workspaceTabs.value.map((item) => ({
id: `workspace-tab:${item.path}`,
title: `切换标签:${item.title}`,
group: "标签页",
keywords: [item.title, item.group, item.path].filter((value): value is string => Boolean(value)),
visible: true,
run: () => navigateWorkspaceTab(item.path),
}));
return [
{
id: "desktop:tab-back",
title: "当前标签后退",
group: "标签页",
shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.back),
visible: true,
run: () => navigateCurrentWorkspaceTabHistory(-1),
},
{
id: "desktop:tab-forward",
title: "当前标签前进",
group: "标签页",
shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.forward),
visible: true,
run: () => navigateCurrentWorkspaceTabHistory(1),
},
{
id: "desktop:refresh",
title: "刷新当前视图",
group: "桌面",
shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh),
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:project-entry",
title: projectEntryMenuLabel.value,
group: "桌面",
visible: true,
run: openDesktopProjectEntry,
},
{
id: "desktop:favorite-current",
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
group: "桌面",
visible: Boolean(currentDesktopRoutePreference.value),
run: toggleCurrentFavorite,
},
{
id: "desktop:restore-closed-tab",
title: "重新打开最近关闭标签",
group: "标签页",
visible: Boolean(lastClosedWorkspaceTab.value),
run: restoreLastClosedWorkspaceTab,
},
...workspaceTabCommands,
...navigationCommands,
];
});
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 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");
}
};
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);
}
};
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "profile") {
profileDialogVisible.value = true;
} else if (cmd === "projectEntry") {
openDesktopProjectEntry();
} 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();
}
};
watch(
() => route.fullPath,
(fullPath, previousFullPath) => {
study.setViewContext(null);
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
const previousTabPath = previousFullPath ? getActiveLayoutPath(router.resolve(previousFullPath).path) : "";
const replacesCurrentTabEntry =
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
if (replacesCurrentTabEntry) {
replaceWorkspaceTabRoute(current.path, fullPath);
} else {
recordWorkspaceTabRoute(current.path, fullPath);
}
refreshDesktopRoutePreferences();
}
},
);
watch(
() => [study.currentStudy?.id, route.path] as const,
() => {
refreshDesktopRoutePreferences();
loadHeaderReminders();
},
);
watch(
() => desktopNavigationItems.value.map((item) => item.path).join("|"),
() => refreshDesktopRoutePreferences(),
);
watch(workspaceTabsElement, (element) => observeWorkspaceTabsElement(element), { flush: "post" });
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
window.addEventListener("resize", updateWorkspaceTabsAvailableWidth);
clearLegacyDesktopRouteHistoryPreference();
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
recordWorkspaceTabRoute(current.path, route.fullPath);
}
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
desktopActivityUnlisten = listenDesktopActivities((items) => {
desktopActivities.value = items;
});
if (auth.token && !auth.user) {
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch (error) {
if (shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
await router.replace({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: route.fullPath } });
return;
}
await logoutImmediately();
return;
}
}
loadHeaderReminders();
});
onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
window.removeEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
window.removeEventListener("resize", updateWorkspaceTabsAvailableWidth);
workspaceTabsResizeObserver?.disconnect();
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
});
useDesktopShortcuts(
() => true,
{
openCommandPalette,
closeActiveLayer: () => {
if (workspaceTabMenu.value.visible) {
closeWorkspaceTabMenu();
return true;
}
if (commandPaletteVisible.value) {
commandPaletteVisible.value = false;
return true;
}
if (desktopPreferencesVisible.value) {
desktopPreferencesVisible.value = false;
return true;
}
return false;
},
navigateBack: () => navigateCurrentWorkspaceTabHistory(-1),
navigateForward: () => navigateCurrentWorkspaceTabHistory(1),
refreshCurrentView: refreshCurrentDesktopView,
},
);
</script>
<style scoped>
.desktop-workbench {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
height: 100vh;
height: 100dvh;
min-height: 0;
min-width: 1180px;
overflow: hidden;
background: #eef2f6;
color: #142033;
}
.icon-button {
appearance: none;
border: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button:active {
transform: scale(0.96);
}
.desktop-sidebar {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
flex-direction: column;
background: #f8fafc;
border-right: 1px solid #d7e0ea;
}
.sidebar-head {
flex: 0 0 auto;
padding: 44px 10px 10px;
border-bottom: 1px solid #dfe7f0;
}
.sidebar-title-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.sidebar-head h1 {
margin: 0;
overflow: hidden;
color: #101827;
font-size: 16px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-app-brand {
display: inline-grid;
min-width: 0;
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 9px;
}
.sidebar-app-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.14);
object-fit: contain;
}
.icon-button {
width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #5d6f85;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
.icon-button:hover {
border-color: #c8d4e1;
background: #ffffff;
color: #172233;
}
.study-switcher-trigger {
appearance: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
margin-top: 0;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 6px;
background: #ffffff;
color: #26384f;
cursor: pointer;
text-align: left;
}
.study-switcher-trigger.empty {
display: inline-flex;
justify-content: center;
justify-self: start;
width: 104px;
color: #3f5d75;
font-weight: 700;
}
.sidebar-scroll {
flex: 1;
min-height: 0;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 10px 8px 24px;
scrollbar-gutter: stable;
}
.sidebar-scroll::-webkit-scrollbar {
width: 10px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
border: 3px solid #f8fafc;
border-radius: 999px;
background: #c5d1df;
}
.sidebar-scroll::-webkit-scrollbar-thumb:hover {
background: #aebdcd;
}
.nav-section + .nav-section {
margin-top: 12px;
}
.section-label {
padding: 0 6px 6px;
color: #7b8da3;
font-size: 11px;
font-weight: 800;
}
.sidebar-link {
appearance: none;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
margin: 1px 0;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #3d5068;
cursor: pointer;
font: inherit;
font-size: 14px;
line-height: 1.3;
text-align: left;
}
.sidebar-link.compact {
grid-template-columns: 16px minmax(0, 1fr);
}
.sidebar-link.child {
grid-template-columns: minmax(0, 1fr);
min-height: 28px;
padding-left: 32px;
color: #607289;
font-size: 12px;
}
.sidebar-link :deep(.el-icon) {
font-size: 16px;
}
.sidebar-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link:hover {
background: #edf3f8;
color: #142033;
}
.sidebar-link.active {
background: #e4edf6;
color: #183756;
font-weight: 760;
}
.sidebar-group {
margin: 1px 0 4px;
}
.group-title {
grid-template-columns: 16px minmax(0, 1fr) 14px;
font-weight: 700;
}
.group-expander {
justify-self: end;
color: #7a8ca2;
font-size: 12px;
transition: transform 0.16s ease;
}
.sidebar-group.expanded .group-expander {
transform: rotate(180deg);
}
.sidebar-children {
padding: 2px 0 4px;
}
.desktop-main {
display: grid;
grid-template-rows: 52px auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.desktop-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 0 12px;
border-bottom: 1px solid #d7e0ea;
background: rgba(248, 250, 252, 0.92);
backdrop-filter: blur(14px);
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
min-width: 0;
}
.toolbar-left {
align-self: stretch;
cursor: default;
user-select: none;
}
.toolbar-right {
justify-content: flex-end;
gap: 8px;
}
.toolbar-right,
.toolbar-right :deep(*),
.desktop-sidebar button,
.desktop-sidebar :deep(.el-dropdown),
.workspace-tabs,
.workspace-tabs *,
.workspace-tab-context-menu,
.workspace-tab-context-menu * {
-webkit-app-region: no-drag;
}
.toolbar-left[data-tauri-drag-region],
.toolbar-context[data-tauri-drag-region],
.toolbar-title[data-tauri-drag-region],
.toolbar-subtitle[data-tauri-drag-region] {
-webkit-app-region: drag;
}
.toolbar-context {
display: grid;
min-width: 0;
align-content: center;
gap: 2px;
width: 100%;
height: 100%;
overflow: hidden;
}
.toolbar-title,
.toolbar-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-title {
color: #142033;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
}
.toolbar-subtitle {
color: #66778d;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.command-trigger,
.update-notice,
.connection-button,
.account-button {
appearance: none;
display: inline-flex;
align-items: center;
height: 32px;
border: 1px solid #d2dce8;
border-radius: 8px;
background: #ffffff;
color: #33475f;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
}
.update-notice {
gap: 6px;
max-width: 138px;
padding: 0 9px;
border-color: #d8bd6c;
background: #fff8e5;
color: #6f5317;
}
.update-notice span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-trigger {
gap: 7px;
padding: 0 8px;
}
.command-trigger kbd {
padding: 1px 5px;
border: 1px solid #c7d2df;
border-radius: 5px;
background: #f6f8fb;
color: #66778d;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.connection-button {
gap: 7px;
padding: 0 9px;
}
.connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #c58b2a;
}
.connection-dot.is-connected {
background: #3f8f6b;
}
.connection-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.panel-title {
color: #101827;
font-size: 14px;
font-weight: 800;
}
.panel-subtitle {
color: #8a9bb0;
font-size: 12px;
}
.panel-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
gap: 10px;
color: #66778d;
font-size: 12px;
}
.panel-row code {
overflow-wrap: anywhere;
color: #101827;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.reminder-button {
position: relative;
}
.reminder-badge {
display: inline-flex;
}
.reminder-badge :deep(.el-badge__content) {
height: 16px;
min-width: 16px;
padding: 0 4px;
border: 2px solid #ffffff;
background: #c24b4b;
font-size: 10px;
line-height: 12px;
}
.reminder-menu {
padding: 0 !important;
}
.reminder-panel {
width: 314px;
padding: 10px;
}
.reminder-head {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 4px 10px;
}
.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: 800;
}
.reminder-item {
width: 100%;
height: auto;
padding: 8px 7px !important;
border-radius: 7px;
}
.reminder-mark {
width: 7px;
height: 28px;
border-radius: 999px;
}
.reminder-mark.is-danger {
background: #c24b4b;
}
.reminder-mark.is-warning {
background: #c58b2a;
}
.reminder-body {
display: inline-flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
margin: 0 10px;
}
.reminder-body span,
.reminder-body small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reminder-body span {
color: #142033;
font-size: 13px;
font-weight: 700;
}
.reminder-body small {
color: #7b8da3;
font-size: 12px;
}
.reminder-empty {
padding: 20px 8px;
color: #8a9bb0;
font-size: 13px;
text-align: center;
}
.activity-button {
position: relative;
}
.activity-badge {
display: inline-flex;
}
.activity-badge :deep(.el-badge__content) {
height: 16px;
min-width: 16px;
padding: 0 4px;
border: 2px solid #ffffff;
background: #3f5d75;
font-size: 10px;
line-height: 12px;
}
.activity-panel {
width: 320px;
padding: 10px;
}
.activity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 4px 4px 10px;
}
.activity-clear {
appearance: none;
height: 24px;
padding: 0 7px;
border: 1px solid #d2dce8;
border-radius: 6px;
background: #ffffff;
color: #52667e;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.activity-clear:hover {
background: #eef4fb;
color: #183756;
}
.activity-list {
display: flex;
max-height: 286px;
flex-direction: column;
gap: 3px;
overflow-y: auto;
}
.activity-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-height: 46px;
padding: 7px;
border-radius: 7px;
}
.activity-item:hover {
background: #f4f7fa;
}
.activity-mark {
width: 7px;
height: 28px;
border-radius: 999px;
background: #7b8da3;
}
.activity-mark.is-running {
background: #3f5d75;
}
.activity-mark.is-completed {
background: #3f8f6b;
}
.activity-mark.is-failed {
background: #c24b4b;
}
.activity-mark.is-cancelled {
background: #9aaabd;
}
.activity-body {
display: inline-flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.activity-title,
.activity-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-title {
color: #142033;
font-size: 13px;
font-weight: 800;
}
.activity-detail {
color: #7b8da3;
font-size: 12px;
font-weight: 600;
}
.activity-meta {
display: inline-flex;
min-width: 44px;
flex-direction: column;
align-items: flex-end;
gap: 2px;
color: #52667e;
font-size: 12px;
font-weight: 800;
}
.activity-meta small {
color: #9aaabd;
font-size: 10px;
font-weight: 700;
}
.activity-empty {
padding: 24px 8px;
color: #8a9bb0;
font-size: 13px;
text-align: center;
}
.account-button {
gap: 7px;
max-width: 188px;
padding: 0 8px 0 3px;
}
.account-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 7px;
background: #3f5d75;
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.account-name {
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-tabs {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
padding: 7px 10px;
overflow: hidden;
border-bottom: 1px solid #dfe7f0;
background: #f4f7fa;
}
.workspace-tabs-list {
display: grid;
flex: 1 1 auto;
min-width: 0;
gap: 6px;
overflow: hidden;
}
.workspace-tab {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
height: 30px;
box-sizing: border-box;
padding: 0 5px 0 10px;
border: 1px solid #d2dce8;
border-radius: 8px;
background: #ffffff;
color: #3d5068;
cursor: grab;
font-size: 12px;
font-weight: 700;
}
.workspace-tab:active {
cursor: grabbing;
}
.workspace-tab.active {
border-color: #aebfd1;
background: #eaf1f8;
color: #183756;
}
.workspace-tab.dragging {
opacity: 0.58;
}
.workspace-tab-action {
appearance: none;
display: block;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: inherit;
}
.workspace-tab-action span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: #7b8da3;
cursor: pointer;
}
.tab-close:hover {
background: rgba(15, 23, 42, 0.08);
color: #142033;
}
.workspace-tabs-overflow {
display: flex;
flex: 0 0 106px;
min-width: 0;
}
.workspace-tabs-overflow-trigger {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
width: 106px;
height: 30px;
gap: 6px;
padding: 0 9px 0 11px;
border: 1px solid #c5d1df;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: #3d5068;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.workspace-tabs-overflow-trigger:hover,
.workspace-tabs-overflow-trigger:focus-visible,
.workspace-tabs-overflow-trigger.is-open {
border-color: #aebfd1;
background: #eaf1f8;
color: #183756;
outline: none;
}
.workspace-tabs-overflow-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
box-sizing: border-box;
padding: 0 6px;
border-radius: 9px;
background: #e3ebf4;
color: #31506f;
font-size: 10px;
line-height: 1;
}
:global(.workspace-tabs-overflow-popper.el-popper) {
overflow: hidden;
padding: 0;
border: 1px solid #d6e0eb;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16), 0 2px 8px rgba(15, 23, 42, 0.08);
}
:global(.workspace-tabs-overflow-popper .el-popper__arrow::before) {
border-color: #d6e0eb;
background: #ffffff;
}
.workspace-tabs-overflow-menu {
width: 252px;
}
.workspace-tabs-overflow-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding: 0 12px;
border-bottom: 1px solid #e5ebf2;
color: #2d4058;
font-size: 12px;
font-weight: 800;
}
.workspace-tabs-overflow-header span:last-child {
color: #8292a6;
font-size: 11px;
font-weight: 700;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu) {
min-width: 0;
max-height: min(360px, calc(100vh - 120px));
padding: 6px;
border: 0;
background: transparent;
overflow-y: auto;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item) {
display: flex;
min-height: 38px;
gap: 9px;
padding: 0 6px 0 10px;
border-radius: 7px;
color: #33485f;
font-size: 12px;
font-weight: 700;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item:hover),
:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item:focus) {
background: #f0f5fa;
color: #183756;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item.is-current) {
background: #eaf1f8;
color: #183756;
}
.workspace-tabs-overflow-label {
display: block;
flex: 1 1 auto;
min-width: 0;
}
.workspace-tabs-overflow-item-mark {
flex: 0 0 7px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #9fb1c4;
}
.workspace-tabs-overflow-label > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-tabs-overflow-close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #7b8da3;
cursor: pointer;
font: inherit;
font-size: 16px;
opacity: 0.62;
}
.workspace-tabs-overflow-close:hover,
.workspace-tabs-overflow-close:focus-visible {
background: rgba(15, 23, 42, 0.08);
color: #142033;
opacity: 1;
outline: none;
}
.workspace-tab-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 184px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.workspace-tab-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.workspace-tab-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.workspace-tab-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.desktop-content {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 0;
background: #eef2f6;
}
.desktop-route-shell {
height: 100%;
min-height: 100%;
}
.desktop-route-enter-active,
.desktop-route-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.desktop-route-enter-from {
opacity: 0;
transform: translateY(4px);
}
.desktop-route-leave-to {
opacity: 0;
transform: translateY(-3px);
}
/* profile-settings-dialog:移除外层卡片外壳,只保留容器行为 */
: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(.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(.profile-settings-dialog .el-dialog__header),
:global(.desktop-preferences-dialog .el-dialog__header) {
display: none;
}
:global(.profile-settings-dialog .el-dialog__body) {
padding: 0;
background: transparent;
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 0;
background: transparent;
}
:global([data-ctms-theme="dark"] .desktop-workbench) {
background: #0f172a;
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .desktop-sidebar) {
border-right-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .sidebar-head) {
border-bottom-color: #26364a;
}
:global([data-ctms-theme="dark"] .section-label),
:global([data-ctms-theme="dark"] .panel-subtitle),
:global([data-ctms-theme="dark"] .toolbar-subtitle) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .sidebar-head h1),
:global([data-ctms-theme="dark"] .toolbar-title),
:global([data-ctms-theme="dark"] .panel-title),
:global([data-ctms-theme="dark"] .panel-row code),
:global([data-ctms-theme="dark"] .reminder-body span) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .icon-button) {
color: #9aaabd;
}
:global([data-ctms-theme="dark"] .icon-button:hover) {
border-color: #334155;
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .study-switcher-trigger),
:global([data-ctms-theme="dark"] .command-trigger),
:global([data-ctms-theme="dark"] .update-notice),
:global([data-ctms-theme="dark"] .connection-button),
:global([data-ctms-theme="dark"] .account-button),
:global([data-ctms-theme="dark"] .workspace-tab),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-trigger) {
border-color: #334155;
background: #172033;
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .study-switcher-trigger.empty) {
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .sidebar-link) {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .sidebar-link.child) {
color: #9aaabd;
}
:global([data-ctms-theme="dark"] .sidebar-link:hover) {
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .sidebar-link.active),
:global([data-ctms-theme="dark"] .workspace-tab.active) {
border-color: #3e5c77;
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .desktop-toolbar) {
border-bottom-color: #26364a;
background: rgba(17, 24, 39, 0.92);
}
:global([data-ctms-theme="dark"] .workspace-tabs) {
border-bottom-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-count) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu) {
background: transparent;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper.el-popper) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-popper__arrow::before) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-header) {
border-bottom-color: #334155;
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-header span:last-child) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item:hover),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item:focus),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item.is-current) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-item-mark) {
background: #6f87a1;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close:hover),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close:focus-visible) {
background: rgba(226, 232, 240, 0.1);
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .desktop-content) {
background: #0f172a;
}
:global([data-ctms-theme="dark"] .command-trigger kbd) {
border-color: #334155;
background: #0f172a;
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .account-avatar) {
background: #3e5c77;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:disabled) {
color: #64748b;
}
:global([data-ctms-theme="dark"] .tab-close) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .tab-close:hover) {
background: rgba(226, 232, 240, 0.1);
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .connection-panel) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-item:hover) {
background: #1f2d3d;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-title) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-detail),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta small),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta) {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear) {
border-color: #334155;
background: #172033;
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear:hover) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .panel-row) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .reminder-total) {
background: rgba(242, 139, 139, 0.14);
color: #fecaca;
}
:global([data-ctms-theme="dark"] .reminder-body small),
:global([data-ctms-theme="dark"] .reminder-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb) {
border-color: #111827;
background: #334155;
}
:global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb:hover) {
background: #475569;
}
:global([data-ctms-theme="dark"] .profile-settings-dialog),
:global([data-ctms-theme="dark"] .desktop-preferences-dialog) {
background: #111827;
}
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
/* 与深色侧边栏 --pref-bg-sidebar 保持一致 */
background: #131b28;
}
@media (prefers-reduced-motion: reduce) {
.icon-button,
.desktop-route-enter-active,
.desktop-route-leave-active {
transition-duration: 1ms !important;
}
.desktop-route-enter-from,
.desktop-route-leave-to {
transform: none;
}
}
</style>