feat(桌面与监控): 完善工作台导航和登录活动定位
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
Cheng Zhou
2026-07-13 16:03:20 +08:00
parent b26ebdda02
commit ab59476d10
50 changed files with 2086 additions and 191 deletions
@@ -14,6 +14,12 @@ describe("account connection status", () => {
expect(component).toContain("当前账号与连接状态");
expect(component).toContain("API 延迟");
expect(component).toContain("会话状态");
expect(component).toContain("useStudyStore");
expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)");
expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"');
expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\"");
expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`");
expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"');
expect(component).toContain("mode === 'network'");
expect(component).toContain("当前 IP");
expect(component).toContain("clientIpLabel");
@@ -59,8 +59,8 @@
<dl class="connection-details-grid">
<div>
<dt>角色</dt>
<dd>{{ roleLabel }}</dd>
<dt>{{ roleFieldLabel }}</dt>
<dd :title="roleContextTitle">{{ roleLabel }}</dd>
</div>
<div>
<dt>客户端</dt>
@@ -91,11 +91,14 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { useStudyStore } from "../store/study";
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
import { parseJwtExp } from "../session/jwt";
import { useConnectionStatus } from "../composables/useConnectionStatus";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
mode: "indicator",
@@ -103,8 +106,10 @@ withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
const auth = useAuthStore();
const session = useSessionStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const appMetadata = getAppMetadata();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const {
status,
statusLabel,
@@ -125,7 +130,17 @@ const securityCheckExpanded = ref(true);
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
);
const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号"));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"));
const roleLabel = computed(() => {
if (isSystemAdmin(auth.user)) return "系统管理员";
return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目";
});
const roleContextTitle = computed(() => {
if (isSystemAdmin(auth.user)) return roleLabel.value;
if (!study.currentStudy) return "选择项目后显示该项目内的具体角色";
return `${study.currentStudy.name} · ${roleLabel.value}`;
});
const clientLabel = computed(() => {
if (!isDesktop) return "网页端";
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
@@ -195,6 +210,9 @@ const runSecurityCheck = async () => {
};
onMounted(() => {
if (!isSystemAdmin(auth.user)) {
void loadRoleTemplates().catch(() => {});
}
stopConnectionMonitor = startConnectionMonitor();
nowTs.value = Date.now();
sessionClockTimer = window.setInterval(() => {
+486 -83
View File
@@ -1,5 +1,12 @@
<template>
<div class="desktop-workbench" @click="closeWorkspaceTabMenu">
<div
class="desktop-workbench"
:class="{
'is-sidebar-hidden': !desktopSidebarVisible,
'is-macos': desktopMetadata.platform === 'macos',
}"
@click="closeWorkspaceTabMenu"
>
<aside class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
@@ -117,9 +124,54 @@
<section class="desktop-main">
<header class="desktop-toolbar">
<div class="toolbar-left" data-tauri-drag-region>
<button
class="toolbar-nav-button sidebar-toggle-button"
type="button"
:title="desktopSidebarToggleLabel"
:aria-label="desktopSidebarToggleLabel"
:aria-pressed="!desktopSidebarVisible"
@click="toggleDesktopSidebar"
>
<el-icon><Fold v-if="desktopSidebarVisible" /><Expand v-else /></el-icon>
</button>
<div class="workspace-history-controls" aria-label="当前任务导航">
<button
class="toolbar-nav-button"
type="button"
title="后退(当前任务)"
:disabled="!canNavigateWorkspaceTabBack"
@click="navigateCurrentWorkspaceTabHistory(-1)"
>
<el-icon><ArrowLeft /></el-icon>
</button>
<button
class="toolbar-nav-button"
type="button"
title="前进(当前任务)"
:disabled="!canNavigateWorkspaceTabForward"
@click="navigateCurrentWorkspaceTabHistory(1)"
>
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<span class="toolbar-history-divider" aria-hidden="true"></span>
<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 class="toolbar-subtitle" :title="desktopToolbarSubtitle" data-tauri-drag-region>
<template v-for="(item, index) in desktopToolbarContextItems" :key="`${item.label}:${item.path || index}`">
<span v-if="index" class="toolbar-context-separator" aria-hidden="true">·</span>
<button
v-if="item.path"
class="toolbar-context-link"
type="button"
:title="`返回${item.label}`"
@click="navigateToolbarContext(item.path)"
>
{{ item.label }}
</button>
<span v-else>{{ item.label }}</span>
</template>
</div>
</div>
</div>
@@ -274,7 +326,7 @@
</div>
</header>
<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
<div
class="workspace-tabs-list"
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
@@ -306,33 +358,47 @@
</div>
<el-dropdown
v-if="overflowWorkspaceTabs.length"
v-if="hasWorkspaceTabsOverflow"
class="workspace-tabs-overflow"
popper-class="workspace-tabs-overflow-popper"
trigger="click"
placement="bottom-end"
@command="navigateOverflowWorkspaceTab"
@visible-change="workspaceTabsOverflowOpen = $event"
@visible-change="handleWorkspaceTabsOverflowVisibleChange"
>
<button
class="workspace-tabs-overflow-trigger"
:class="{ 'is-open': workspaceTabsOverflowOpen }"
type="button"
:aria-label="`查看其余 ${overflowWorkspaceTabs.length} 个标签`"
:aria-label="`查看全部 ${workspaceTabs.length} 个任务`"
>
<span>更多</span>
<span class="workspace-tabs-overflow-count">{{ overflowWorkspaceTabs.length }}</span>
<span>全部任务</span>
<span class="workspace-tabs-overflow-count">{{ workspaceTabs.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>
<span>全部任务</span>
<span>{{ workspaceTabs.length }} 个</span>
</div>
<div class="workspace-tabs-overflow-search" @click.stop @keydown.stop>
<el-icon><Search /></el-icon>
<input
v-model="workspaceTabsSearchQuery"
type="search"
autocomplete="off"
aria-label="搜索已打开任务"
placeholder="搜索已打开任务"
/>
</div>
<div class="workspace-tabs-overflow-actions">
<button type="button" @click.stop="closeOtherWorkspaceTabs(activeMenu)">关闭其他</button>
<button type="button" @click.stop="closeAllWorkspaceTabs">关闭全部</button>
</div>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in overflowWorkspaceTabs"
v-for="item in filteredWorkspaceTabs"
:key="item.path"
:command="item.path"
:class="{ 'is-current': activeMenu === item.path }"
@@ -351,6 +417,7 @@
×
</button>
</el-dropdown-item>
<li v-if="!filteredWorkspaceTabs.length" class="workspace-tabs-overflow-empty">没有匹配的任务</li>
</el-dropdown-menu>
</div>
</template>
@@ -432,6 +499,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Box,
Calendar,
@@ -441,8 +510,10 @@ import {
DataAnalysis,
Document,
Download,
Expand,
Files,
Flag,
Fold,
Folder,
House,
Key,
@@ -488,7 +559,9 @@ import {
getDesktopServerUrl,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopSidebarVisible,
readDesktopShortcutPreferences,
setDesktopSidebarVisible,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
type DesktopShortcutPreferences,
@@ -511,6 +584,7 @@ import {
type LayoutNavigationItem,
} from "./layout/navigation";
import {
canMoveWorkspaceTabHistory,
createWorkspaceTabHistory,
currentWorkspaceTabPath,
moveWorkspaceTabHistory,
@@ -518,6 +592,12 @@ import {
replaceWorkspaceTabPath,
type WorkspaceTabHistory,
} from "./layout/workspaceTabHistory";
import {
buildWorkspaceTabTitle,
buildWorkspaceToolbarTitle,
resolveWorkspaceTabContextTitle,
type WorkspaceTabContext,
} from "./layout/workspaceTabTitle";
const auth = useAuthStore();
const study = useStudyStore();
@@ -525,10 +605,8 @@ 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_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;
const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
@@ -554,11 +632,12 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const desktopShortcutPreferences = ref<DesktopShortcutPreferences>(readDesktopShortcutPreferences());
const desktopSidebarVisible = ref(readDesktopSidebarVisible());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
const workspaceTabsElement = ref<HTMLElement | null>(null);
const workspaceTabsAvailableWidth = ref(0);
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
const workspaceTabsOverflowOpen = ref(false);
const workspaceTabsSearchQuery = ref("");
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
@@ -580,7 +659,6 @@ 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,
@@ -659,6 +737,7 @@ const userDisplayInitial = computed(() => {
return source.charAt(0).toUpperCase();
});
const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject);
const desktopSidebarToggleLabel = computed(() => (desktopSidebarVisible.value ? "隐藏导航栏" : "显示导航栏"));
const projectEntryMenuLabel = computed(() => (study.currentStudy ? "返回项目选择" : "选择项目"));
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
@@ -700,39 +779,60 @@ const formatDesktopActivityTime = (value: string) => {
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
const storedContext = workspaceTabContexts.value[activeMenu.value];
const pageTitle = resolveWorkspaceTabContextTitle({
liveContextTitle: study.viewContext?.pageTitle,
storedContext,
routePath: route.path,
});
if (!pageTitle) return null;
const storedContextMatchesRoute = storedContext?.routePath === route.path;
return {
routePath: route.path,
pageTitle,
objectType: study.viewContext?.objectType || (storedContextMatchesRoute ? storedContext.objectType : undefined),
};
});
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),
),
);
return {
path: item.path,
title: buildWorkspaceTabTitle({
moduleTitle: item.label,
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
contextTitle: currentWorkspaceContext.value?.pageTitle,
}),
group: item.group,
};
});
const workspaceTabsByRecentUse = computed(() =>
workspaceTabs.value
.map((item, index) => ({ item, index }))
.sort(
(left, right) =>
left.item.updatedAt.localeCompare(right.item.updatedAt) ||
Number(left.item.path === activeMenu.value) - Number(right.item.path === activeMenu.value) ||
left.index - right.index,
)
.map(({ item }) => item),
);
const hasWorkspaceTabsOverflow = computed(
() => workspaceTabs.value.length > DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD,
);
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];
if (!hasWorkspaceTabsOverflow.value) return workspaceTabs.value;
const recentVisiblePaths = new Set(
workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT).map((item) => item.path),
);
return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));
});
const overflowWorkspaceTabs = computed(() => {
const visiblePaths = new Set(visibleWorkspaceTabs.value.map((item) => item.path));
return workspaceTabs.value.filter((item) => !visiblePaths.has(item.path));
const filteredWorkspaceTabs = computed(() => {
const query = workspaceTabsSearchQuery.value.trim().toLocaleLowerCase();
const recentFirst = [...workspaceTabsByRecentUse.value].reverse();
if (!query) return recentFirst;
return recentFirst.filter((item) => `${item.title} ${item.path}`.toLocaleLowerCase().includes(query));
});
const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
@@ -740,25 +840,42 @@ const currentRouteFavorited = computed(() => {
});
const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value;
return current?.title || String(route.meta?.title || TEXT.common.appName);
if (!current) return String(route.meta?.title || TEXT.common.appName);
return buildWorkspaceToolbarTitle({
moduleTitle: desktopNavigationItems.value.find((item) => item.path === current.path)?.label || current.title,
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
contextTitle: currentWorkspaceContext.value?.pageTitle,
contextObjectType: currentWorkspaceContext.value?.objectType,
});
});
const desktopToolbarSubtitle = computed(() => {
const desktopToolbarContextItems = computed<Array<{ label: string; path?: string }>>(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return TEXT.common.appName;
if (!current) return [{ label: TEXT.common.appName }];
const moduleItem = desktopNavigationItems.value.find((item) => item.path === current.path);
const items: Array<{ label: string; path?: string }> = [];
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) {
items.push({ label: DESKTOP_ADMIN_SECTION_LABEL });
if (section) items.push({ label: section });
} else if (projectDesktopNavigationItems.value.some((item) => item.path === current.path)) {
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(" / ");
items.push({ label: projectName });
if (section) items.push({ label: section });
} else {
items.push({ label: current.group || TEXT.common.appName });
}
return current.group || TEXT.common.appName;
if (currentWorkspaceContext.value && moduleItem && !items.some((item) => item.label === moduleItem.label)) {
items.push({ label: moduleItem.label, path: moduleItem.path });
}
return items;
});
const desktopToolbarSubtitle = computed(() => desktopToolbarContextItems.value.map((item) => item.label).join(" · "));
const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[activeMenu.value]);
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
const canNavigateWorkspaceTabForward = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
@@ -823,6 +940,13 @@ const removeWorkspaceTabHistory = (tabPath: string) => {
workspaceTabHistories.value = next;
};
const removeWorkspaceTabContext = (tabPath: string) => {
if (!workspaceTabContexts.value[tabPath]) return;
const next = { ...workspaceTabContexts.value };
delete next[tabPath];
workspaceTabContexts.value = next;
};
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
@@ -849,27 +973,18 @@ const navigateCurrentWorkspaceTabHistory = (offset: -1 | 1) => {
void router.push(movement.path);
};
const navigateToolbarContext = (path: string) => {
if (route.path === path) return;
void router.push(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 handleWorkspaceTabsOverflowVisibleChange = (visible: boolean) => {
workspaceTabsOverflowOpen.value = visible;
if (!visible) workspaceTabsSearchQuery.value = "";
};
const closeWorkspaceTabMenu = () => {
@@ -932,6 +1047,7 @@ const closeWorkspaceTab = (path: string) => {
}
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
removeWorkspaceTabHistory(path);
removeWorkspaceTabContext(path);
if (activeMenu.value !== path) return;
const next =
workspaceTabs.value[index] ||
@@ -955,10 +1071,36 @@ const closeOtherWorkspaceTabs = (path: string) => {
workspaceTabs.value = [target];
const targetHistory = workspaceTabHistories.value[path];
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
const targetContext = workspaceTabContexts.value[path];
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
closeWorkspaceTabMenu();
};
const closeAllWorkspaceTabs = () => {
const activeTab = workspaceTabs.value.find((tab) => tab.path === activeMenu.value);
if (activeTab) {
lastClosedWorkspaceTab.value = activeTab;
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
}
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTabsSearchQuery.value = "";
closeWorkspaceTabMenu();
const fallbackPath = workspaceTabFallbackPath.value;
if (activeMenu.value === fallbackPath) {
const fallbackTab = currentDesktopRoutePreference.value;
if (fallbackTab) {
addWorkspaceTab(fallbackTab);
recordWorkspaceTabRoute(fallbackTab.path, route.fullPath);
}
return;
}
void router.push(fallbackPath);
};
const restoreLastClosedWorkspaceTab = () => {
const tab = lastClosedWorkspaceTab.value;
if (!tab) return;
@@ -989,10 +1131,15 @@ const openDesktopPreferences = () => {
desktopPreferencesVisible.value = true;
};
const toggleDesktopSidebar = () => {
desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);
};
const openDesktopProjectEntry = async () => {
study.clearCurrentStudy();
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
await router.push("/desktop/project-entry");
@@ -1011,7 +1158,7 @@ const refreshCurrentDesktopView = () => {
};
const handleDesktopMenuCommand = (command: string) => {
if (command === "ctms.desktop.commandPalette") {
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
openCommandPalette();
return;
}
@@ -1033,6 +1180,10 @@ const handleDesktopMenuCommand = (command: string) => {
}
if (command === "ctms.desktop.forward") {
navigateCurrentWorkspaceTabHistory(1);
return;
}
if (command === "ctms.desktop.toggleSidebar") {
toggleDesktopSidebar();
}
};
@@ -1236,11 +1387,14 @@ const beforeProfileDialogClose = async (done: () => void) => {
watch(
() => route.fullPath,
(fullPath, previousFullPath) => {
study.setViewContext(null);
const previousRoute = previousFullPath ? router.resolve(previousFullPath) : null;
if (!previousRoute || previousRoute.path !== route.path) {
study.setViewContext(null);
}
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
const previousTabPath = previousFullPath ? getActiveLayoutPath(router.resolve(previousFullPath).path) : "";
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
const replacesCurrentTabEntry =
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
if (replacesCurrentTabEntry) {
@@ -1266,12 +1420,27 @@ watch(
() => refreshDesktopRoutePreferences(),
);
watch(workspaceTabsElement, (element) => observeWorkspaceTabsElement(element), { flush: "post" });
watch(
() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,
([pageTitle, objectType]) => {
if (!pageTitle) return;
workspaceTabContexts.value = {
...workspaceTabContexts.value,
[activeMenu.value]: {
routePath: route.path,
pageTitle,
objectType,
},
};
const current = currentDesktopRoutePreference.value;
if (current) addWorkspaceTab(current);
},
{ flush: "sync" },
);
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) {
@@ -1304,8 +1473,6 @@ onMounted(async () => {
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?.();
@@ -1348,6 +1515,15 @@ useDesktopShortcuts(
overflow: hidden;
background: #eef2f6;
color: #142033;
transition: grid-template-columns 180ms ease;
}
.desktop-workbench.is-sidebar-hidden {
grid-template-columns: 0 minmax(0, 1fr);
}
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
padding-left: 88px;
}
.icon-button {
@@ -1372,6 +1548,18 @@ useDesktopShortcuts(
flex-direction: column;
background: #f8fafc;
border-right: 1px solid #d7e0ea;
opacity: 1;
transform: translateX(0);
visibility: visible;
transition: opacity 140ms ease, transform 180ms ease, visibility 180ms ease;
}
.desktop-workbench.is-sidebar-hidden .desktop-sidebar {
border-right-color: transparent;
opacity: 0;
pointer-events: none;
transform: translateX(-12px);
visibility: hidden;
}
.sidebar-head {
@@ -1601,6 +1789,7 @@ useDesktopShortcuts(
.toolbar-left {
align-self: stretch;
gap: 10px;
cursor: default;
user-select: none;
}
@@ -1612,6 +1801,10 @@ useDesktopShortcuts(
.toolbar-right,
.toolbar-right :deep(*),
.sidebar-toggle-button,
.workspace-history-controls,
.workspace-history-controls *,
.toolbar-context-link,
.desktop-sidebar button,
.desktop-sidebar :deep(.el-dropdown),
.workspace-tabs,
@@ -1621,6 +1814,51 @@ useDesktopShortcuts(
-webkit-app-region: no-drag;
}
.workspace-history-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
}
.toolbar-nav-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #52667d;
cursor: pointer;
font: inherit;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.toolbar-nav-button:hover:not(:disabled),
.toolbar-nav-button:focus-visible:not(:disabled) {
border-color: #d3dee9;
background: #edf3f8;
color: #183756;
outline: none;
}
.toolbar-nav-button:disabled {
color: #aeb9c7;
cursor: default;
opacity: 0.56;
}
.toolbar-history-divider {
flex: 0 0 1px;
width: 1px;
height: 22px;
background: #d7e0ea;
}
.toolbar-left[data-tauri-drag-region],
.toolbar-context[data-tauri-drag-region],
.toolbar-title[data-tauri-drag-region],
@@ -1630,6 +1868,7 @@ useDesktopShortcuts(
.toolbar-context {
display: grid;
flex: 1 1 auto;
min-width: 0;
align-content: center;
gap: 2px;
@@ -1653,12 +1892,44 @@ useDesktopShortcuts(
}
.toolbar-subtitle {
display: flex;
align-items: center;
gap: 5px;
color: #66778d;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.toolbar-context-separator {
flex: 0 0 auto;
color: #9aa8b8;
}
.toolbar-context-link {
appearance: none;
min-width: 0;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: inherit;
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-context-link:hover,
.toolbar-context-link:focus-visible {
color: #183756;
text-decoration: underline;
text-underline-offset: 2px;
outline: none;
}
.command-trigger,
.update-notice,
.connection-button,
@@ -2033,7 +2304,7 @@ useDesktopShortcuts(
align-items: center;
min-width: 0;
gap: 6px;
padding: 7px 10px;
padding: 0 10px;
overflow: hidden;
border-bottom: 1px solid #dfe7f0;
background: #f4f7fa;
@@ -2048,6 +2319,7 @@ useDesktopShortcuts(
}
.workspace-tab {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -2073,6 +2345,7 @@ useDesktopShortcuts(
border-color: #aebfd1;
background: #eaf1f8;
color: #183756;
box-shadow: inset 0 -2px #315f89;
}
.workspace-tab.dragging {
@@ -2108,6 +2381,14 @@ useDesktopShortcuts(
background: transparent;
color: #7b8da3;
cursor: pointer;
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.workspace-tab:hover .tab-close,
.workspace-tab.active .tab-close,
.tab-close:focus-visible {
opacity: 1;
}
.tab-close:hover {
@@ -2117,7 +2398,7 @@ useDesktopShortcuts(
.workspace-tabs-overflow {
display: flex;
flex: 0 0 106px;
flex: 0 0 132px;
min-width: 0;
}
@@ -2126,7 +2407,7 @@ useDesktopShortcuts(
display: flex;
align-items: center;
justify-content: center;
width: 106px;
width: 132px;
height: 30px;
gap: 6px;
padding: 0 9px 0 11px;
@@ -2181,7 +2462,7 @@ useDesktopShortcuts(
}
.workspace-tabs-overflow-menu {
width: 252px;
width: 292px;
}
.workspace-tabs-overflow-header {
@@ -2202,6 +2483,64 @@ useDesktopShortcuts(
font-weight: 700;
}
.workspace-tabs-overflow-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 34px;
margin: 8px 8px 6px;
padding: 0 9px;
border: 1px solid #d6e0eb;
border-radius: 7px;
background: #f8fafc;
color: #8292a6;
}
.workspace-tabs-overflow-search input {
min-width: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: #223349;
font: inherit;
font-size: 12px;
}
.workspace-tabs-overflow-search input::placeholder {
color: #96a4b5;
}
.workspace-tabs-overflow-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 0 8px 6px;
border-bottom: 1px solid #e5ebf2;
}
.workspace-tabs-overflow-actions button {
appearance: none;
min-height: 26px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #52667d;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.workspace-tabs-overflow-actions button:hover,
.workspace-tabs-overflow-actions button:focus-visible {
background: #eef4fb;
color: #183756;
outline: none;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu) {
min-width: 0;
max-height: min(360px, calc(100vh - 120px));
@@ -2233,6 +2572,16 @@ useDesktopShortcuts(
color: #183756;
}
.workspace-tabs-overflow-empty {
display: flex;
min-height: 72px;
align-items: center;
justify-content: center;
color: #8292a6;
font-size: 12px;
list-style: none;
}
.workspace-tabs-overflow-label {
display: block;
flex: 1 1 auto;
@@ -2431,6 +2780,30 @@ useDesktopShortcuts(
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button) {
color: #aebed0;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button:hover:not(:disabled)),
:global([data-ctms-theme="dark"] .toolbar-nav-button:focus-visible:not(:disabled)) {
border-color: #334155;
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button:disabled) {
color: #64748b;
}
:global([data-ctms-theme="dark"] .toolbar-history-divider) {
background: #334155;
}
:global([data-ctms-theme="dark"] .toolbar-context-link:hover),
:global([data-ctms-theme="dark"] .toolbar-context-link:focus-visible) {
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .study-switcher-trigger),
:global([data-ctms-theme="dark"] .command-trigger),
:global([data-ctms-theme="dark"] .update-notice),
@@ -2505,6 +2878,30 @@ useDesktopShortcuts(
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search) {
border-color: #334155;
background: #111827;
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search input) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions) {
border-bottom-color: #334155;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button) {
color: #aebed0;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:hover),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:focus-visible) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item) {
color: #dbe5f1;
}
@@ -2520,6 +2917,10 @@ useDesktopShortcuts(
background: #6f87a1;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close) {
color: #94a3b8;
}
@@ -2739,7 +3140,7 @@ useDesktopShortcuts(
.workspace-tabs {
gap: 8px;
padding: 9px 14px;
padding: 0 14px;
}
.workspace-tab {
@@ -2779,6 +3180,8 @@ useDesktopShortcuts(
@media (prefers-reduced-motion: reduce) {
.icon-button,
.desktop-workbench,
.desktop-sidebar,
.desktop-route-enter-active,
.desktop-route-leave-active {
transition-duration: 1ms !important;
+105 -15
View File
@@ -37,6 +37,8 @@ describe("desktop layout shell", () => {
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain("initializeDesktopThemePreference();");
expect(app).toContain("initializeDesktopMenuShortcutSync();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
});
@@ -98,9 +100,14 @@ describe("desktop layout shell", () => {
expect(source).toContain("desktopToolbarSubtitle");
expect(source).not.toContain("desktopBreadcrumbs");
expect(source).not.toContain("breadcrumb-chip");
expect(source).not.toContain("history-controls");
expect(source).not.toContain('title="后退"');
expect(source).not.toContain('title="前进"');
expect(source).toContain('class="workspace-history-controls"');
expect(source).toContain('title="后退(当前任务)"');
expect(source).toContain('title="前进(当前任务)"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabBack"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabForward"');
expect(source).toContain('class="toolbar-history-divider"');
expect(source).toContain('v-for="(item, index) in desktopToolbarContextItems"');
expect(source).toContain('class="toolbar-context-link"');
expect(source).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
@@ -135,6 +142,29 @@ describe("desktop layout shell", () => {
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("uses a native macOS application menu and rebuilds a closed main window from the Dock", () => {
const tauriLib = readTauriLibSource();
expect(tauriLib.indexOf("package.name.clone()")).toBeLessThan(tauriLib.indexOf('"文件"'));
expect(tauriLib).toContain('Some("关于 CTMS")');
expect(tauriLib).toContain("short_version: Some(String::new())");
expect(tauriLib).toContain("icon: handle.default_window_icon().cloned()");
expect(tauriLib).toContain('"ctms.desktop.preferences"');
expect(tauriLib).toContain("PredefinedMenuItem::services");
expect(tauriLib).toContain("PredefinedMenuItem::hide_others");
expect(tauriLib).toContain("PredefinedMenuItem::show_all");
expect(tauriLib).toContain("WINDOW_SUBMENU_ID");
expect(tauriLib).toContain("HELP_SUBMENU_ID");
expect(tauriLib).toContain("TOGGLE_SIDEBAR_COMMAND_ID");
expect(tauriLib).toContain('"显示/隐藏导航栏"');
expect(tauriLib).toContain("RunEvent::Reopen");
expect(tauriLib).toContain("WebviewWindowBuilder::from_config");
expect(tauriLib).toContain('find(|config| config.label == "main")');
expect(tauriLib).not.toContain("WindowEvent::CloseRequested");
expect(tauriLib).not.toContain("api.prevent_close()");
expect(tauriLib).not.toContain("window.hide()");
});
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
@@ -169,6 +199,24 @@ describe("desktop layout shell", () => {
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("persists a fully hidden desktop sidebar with a macOS-safe restore control", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("const desktopSidebarVisible = ref(readDesktopSidebarVisible());");
expect(layout).toContain("desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);");
expect(layout).toContain("'is-sidebar-hidden': !desktopSidebarVisible");
expect(layout).toContain("'is-macos': desktopMetadata.platform === 'macos'");
expect(layout).toContain('class="toolbar-nav-button sidebar-toggle-button"');
expect(layout).toContain(':title="desktopSidebarToggleLabel"');
expect(layout).toContain('<Fold v-if="desktopSidebarVisible" /><Expand v-else />');
expect(layout).toContain('command === "ctms.desktop.toggleSidebar"');
expect(layout).toContain(".desktop-workbench.is-sidebar-hidden {");
expect(layout).toContain("grid-template-columns: 0 minmax(0, 1fr);");
expect(layout).toContain(".desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {");
expect(layout).toContain("padding-left: 88px;");
expect(layout).toContain("pointer-events: none;");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -183,6 +231,10 @@ describe("desktop layout shell", () => {
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ value: "system" as const, label: "跟随系统"');
expect(preferences).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(preferences).toContain("width: min(100%, 336px);");
expect(preferences).toContain("white-space: nowrap;");
expect(preferences).toContain('{ id: "shortcuts", label: "快捷键"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
@@ -249,7 +301,7 @@ describe("desktop layout shell", () => {
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -292,6 +344,8 @@ describe("desktop layout shell", () => {
expect(source).toContain("replaceWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath)");
expect(source).toContain("moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1)");
expect(source).toContain("navigateBack: () => navigateCurrentWorkspaceTabHistory(-1)");
expect(source).toContain("navigateForward: () => navigateCurrentWorkspaceTabHistory(1)");
expect(source).toContain('id: "desktop:tab-back"');
@@ -300,6 +354,25 @@ describe("desktop layout shell", () => {
expect(source).not.toContain("navigateForward: () => router.forward()");
});
it("updates a detail workspace tab after its business context has loaded", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("buildWorkspaceTabTitle,");
expect(source).toContain("buildWorkspaceToolbarTitle,");
expect(source).toContain("resolveWorkspaceTabContextTitle,");
expect(source).toContain('} from "./layout/workspaceTabTitle";');
expect(source).toContain("const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});");
expect(source).toContain("title: buildWorkspaceTabTitle({");
expect(source).toContain("const pageTitle = resolveWorkspaceTabContextTitle({");
expect(source).toContain("() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,");
expect(source).toContain("[activeMenu.value]: {");
expect(source).toContain("([pageTitle, objectType]) => {");
expect(source).toContain("if (current) addWorkspaceTab(current);");
expect(source).toContain('{ flush: "sync" },');
expect(source).toContain("if (!previousRoute || previousRoute.path !== route.path) {");
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
@@ -313,30 +386,45 @@ describe("desktop layout shell", () => {
expect(preferences).toContain('window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true })');
expect(preferences).toContain("event.stopImmediatePropagation()");
expect(preferences).toContain("恢复默认");
expect(tauriLib).not.toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).not.toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).not.toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain("desktop_menu_set_shortcuts");
expect(tauriLib).toContain("set_menu_accelerator");
});
it("moves overflowing workspace tabs into a fixed dropdown without horizontal scrolling", () => {
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('v-for="item in visibleWorkspaceTabs"');
expect(tabsTemplate).toContain('v-if="overflowWorkspaceTabs.length"');
expect(tabsTemplate).toContain('v-if="hasWorkspaceTabsOverflow"');
expect(tabsTemplate).toContain('popper-class="workspace-tabs-overflow-popper"');
expect(tabsTemplate).toContain('{{ overflowWorkspaceTabs.length }}');
expect(tabsTemplate).toContain('<span>全部任务</span>');
expect(tabsTemplate).toContain('{{ workspaceTabs.length }}');
expect(tabsTemplate).toContain('@command="navigateOverflowWorkspaceTab"');
expect(tabsTemplate).toContain('gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))`');
expect(tabsTemplate).toContain('class="workspace-tabs-overflow-header"');
expect(source).toContain("const visibleWorkspaceTabCapacity = computed(() => {");
expect(tabsTemplate).toContain('v-model="workspaceTabsSearchQuery"');
expect(tabsTemplate).toContain('v-for="item in filteredWorkspaceTabs"');
expect(tabsTemplate).toContain('@click.stop="closeOtherWorkspaceTabs(activeMenu)"');
expect(tabsTemplate).toContain('@click.stop="closeAllWorkspaceTabs"');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;");
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;");
expect(source).toContain("const workspaceTabsByRecentUse = computed(() =>");
expect(source).toContain("const visibleWorkspaceTabs = computed(() => {");
expect(source).toContain("const overflowWorkspaceTabs = computed(() => {");
expect(source).toContain('workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;");
expect(source).toContain("const recentVisiblePaths = new Set(");
expect(source).toContain("workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT)");
expect(source).toContain("return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));");
expect(source).toContain("const filteredWorkspaceTabs = computed(() => {");
expect(source).toContain("const closeAllWorkspaceTabs = () => {");
expect(source).not.toContain("visibleWorkspaceTabCapacity");
expect(source).not.toContain("workspaceTabsResizeObserver");
expect(source).toContain(".workspace-tabs-list {\n display: grid;");
expect(source).toContain("padding: 0 10px;");
expect(source).toContain("padding: 0 14px;");
expect(source).toContain("overflow: hidden;");
expect(source).not.toContain("overflow-x: auto;");
});
@@ -584,6 +672,8 @@ describe("desktop layout shell", () => {
expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;");
expect(profile).toContain(".dialog-close:hover");
expect(profile).toContain(".dialog-close :deep(.el-icon)");
expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;");
});
@@ -183,6 +183,7 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("loadIpRankingData");
expect(source).toContain("fetchAccessRowsForIpRanking");
expect(source).toContain("buildIpRankingRows");
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("ip-rank-grid");
expect(source).toContain("ip-rank-card");
@@ -325,7 +325,7 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
const REALTIME_POLL_INTERVAL_MS = 30_000;
const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;
const IP_RANKING_PAGE_SIZE = 200;
const IP_RANKING_LIMIT = 20;
const IP_RANKING_LIMIT = 10;
const TIME_RANGE_PRESETS = [
{ value: "all", label: "全部", hours: null },
@@ -73,8 +73,11 @@ describe("PermissionIpLocations", () => {
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain('const mapView = ref<"china" | "flow">("flow");');
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("external_fallback_ip_count");
expect(source).toContain("外部补全");
expect(source).toContain("chinaRankRows");
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
expect(source).not.toContain('from "./worldMapSource"');
@@ -45,6 +45,9 @@
<el-tag v-if="dataQuality && dataQuality.unknown_ip_count" type="warning" effect="plain" round size="small">
定位 {{ dataQuality.location_coverage_rate }}%
</el-tag>
<el-tag v-if="dataQuality?.external_fallback_ip_count" type="info" effect="plain" round size="small">
外部补全 {{ dataQuality.external_fallback_ip_count }} IP
</el-tag>
<el-tooltip content="全屏预览" placement="top">
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
@@ -205,7 +208,7 @@ const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
};
return map[preset];
};
const mapView = ref<"china" | "flow">("china");
const mapView = ref<"china" | "flow">("flow");
const mapMetric = ref<"traffic" | "risk">("traffic");
const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null);
+1 -1
View File
@@ -786,7 +786,7 @@ const refreshCurrentDesktopView = () => {
};
const handleDesktopMenuCommand = (command: string) => {
if (command === "ctms.desktop.commandPalette") {
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
openCommandPalette();
return;
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceTabTitle,
buildWorkspaceToolbarTitle,
resolveWorkspaceTabContextTitle,
} from "./workspaceTabTitle";
describe("workspace tab title", () => {
it("keeps the module title when no detail context is available", () => {
expect(buildWorkspaceTabTitle({ moduleTitle: "参与者管理", routeTitle: "参与者详情" })).toBe("参与者管理");
});
it("combines the detail object type with its loaded identifier", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "SUBJ-001",
}),
).toBe("参与者 · SUBJ-001");
});
it("uses a non-detail route title as the context prefix", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "知情同意书",
}),
).toBe("文件版本管理 · 知情同意书");
});
it("avoids duplicate fallback titles", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "设备管理",
routeTitle: "设备详情",
contextTitle: "设备详情",
}),
).toBe("设备详情");
});
it("keeps a loaded context when the same detail route temporarily clears its live context", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-1",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBe("SUBJ-001");
});
it("does not leak a stored title into another detail route", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-2",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBeUndefined();
});
it("uses the business object type in the desktop toolbar", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "11",
contextObjectType: "SOP",
}),
).toBe("SOP · 11");
});
it("derives a concise object label from a detail route", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "S01",
}),
).toBe("参与者 · S01");
});
});
@@ -0,0 +1,53 @@
interface WorkspaceTabTitleInput {
moduleTitle: string;
routeTitle?: string;
contextTitle?: string;
}
export interface WorkspaceTabContext {
routePath: string;
pageTitle: string;
objectType?: string;
}
interface WorkspaceTabContextTitleInput {
liveContextTitle?: string;
storedContext?: WorkspaceTabContext;
routePath: string;
}
export const resolveWorkspaceTabContextTitle = ({
liveContextTitle,
storedContext,
routePath,
}: WorkspaceTabContextTitleInput) =>
liveContextTitle || (storedContext?.routePath === routePath ? storedContext.pageTitle : undefined);
interface WorkspaceToolbarTitleInput extends WorkspaceTabTitleInput {
contextObjectType?: string;
}
export const buildWorkspaceToolbarTitle = ({
moduleTitle,
routeTitle,
contextTitle,
contextObjectType,
}: WorkspaceToolbarTitleInput) => {
const contextLabel = contextTitle?.trim();
if (!contextLabel) return moduleTitle.trim();
const objectLabel = contextObjectType?.trim() || routeTitle?.replace(/详情$/, "").trim() || moduleTitle.trim();
if (!objectLabel || contextLabel === objectLabel) return contextLabel;
return `${objectLabel} · ${contextLabel}`;
};
export const buildWorkspaceTabTitle = ({ moduleTitle, routeTitle, contextTitle }: WorkspaceTabTitleInput) => {
const moduleLabel = moduleTitle.trim();
const routeLabel = routeTitle?.trim() || moduleLabel;
const contextLabel = contextTitle?.trim();
if (!contextLabel || contextLabel === moduleLabel) return moduleLabel;
if (contextLabel === routeLabel) return routeLabel;
const objectLabel = routeLabel.replace(/详情$/, "").trim() || moduleLabel;
if (contextLabel === objectLabel) return routeLabel;
return `${objectLabel} · ${contextLabel}`;
};