feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user