From 0aff10fe0dbdae5a7b74f612d7e5b696626b61de Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 9 Jul 2026 08:56:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=AF=BC=E8=88=AA=E4=B8=8E=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/scripts/verify-ui-contract.mjs | 30 +- frontend/src-tauri/src/lib.rs | 6 +- frontend/src/components/DesktopLayout.vue | 540 ++++++++++++++++-- .../src/components/Layout.desktop.test.ts | 74 ++- .../layout/workspaceTabHistory.test.ts | 50 ++ .../components/layout/workspaceTabHistory.ts | 54 ++ .../src/composables/useDesktopShortcuts.ts | 47 +- frontend/src/router.test.ts | 15 + frontend/src/router/index.ts | 7 +- .../src/runtime/desktopUiPreferences.test.ts | 40 ++ frontend/src/runtime/desktopUiPreferences.ts | 130 +++++ frontend/src/runtime/index.ts | 12 + frontend/src/views/DesktopPreferences.vue | 192 ++++++- frontend/src/views/DesktopProjectEntry.vue | 70 +-- .../src/views/ia/FileVersionManagement.vue | 41 -- .../src/views/ia/MaterialEquipment.test.ts | 14 + frontend/src/views/ia/MaterialEquipment.vue | 117 ++-- 17 files changed, 1232 insertions(+), 207 deletions(-) create mode 100644 frontend/src/components/layout/workspaceTabHistory.test.ts create mode 100644 frontend/src/components/layout/workspaceTabHistory.ts delete mode 100644 frontend/src/views/ia/FileVersionManagement.vue diff --git a/frontend/scripts/verify-ui-contract.mjs b/frontend/scripts/verify-ui-contract.mjs index 913291ad..3627b793 100644 --- a/frontend/scripts/verify-ui-contract.mjs +++ b/frontend/scripts/verify-ui-contract.mjs @@ -100,26 +100,46 @@ for (const group of requiredOverviewGroups) { } } -const financeAndFilePages = [ +const unifiedShellPages = [ "src/views/fees/ContractFees.vue", - "src/views/ia/FileVersionManagement.vue" + "src/views/documents/DocumentList.vue" ]; -const requiredFinanceAndFileClasses = [ +const requiredUnifiedShellClasses = [ "ctms-page-shell", "unified-action-bar", "unified-shell" ]; -for (const file of financeAndFilePages) { +for (const file of unifiedShellPages) { const content = readFileSync(file, "utf8"); - for (const className of requiredFinanceAndFileClasses) { + for (const className of requiredUnifiedShellClasses) { if (!content.includes(className)) { missing.push(`${file}:${className}`); } } } +const router = readFileSync("src/router/index.ts", "utf8"); +const legacyFileVersionRouteIndex = router.indexOf('name: "FileVersionManagement"'); +const canonicalDocumentRouteIndex = router.indexOf('path: "trial/:trialId/documents"', legacyFileVersionRouteIndex); +const legacyFileVersionRoute = + legacyFileVersionRouteIndex >= 0 && canonicalDocumentRouteIndex > legacyFileVersionRouteIndex + ? router.slice(legacyFileVersionRouteIndex, canonicalDocumentRouteIndex) + : ""; + +if (router.includes("FileVersionManagement.vue")) { + missing.push("src/router/index.ts:legacy FileVersionManagement component import"); +} + +if (!legacyFileVersionRoute.includes("component: DocumentList")) { + missing.push("src/router/index.ts:FileVersionManagement->DocumentList"); +} + +if (!router.includes('next({ name: "DocumentList", params: { trialId: studyStore.currentStudy.id }, replace: true });')) { + missing.push("src/router/index.ts:legacy file version canonical redirect"); +} + const adminProjectDetail = readFileSync("src/views/admin/ProjectDetail.vue", "utf8"); const requiredAdminProjectDetailClasses = [ "ctms-page-shell", diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 725be312..b073da28 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -58,7 +58,7 @@ fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result, )?, &PredefinedMenuItem::fullscreen(handle, None)?, ], @@ -73,14 +73,14 @@ fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result, )?, &MenuItem::with_id( handle, "ctms.desktop.forward", "前进", true, - Some("CmdOrCtrl+]"), + None::<&str>, )?, ], )?, diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index e99bae7f..cf1a27e0 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -5,7 +5,7 @@ @@ -269,26 +269,87 @@ -
+
- - +
+ + +
+ + + + +
@@ -415,13 +476,17 @@ import { } from "../session/desktopActivityCenter"; import { DESKTOP_SERVER_URL_CHANGED_EVENT, + DESKTOP_SHORTCUTS_CHANGED_EVENT, clearLegacyDesktopRouteHistoryPreference, + formatDesktopShortcut, getAppMetadata, getDesktopServerUrl, listenDesktopMenuCommand, readDesktopFavoriteRoutes, + readDesktopShortcutPreferences, toggleDesktopFavoriteRoute, type DesktopRoutePreference, + type DesktopShortcutPreferences, } from "../runtime"; import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh"; import { useDesktopShortcuts } from "../composables/useDesktopShortcuts"; @@ -438,6 +503,14 @@ import { type LayoutNavigationIcon, type LayoutNavigationItem, } from "./layout/navigation"; +import { + createWorkspaceTabHistory, + currentWorkspaceTabPath, + moveWorkspaceTabHistory, + recordWorkspaceTabPath, + replaceWorkspaceTabPath, + type WorkspaceTabHistory, +} from "./layout/workspaceTabHistory"; const auth = useAuthStore(); const study = useStudyStore(); @@ -445,6 +518,10 @@ const router = useRouter(); const route = useRoute(); const desktopMetadata = getAppMetadata(); const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12; +const DESKTOP_WORKSPACE_TAB_MIN_WIDTH = 130; +const DESKTOP_WORKSPACE_TAB_GAP = 6; +const DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING = 20; +const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106; const DESKTOP_ADMIN_SECTION_LABEL = "系统管理"; const isAdmin = computed(() => !!auth.user?.is_admin); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); @@ -453,6 +530,7 @@ const canAccessProjectPath = (path: string) => hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value); const hasAnyProjectModuleAccess = computed(() => projectRouteLandingPaths.some((path) => canAccessProjectPath(path))); const isAdminContext = computed(() => route.path.startsWith("/admin")); +const showSidebarProjectChooser = computed(() => !study.currentStudy && !isAdminContext.value); const loggingOut = ref(false); const commandPaletteVisible = ref(false); const desktopPreferencesVisible = ref(false); @@ -462,9 +540,15 @@ const desktopServerUrl = ref(getDesktopServerUrl()); const desktopUpdateStatus = ref(getDesktopUpdateStatus()); const desktopActivities = ref(getDesktopActivities()); const desktopFavoriteRoutes = ref(readDesktopFavoriteRoutes()); +const desktopShortcutPreferences = ref(readDesktopShortcutPreferences()); const workspaceTabs = ref([]); +const workspaceTabHistories = ref>({}); +const workspaceTabsElement = ref(null); +const workspaceTabsAvailableWidth = ref(0); +const workspaceTabsOverflowOpen = ref(false); const draggingWorkspaceTabPath = ref(""); const lastClosedWorkspaceTab = ref(null); +const lastClosedWorkspaceTabHistory = ref(null); const workspaceTabMenu = ref<{ visible: boolean; x: number; @@ -477,11 +561,13 @@ const workspaceTabMenu = ref<{ tab: null, }); const expandedNavigationGroups = ref>(new Set()); +const collapsedNavigationGroups = ref>(new Set()); const headerRemindersLoading = ref(false); const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 }); let desktopMenuUnlisten: (() => void) | undefined; let desktopUpdateStatusUnlisten: (() => void) | undefined; let desktopActivityUnlisten: (() => void) | undefined; +let workspaceTabsResizeObserver: ResizeObserver | undefined; const iconComponentMap = { audit: Document, @@ -507,17 +593,23 @@ const activeMenu = computed(() => getActiveLayoutPath(route.path)); const navigationGroupKey = (item: LayoutNavigationItem) => `${item.group}:${item.label}:${item.path}`; const isNavigationGroupActive = (item: LayoutNavigationItem) => Boolean(item.children?.some((child) => activeMenu.value === child.path)); -const isNavigationGroupExpanded = (item: LayoutNavigationItem) => - isNavigationGroupActive(item) || expandedNavigationGroups.value.has(navigationGroupKey(item)); +const isNavigationGroupExpanded = (item: LayoutNavigationItem) => { + const key = navigationGroupKey(item); + return expandedNavigationGroups.value.has(key) || (isNavigationGroupActive(item) && !collapsedNavigationGroups.value.has(key)); +}; const toggleNavigationGroup = (item: LayoutNavigationItem) => { const key = navigationGroupKey(item); - const next = new Set(expandedNavigationGroups.value); - if (next.has(key)) { - next.delete(key); + const nextExpanded = new Set(expandedNavigationGroups.value); + const nextCollapsed = new Set(collapsedNavigationGroups.value); + if (isNavigationGroupExpanded(item)) { + nextExpanded.delete(key); + nextCollapsed.add(key); } else { - next.add(key); + nextExpanded.add(key); + nextCollapsed.delete(key); } - expandedNavigationGroups.value = next; + expandedNavigationGroups.value = nextExpanded; + collapsedNavigationGroups.value = nextCollapsed; }; const adminNavigationItems = computed(() => study.currentStudy @@ -540,6 +632,10 @@ const projectDesktopNavigationItems = computed(() => flattenLayoutNavigationItem const desktopNavigationItems = computed(() => [...adminDesktopNavigationItems.value, ...projectDesktopNavigationItems.value], ); +const workspaceTabFallbackPath = computed(() => { + if (isAdminContext.value) return adminDesktopNavigationItems.value[0]?.path || "/admin/projects"; + return study.currentStudy ? "/project/overview" : "/desktop/project-entry"; +}); const userDisplayName = computed( () => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback, @@ -595,6 +691,35 @@ const currentDesktopRoutePreference = computed(() => { if (!item) return null; return { path: item.path, title: item.label, group: item.group }; }); +const visibleWorkspaceTabCapacity = computed(() => { + const tabCount = workspaceTabs.value.length; + const availableWidth = workspaceTabsAvailableWidth.value; + if (!tabCount || availableWidth <= 0) return tabCount; + const maxWithoutOverflow = Math.max( + 1, + Math.floor((availableWidth + DESKTOP_WORKSPACE_TAB_GAP) / (DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP)), + ); + if (tabCount <= maxWithoutOverflow) return tabCount; + return Math.max( + 1, + Math.floor( + (availableWidth - DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH) / + (DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP), + ), + ); +}); +const visibleWorkspaceTabs = computed(() => { + const capacity = visibleWorkspaceTabCapacity.value; + if (workspaceTabs.value.length <= capacity) return workspaceTabs.value; + const firstTabs = workspaceTabs.value.slice(0, capacity); + const activeTab = workspaceTabs.value.find((item) => item.path === activeMenu.value); + if (!activeTab || firstTabs.some((item) => item.path === activeTab.path)) return firstTabs; + return [...firstTabs.slice(0, -1), activeTab]; +}); +const overflowWorkspaceTabs = computed(() => { + const visiblePaths = new Set(visibleWorkspaceTabs.value.map((item) => item.path)); + return workspaceTabs.value.filter((item) => !visiblePaths.has(item.path)); +}); const currentRouteFavorited = computed(() => { const current = currentDesktopRoutePreference.value; return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path)); @@ -673,11 +798,64 @@ const addWorkspaceTab = (routePreference: Pick { + workspaceTabHistories.value = { ...workspaceTabHistories.value, [tabPath]: history }; +}; + +const removeWorkspaceTabHistory = (tabPath: string) => { + if (!workspaceTabHistories.value[tabPath]) return; + const next = { ...workspaceTabHistories.value }; + delete next[tabPath]; + workspaceTabHistories.value = next; +}; + +const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => { + setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath)); +}; + +const replaceWorkspaceTabRoute = (tabPath: string, fullPath: string) => { + setWorkspaceTabHistory(tabPath, replaceWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath)); +}; + +const workspaceTabDestination = (tabPath: string) => + currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath); + const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath; const navigateWorkspaceTab = (path: string) => { if (activeMenu.value === path) return; - void router.push(path); + void router.push(workspaceTabDestination(path)); +}; + +const navigateCurrentWorkspaceTabHistory = (offset: -1 | 1) => { + const tabPath = activeMenu.value; + const movement = moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset); + if (!movement) return; + setWorkspaceTabHistory(tabPath, movement.history); + void router.push(movement.path); +}; + +const navigateOverflowWorkspaceTab = (path: string) => { + navigateWorkspaceTab(path); +}; + +const updateWorkspaceTabsAvailableWidth = () => { + const element = workspaceTabsElement.value; + workspaceTabsAvailableWidth.value = element + ? Math.max(0, element.clientWidth - DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING) + : 0; +}; + +const observeWorkspaceTabsElement = (element: HTMLElement | null) => { + workspaceTabsResizeObserver?.disconnect(); + if (!element) { + workspaceTabsAvailableWidth.value = 0; + return; + } + updateWorkspaceTabsAvailableWidth(); + if (typeof ResizeObserver === "undefined") return; + workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth); + workspaceTabsResizeObserver.observe(element); }; const closeWorkspaceTabMenu = () => { @@ -734,17 +912,21 @@ const dropWorkspaceTab = (targetPath: string, event: DragEvent) => { const closeWorkspaceTab = (path: string) => { const index = workspaceTabs.value.findIndex((tab) => tab.path === path); - if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index]; + if (index >= 0) { + lastClosedWorkspaceTab.value = workspaceTabs.value[index]; + lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path); + } workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path); + removeWorkspaceTabHistory(path); if (activeMenu.value !== path) return; const next = workspaceTabs.value[index] || workspaceTabs.value[index - 1] || desktopFavoriteRoutes.value.find((item) => item.path !== path); if (next) { - router.push(next.path); + router.push(workspaceTabDestination(next.path)); } else { - router.push(study.currentStudy ? "/project/overview" : "/desktop/project-entry"); + router.push(workspaceTabFallbackPath.value); } }; @@ -757,7 +939,9 @@ const closeOtherWorkspaceTabs = (path: string) => { const target = workspaceTabs.value.find((tab) => tab.path === path); if (!target) return; workspaceTabs.value = [target]; - if (activeMenu.value !== path) void router.push(path); + const targetHistory = workspaceTabHistories.value[path]; + workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {}; + if (activeMenu.value !== path) void router.push(workspaceTabDestination(path)); closeWorkspaceTabMenu(); }; @@ -765,8 +949,12 @@ const restoreLastClosedWorkspaceTab = () => { const tab = lastClosedWorkspaceTab.value; if (!tab) return; addWorkspaceTab(tab); - void router.push(tab.path); + if (lastClosedWorkspaceTabHistory.value) { + setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value); + } + void router.push(workspaceTabDestination(tab.path)); lastClosedWorkspaceTab.value = null; + lastClosedWorkspaceTabHistory.value = null; closeWorkspaceTabMenu(); }; @@ -774,6 +962,11 @@ const updateDesktopServerUrl = () => { desktopServerUrl.value = getDesktopServerUrl(); }; +const updateDesktopShortcutPreferences = (event?: Event) => { + desktopShortcutPreferences.value = + (event as CustomEvent | undefined)?.detail || readDesktopShortcutPreferences(); +}; + const openCommandPalette = () => { commandPaletteVisible.value = true; }; @@ -785,7 +978,9 @@ const openDesktopPreferences = () => { const openDesktopProjectEntry = async () => { study.clearCurrentStudy(); workspaceTabs.value = []; + workspaceTabHistories.value = {}; lastClosedWorkspaceTab.value = null; + lastClosedWorkspaceTabHistory.value = null; await router.push("/desktop/project-entry"); }; @@ -819,11 +1014,11 @@ const handleDesktopMenuCommand = (command: string) => { return; } if (command === "ctms.desktop.back") { - router.back(); + navigateCurrentWorkspaceTabHistory(-1); return; } if (command === "ctms.desktop.forward") { - router.forward(); + navigateCurrentWorkspaceTabHistory(1); } }; @@ -847,11 +1042,27 @@ const desktopCommands = computed(() => { run: () => navigateWorkspaceTab(item.path), })); return [ + { + id: "desktop:tab-back", + title: "当前标签后退", + group: "标签页", + shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.back), + visible: true, + run: () => navigateCurrentWorkspaceTabHistory(-1), + }, + { + id: "desktop:tab-forward", + title: "当前标签前进", + group: "标签页", + shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.forward), + visible: true, + run: () => navigateCurrentWorkspaceTabHistory(1), + }, { id: "desktop:refresh", title: "刷新当前视图", group: "桌面", - shortcut: "⌘R", + shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh), visible: true, run: refreshCurrentDesktopView, }, @@ -1009,12 +1220,20 @@ const beforeProfileDialogClose = async (done: () => void) => { }; watch( - () => route.path, - () => { + () => route.fullPath, + (fullPath, previousFullPath) => { study.setViewContext(null); const current = currentDesktopRoutePreference.value; if (current) { addWorkspaceTab(current); + const previousTabPath = previousFullPath ? getActiveLayoutPath(router.resolve(previousFullPath).path) : ""; + const replacesCurrentTabEntry = + previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced); + if (replacesCurrentTabEntry) { + replaceWorkspaceTabRoute(current.path, fullPath); + } else { + recordWorkspaceTabRoute(current.path, fullPath); + } refreshDesktopRoutePreferences(); } }, @@ -1033,12 +1252,17 @@ watch( () => refreshDesktopRoutePreferences(), ); +watch(workspaceTabsElement, (element) => observeWorkspaceTabsElement(element), { flush: "post" }); + onMounted(async () => { window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); + window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences); + window.addEventListener("resize", updateWorkspaceTabsAvailableWidth); clearLegacyDesktopRouteHistoryPreference(); const current = currentDesktopRoutePreference.value; if (current) { addWorkspaceTab(current); + recordWorkspaceTabRoute(current.path, route.fullPath); } refreshDesktopRoutePreferences(); desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined); @@ -1065,6 +1289,9 @@ 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?.(); @@ -1089,8 +1316,8 @@ useDesktopShortcuts( } return false; }, - navigateBack: () => router.back(), - navigateForward: () => router.forward(), + navigateBack: () => navigateCurrentWorkspaceTabHistory(-1), + navigateForward: () => navigateCurrentWorkspaceTabHistory(1), refreshCurrentView: refreshCurrentDesktopView, }, ); @@ -1772,22 +1999,31 @@ useDesktopShortcuts( .workspace-tabs { display: flex; + align-items: center; min-width: 0; gap: 6px; padding: 7px 10px; - overflow-x: auto; + overflow: hidden; border-bottom: 1px solid #dfe7f0; background: #f4f7fa; } +.workspace-tabs-list { + display: grid; + flex: 1 1 auto; + min-width: 0; + gap: 6px; + overflow: hidden; +} + .workspace-tab { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; - min-width: 150px; - max-width: 238px; + min-width: 0; height: 30px; + box-sizing: border-box; padding: 0 5px 0 10px; border: 1px solid #d2dce8; border-radius: 8px; @@ -1848,6 +2084,172 @@ useDesktopShortcuts( color: #142033; } +.workspace-tabs-overflow { + display: flex; + flex: 0 0 106px; + min-width: 0; +} + +.workspace-tabs-overflow-trigger { + appearance: none; + display: flex; + align-items: center; + justify-content: center; + width: 106px; + height: 30px; + gap: 6px; + padding: 0 9px 0 11px; + border: 1px solid #c5d1df; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + color: #3d5068; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.workspace-tabs-overflow-trigger:hover, +.workspace-tabs-overflow-trigger:focus-visible, +.workspace-tabs-overflow-trigger.is-open { + border-color: #aebfd1; + background: #eaf1f8; + color: #183756; + outline: none; +} + +.workspace-tabs-overflow-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + background: #e3ebf4; + color: #31506f; + font-size: 10px; + line-height: 1; +} + +:global(.workspace-tabs-overflow-popper.el-popper) { + overflow: hidden; + padding: 0; + border: 1px solid #d6e0eb; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16), 0 2px 8px rgba(15, 23, 42, 0.08); +} + +:global(.workspace-tabs-overflow-popper .el-popper__arrow::before) { + border-color: #d6e0eb; + background: #ffffff; +} + +.workspace-tabs-overflow-menu { + width: 252px; +} + +.workspace-tabs-overflow-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 38px; + padding: 0 12px; + border-bottom: 1px solid #e5ebf2; + color: #2d4058; + font-size: 12px; + font-weight: 800; +} + +.workspace-tabs-overflow-header span:last-child { + color: #8292a6; + font-size: 11px; + font-weight: 700; +} + +:global(.workspace-tabs-overflow-popper .el-dropdown-menu) { + min-width: 0; + max-height: min(360px, calc(100vh - 120px)); + padding: 6px; + border: 0; + background: transparent; + overflow-y: auto; +} + +:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item) { + display: flex; + min-height: 38px; + gap: 9px; + padding: 0 6px 0 10px; + border-radius: 7px; + color: #33485f; + font-size: 12px; + font-weight: 700; +} + +:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item:hover), +:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item:focus) { + background: #f0f5fa; + color: #183756; +} + +:global(.workspace-tabs-overflow-popper .el-dropdown-menu__item.is-current) { + background: #eaf1f8; + color: #183756; +} + +.workspace-tabs-overflow-label { + display: block; + flex: 1 1 auto; + min-width: 0; +} + +.workspace-tabs-overflow-item-mark { + flex: 0 0 7px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #9fb1c4; +} + +.workspace-tabs-overflow-label > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-tabs-overflow-close { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #7b8da3; + cursor: pointer; + font: inherit; + font-size: 16px; + opacity: 0.62; +} + +.workspace-tabs-overflow-close:hover, +.workspace-tabs-overflow-close:focus-visible { + background: rgba(15, 23, 42, 0.08); + color: #142033; + opacity: 1; + outline: none; +} + .workspace-tab-context-menu { position: fixed; z-index: 2300; @@ -2007,7 +2409,8 @@ useDesktopShortcuts( :global([data-ctms-theme="dark"] .update-notice), :global([data-ctms-theme="dark"] .connection-button), :global([data-ctms-theme="dark"] .account-button), -:global([data-ctms-theme="dark"] .workspace-tab) { +:global([data-ctms-theme="dark"] .workspace-tab), +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-trigger) { border-color: #334155; background: #172033; color: #dbe5f1; @@ -2047,6 +2450,59 @@ useDesktopShortcuts( background: #111827; } +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-count) { + background: #243247; + color: #bfdbfe; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu) { + background: transparent; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper.el-popper) { + border-color: #334155; + background: #172033; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-popper__arrow::before) { + border-color: #334155; + background: #172033; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-header) { + border-bottom-color: #334155; + color: #e5edf7; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-header span:last-child) { + color: #94a3b8; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item) { + color: #dbe5f1; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item:hover), +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item:focus), +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item.is-current) { + background: #243247; + color: #bfdbfe; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-item-mark) { + background: #6f87a1; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close) { + color: #94a3b8; +} + +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close:hover), +:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close:focus-visible) { + background: rgba(226, 232, 240, 0.1); + color: #f8fafc; +} + :global([data-ctms-theme="dark"] .desktop-content) { background: #0f172a; } diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index f1c776a1..96cc53f0 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -8,6 +8,7 @@ const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./Desktop const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8"); const readTauriConfigSource = () => readFileSync(resolve(__dirname, "../../src-tauri/tauri.conf.json"), "utf8"); +const readTauriLibSource = () => readFileSync(resolve(__dirname, "../../src-tauri/src/lib.rs"), "utf8"); const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8"); const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8"); const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8"); @@ -92,7 +93,7 @@ describe("desktop layout shell", () => { expect(source).toContain("const adminDesktopNavigationItems"); expect(source).toContain("const projectDesktopNavigationItems"); expect(source).toContain('id: "desktop:refresh"'); - expect(source).toContain('shortcut: "⌘R"'); + expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)"); expect(source).toContain('command === "ctms.desktop.back"'); expect(source).toContain('command === "ctms.desktop.forward"'); expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject"); @@ -127,12 +128,13 @@ describe("desktop layout shell", () => { expect(layout).toContain('command="projectEntry"'); expect(layout).toContain("projectEntryMenuLabel"); expect(layout).toContain("openDesktopProjectEntry"); + expect(layout).toContain("const showSidebarProjectChooser = computed(() => !study.currentStudy && !isAdminContext.value);"); expect(layout).toContain("padding: 10px 8px 24px;"); expect(layout).toContain("margin-top: 12px;"); expect(layout).toContain("font-size: 14px;"); expect(sidebarHeadTemplate).toContain(' +
+ + +
@@ -189,24 +219,31 @@ import { computed, onBeforeUnmount, onMounted, ref, type Component } from "vue"; import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; -import { Bell, Close, Download, InfoFilled, Link, Monitor, Moon, Sunny } from "@element-plus/icons-vue"; +import { Bell, Close, Download, InfoFilled, Key, Link, Monitor, Moon, Sunny } from "@element-plus/icons-vue"; import { getDesktopNotificationSubscription, setDesktopNotificationSubscription, } from "../api/desktopNotifications"; import { clientRuntime, + desktopShortcutFromKeyboardEvent, + formatDesktopShortcut, getAppMetadata, getDesktopServerUrl, getNotificationPermission, isDesktopUpdaterAvailable, normalizeDesktopServerUrl, + readDesktopShortcutPreferences, readDesktopThemePreference, requestNotificationPermission, + resetDesktopShortcutPreferences, + setDesktopShortcutPreference, setDesktopServerUrl, setDesktopThemePreference, showSystemNotificationProbe, type DesktopThemePreference, + type DesktopShortcutAction, + type DesktopShortcutPreferences, type NotificationPermissionState, } from "../runtime"; import { @@ -227,7 +264,7 @@ const emit = defineEmits<{ const router = useRouter(); const auth = useAuthStore(); const studyStore = useStudyStore(); -type PreferenceSectionId = "connection" | "appearance" | "notifications" | "updates" | "diagnostics"; +type PreferenceSectionId = "connection" | "appearance" | "shortcuts" | "notifications" | "updates" | "diagnostics"; const preferenceSections: Array<{ id: PreferenceSectionId; @@ -237,6 +274,7 @@ const preferenceSections: Array<{ }> = [ { id: "connection", label: "连接", description: "服务器入口与在线状态", icon: Link }, { id: "appearance", label: "外观", description: "主题与界面偏好", icon: Monitor }, + { id: "shortcuts", label: "快捷键", description: "当前标签的导航与刷新操作", icon: Key }, { id: "notifications", label: "通知", description: "系统通知权限与投递状态", icon: Bell }, { id: "updates", label: "更新", description: "桌面端签名更新与安装状态", icon: Download }, { id: "diagnostics", label: "诊断信息", description: "客户端版本、平台与能力", icon: InfoFilled }, @@ -267,6 +305,9 @@ const desktopNotificationLoading = ref(false); const desktopNotificationTestLoading = ref(false); const desktopUpdateChecking = ref(false); const desktopTheme = ref(readDesktopThemePreference()); +const desktopShortcuts = ref(readDesktopShortcutPreferences()); +const capturingShortcutAction = ref(null); +const shortcutCaptureElement = ref(null); const notificationPermission = ref("unsupported"); const desktopUpdateStatus = ref(getDesktopUpdateStatus()); let updateStatusUnlisten: (() => void) | undefined; @@ -275,6 +316,15 @@ const themeOptions = [ { value: "light" as const, label: "明亮", icon: Sunny }, { value: "dark" as const, label: "暗黑", icon: Moon }, ]; +const shortcutDefinitions: Array<{ + action: DesktopShortcutAction; + label: string; + description: string; +}> = [ + { action: "back", label: "后退", description: "返回当前标签的上一页面" }, + { action: "forward", label: "前进", description: "前往当前标签的下一页面" }, + { action: "refresh", label: "刷新", description: "重新加载当前标签的数据" }, +]; const HEALTH_TIMEOUT_MS = 10_000; const clientMetadataRows = computed(() => [ @@ -645,6 +695,59 @@ const setTheme = (theme: DesktopThemePreference) => { desktopTheme.value = setDesktopThemePreference(theme); }; +const clearDesktopShortcutCapture = () => { + capturingShortcutAction.value = null; + shortcutCaptureElement.value = null; +}; + +const startDesktopShortcutCapture = (event: MouseEvent, action: DesktopShortcutAction) => { + event.preventDefault(); + event.stopPropagation(); + capturingShortcutAction.value = action; + shortcutCaptureElement.value = event.currentTarget as HTMLElement | null; + shortcutCaptureElement.value?.focus(); +}; + +const captureDesktopShortcut = (event: KeyboardEvent) => { + const action = capturingShortcutAction.value; + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.key === "Escape") { + clearDesktopShortcutCapture(); + return; + } + const shortcut = desktopShortcutFromKeyboardEvent(event); + if (!shortcut) { + if (!["Meta", "Control", "Shift", "Alt"].includes(event.key)) { + ElMessage.warning("请按下包含 Command(或 Ctrl)的组合键"); + } + return; + } + const result = setDesktopShortcutPreference(action, shortcut); + if (!result.ok) { + ElMessage.warning(result.reason); + return; + } + desktopShortcuts.value = result.shortcuts; + ElMessage.success("快捷键已更新"); + clearDesktopShortcutCapture(); +}; + +const cancelDesktopShortcutCaptureOnPointerDown = (event: PointerEvent) => { + if (!capturingShortcutAction.value) return; + const target = event.target; + if (target instanceof Node && shortcutCaptureElement.value?.contains(target)) return; + clearDesktopShortcutCapture(); +}; + +const resetDesktopShortcuts = () => { + desktopShortcuts.value = resetDesktopShortcutPreferences(); + clearDesktopShortcutCapture(); + ElMessage.success("快捷键已恢复默认"); +}; + const copyClientMetadata = async () => { const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n"); if (!navigator.clipboard?.writeText) { @@ -656,6 +759,8 @@ const copyClientMetadata = async () => { }; onMounted(() => { + window.addEventListener("keydown", captureDesktopShortcut, { capture: true }); + window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true }); updateStatusUnlisten = listenDesktopUpdateStatus((status) => { desktopUpdateStatus.value = status; }); @@ -663,6 +768,8 @@ onMounted(() => { }); onBeforeUnmount(() => { + window.removeEventListener("keydown", captureDesktopShortcut, { capture: true }); + window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true }); clearConnectionPendingTimer(); updateStatusUnlisten?.(); }); @@ -983,6 +1090,7 @@ h3 { .preferences-panel-enter-active .connection-alert, .preferences-panel-enter-active .connection-diagnostic, .preferences-panel-enter-active .preference-row, +.preferences-panel-enter-active .shortcut-settings, .preferences-panel-enter-active .metadata-panel { animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; } @@ -1004,6 +1112,7 @@ h3 { * ===================================================== */ .server-overview, .preference-row, +.shortcut-settings, .metadata-panel { border: 1px solid var(--pref-border-card); border-radius: 10px; @@ -1178,6 +1287,73 @@ h3 { flex-shrink: 0; } +/* ===================================================== + * 快捷键 + * ===================================================== */ +.shortcut-settings { + overflow: hidden; +} + +.shortcut-settings-head, +.shortcut-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + padding: 14px 16px; +} + +.shortcut-settings-head { + border-bottom: 1px solid var(--pref-border); + background: #f8fafc; +} + +.shortcut-list { + display: grid; +} + +.shortcut-row + .shortcut-row { + border-top: 1px solid var(--pref-border); +} + +.shortcut-recorder { + appearance: none; + display: grid; + justify-items: end; + min-width: 126px; + gap: 3px; + padding: 7px 9px; + border: 1px solid var(--pref-border-card); + border-radius: 8px; + background: #f8fafc; + color: var(--pref-text-secondary); + cursor: pointer; + font: inherit; + transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease; +} + +.shortcut-recorder:hover, +.shortcut-recorder:focus-visible, +.shortcut-recorder.capturing { + border-color: var(--pref-accent); + background: var(--pref-accent-light); + box-shadow: 0 0 0 3px rgba(30, 77, 130, 0.1); + outline: none; +} + +.shortcut-recorder kbd { + color: var(--pref-text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; + font-weight: 750; +} + +.shortcut-recorder span { + color: var(--pref-text-muted); + font-size: 10px; + font-weight: 600; +} + /* ===================================================== * 主题切换器 * ===================================================== */ @@ -1349,6 +1525,7 @@ code { :global([data-ctms-theme="dark"] .desktop-preferences .server-config-form), :global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic), :global([data-ctms-theme="dark"] .desktop-preferences .preference-row), +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings), :global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) { border-color: var(--pref-border-card); background: var(--pref-bg-card); @@ -1388,6 +1565,17 @@ code { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings-head), +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder) { + background: #101827; +} + +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder:hover), +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder:focus-visible), +:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder.capturing) { + background: var(--pref-accent-light); +} + :global([data-ctms-theme="dark"] .desktop-preferences .server-status-dot.connected) { box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.2); } diff --git a/frontend/src/views/DesktopProjectEntry.vue b/frontend/src/views/DesktopProjectEntry.vue index 7fb585d6..8e07790e 100644 --- a/frontend/src/views/DesktopProjectEntry.vue +++ b/frontend/src/views/DesktopProjectEntry.vue @@ -279,9 +279,9 @@ onMounted(() => { flex-direction: column; justify-content: space-between; padding: 40px 28px; - background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); - border-right: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: 10px 0 40px rgba(15, 23, 42, 0.15); + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); + border-right: 1px solid #e2e8f0; + box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02); } /* 顶部品牌与Logo */ @@ -298,19 +298,19 @@ onMounted(() => { justify-content: center; width: 44px; height: 44px; - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid rgba(59, 130, 246, 0.1); border-radius: 10px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: #ffffff; font-size: 13.5px; font-weight: 900; letter-spacing: 0.05em; - box-shadow: 0 8px 20px rgba(37, 99, 235, 0.25); + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18); } .brand-text h1 { margin: 1px 0 0; - color: #ffffff; + color: #0f172a; font-size: 17.5px; font-weight: 800; letter-spacing: -0.02em; @@ -318,14 +318,14 @@ onMounted(() => { .brand-kicker { display: block; - color: rgba(147, 197, 253, 0.65); + color: #2563eb; font-size: 9.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } -/* 个人信息卡片(暗色拟物化) */ +/* 个人信息卡片(浅色拟物化) */ .sidebar-account { display: flex; flex-direction: column; @@ -333,9 +333,9 @@ onMounted(() => { text-align: center; padding: 24px 20px; border-radius: 16px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + background: #ffffff; + border: 1px solid rgba(226, 232, 240, 0.9); + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.02); } .avatar-wrapper { @@ -354,7 +354,7 @@ onMounted(() => { color: #ffffff; font-size: 21px; font-weight: 800; - box-shadow: 0 8px 24px rgba(29, 78, 216, 0.3); + box-shadow: 0 8px 24px rgba(29, 78, 216, 0.15); } .status-dot-active { @@ -363,7 +363,7 @@ onMounted(() => { right: -2px; width: 12px; height: 12px; - border: 2.5px solid #1e293b; + border: 2.5px solid #ffffff; border-radius: 50%; background: #10b981; } @@ -377,7 +377,7 @@ onMounted(() => { } .account-details small { - color: rgba(255, 255, 255, 0.4); + color: #64748b; font-size: 9.5px; font-weight: 800; letter-spacing: 0.05em; @@ -385,16 +385,15 @@ onMounted(() => { } .account-details strong { - color: #ffffff; + color: #0f172a; font-size: 15px; font-weight: 700; } .account-email { - color: #94a3b8; + color: #475569; font-size: 11.5px; word-break: break-all; - opacity: 0.8; } .logout-btn { @@ -405,10 +404,10 @@ onMounted(() => { gap: 6px; width: 100%; height: 36px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid #cbd5e1; border-radius: 10px; background: transparent; - color: rgba(255, 255, 255, 0.7); + color: #475569; font-size: 12.5px; font-weight: 700; cursor: pointer; @@ -416,12 +415,12 @@ onMounted(() => { } .logout-btn:hover { - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.2); - color: #f87171; + background: rgba(239, 68, 68, 0.05); + border-color: rgba(239, 68, 68, 0.15); + color: #ef4444; } -/* 常驻管理后台卡片(精致深黑流光) */ +/* 常驻管理后台卡片(精致浅色流光) */ .sidebar-admin { width: 100%; } @@ -433,13 +432,13 @@ onMounted(() => { width: 100%; overflow: hidden; padding: 18px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(59, 130, 246, 0.12); border-radius: 12px; - background: linear-gradient(135deg, #090d16 0%, #111827 100%); - color: #ffffff; + background: linear-gradient(135deg, #ffffff 0%, #f0f7ff 100%); + color: #0f172a; cursor: pointer; text-align: left; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.04); transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); } @@ -450,15 +449,15 @@ onMounted(() => { width: 120px; height: 120px; border-radius: 50%; - background: rgba(59, 130, 246, 0.15); + background: rgba(59, 130, 246, 0.06); filter: blur(15px); pointer-events: none; } .admin-action-card:hover { transform: translateY(-2px); - border-color: rgba(59, 130, 246, 0.3); - box-shadow: 0 16px 30px rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.28); + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.08); } .admin-card-head { @@ -475,8 +474,8 @@ onMounted(() => { width: 32px; height: 32px; border-radius: 8px; - background: rgba(59, 130, 246, 0.15); - color: #3b82f6; + background: rgba(59, 130, 246, 0.08); + color: #2563eb; font-size: 16px; } @@ -486,7 +485,7 @@ onMounted(() => { } .admin-kicker { - color: rgba(59, 130, 246, 0.7); + color: #2563eb; font-size: 8.5px; font-weight: 800; letter-spacing: 0.05em; @@ -494,13 +493,14 @@ onMounted(() => { } .admin-text-box strong { + color: #0f172a; font-size: 13.5px; font-weight: 700; } .admin-desc { margin: 0 0 12px; - color: #64748b; + color: #475569; font-size: 11px; line-height: 1.4; } @@ -509,7 +509,7 @@ onMounted(() => { display: flex; align-items: center; justify-content: space-between; - color: #3b82f6; + color: #2563eb; font-size: 11.5px; font-weight: 700; } diff --git a/frontend/src/views/ia/FileVersionManagement.vue b/frontend/src/views/ia/FileVersionManagement.vue deleted file mode 100644 index 1517919d..00000000 --- a/frontend/src/views/ia/FileVersionManagement.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/frontend/src/views/ia/MaterialEquipment.test.ts b/frontend/src/views/ia/MaterialEquipment.test.ts index 688f416f..98a6d627 100644 --- a/frontend/src/views/ia/MaterialEquipment.test.ts +++ b/frontend/src/views/ia/MaterialEquipment.test.ts @@ -38,4 +38,18 @@ describe("MaterialEquipment desktop list workflow", () => { expect(source).toContain("if (!isDesktop || !row?.id) return;"); expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"'); }); + + it("aligns the desktop preview pane with the drug shipment preview layout", () => { + const source = readSource(); + + expect(source).toContain("grid-template-columns: minmax(0, 1fr) 316px;"); + expect(source).toContain(".equipment-preview-pane"); + expect(source).toContain("background: #f8fafc;"); + expect(source).toContain("grid-template-columns: 78px minmax(0, 1fr);"); + expect(source).toContain("gap: 10px 12px;"); + expect(source).toContain("font-weight: 650;"); + expect(source).not.toContain("float: left;"); + expect(source).not.toContain("border-radius: 10px;"); + expect(source).not.toContain("box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);"); + }); }); diff --git a/frontend/src/views/ia/MaterialEquipment.vue b/frontend/src/views/ia/MaterialEquipment.vue index ccc43c76..e5f6499b 100644 --- a/frontend/src/views/ia/MaterialEquipment.vue +++ b/frontend/src/views/ia/MaterialEquipment.vue @@ -117,9 +117,6 @@
-
- -
选择一行查看详情摘要
@@ -725,7 +722,7 @@ onBeforeUnmount(() => { .equipment-workbench.is-desktop { display: grid; - grid-template-columns: minmax(0, 1fr) 304px; + grid-template-columns: minmax(0, 1fr) 316px; /* 桌面端:不限定固定高度,由父容器 flex 撑满 */ flex: 1; min-height: 0; @@ -753,10 +750,9 @@ onBeforeUnmount(() => { min-width: 0; min-height: 0; flex-direction: column; - gap: 14px; + gap: 16px; padding: 16px; - background: linear-gradient(180deg, #f5f8ff 0%, #eef3fb 100%); - border-left: 1px solid rgba(79, 126, 207, 0.1); + background: #f8fafc; } .equipment-table { @@ -852,70 +848,46 @@ onBeforeUnmount(() => { align-items: flex-start; justify-content: space-between; gap: 12px; - padding-bottom: 14px; - border-bottom: 1px solid rgba(79, 126, 207, 0.12); } .preview-kicker { - display: inline-flex; - align-items: center; - gap: 5px; - color: #4f7ecf; - font-size: 10px; + margin-bottom: 5px; + color: #6b7e95; + font-size: 11px; font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; - background: rgba(79, 126, 207, 0.1); - padding: 2px 8px; - border-radius: 20px; } .preview-title { - margin-top: 8px; - color: #0f172a; + display: -webkit-box; + overflow: hidden; + color: #142033; font-size: 17px; font-weight: 800; - word-break: break-word; - letter-spacing: -0.01em; - line-height: 1.3; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; } .preview-list { - display: block; + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 10px 12px; margin: 0; - border: 1px solid rgba(79, 126, 207, 0.1); - border-radius: 10px; - overflow: hidden; - background: #fff; - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05); } -/* dt/dd float 布局:dt 左浮动作为标签列,dd 跟随在右侧 */ .preview-list dt { - float: left; - clear: left; - width: 64px; - padding: 9px 6px 9px 14px; - color: #7a8ca8; - font-size: 11px; + color: #7b8da3; + font-size: 12px; font-weight: 700; - letter-spacing: 0.02em; - line-height: 1.5; } .preview-list dd { - margin-left: 0; - padding: 9px 14px 9px 78px; - border-bottom: 1px solid rgba(79, 126, 207, 0.07); - color: #1a2540; - font-size: 13px; - font-weight: 700; - overflow-wrap: anywhere; min-width: 0; -} - -.preview-list dd:last-child { - border-bottom: none; + margin: 0; + overflow-wrap: anywhere; + color: #223349; + font-size: 13px; + font-weight: 650; } .preview-actions { @@ -923,18 +895,16 @@ onBeforeUnmount(() => { flex-wrap: wrap; gap: 8px; margin-top: auto; - padding-top: 4px; } .preview-empty { - min-height: 320px; display: flex; - flex-direction: column; + min-height: 180px; align-items: center; justify-content: center; - gap: 12px; - color: #94a3b8; + color: #7b8da3; font-size: 13px; + font-weight: 700; text-align: center; } @@ -1163,4 +1133,41 @@ onBeforeUnmount(() => { width: 100%; } } + +:global([data-ctms-theme="dark"] .equipment-page--desktop .table-card) { + background: #172033; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-preview-pane) { + border-color: #26364a; + background: #111827; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-title), +:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dd) { + color: #f8fafc; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-kicker), +:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dt), +:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-empty) { + color: #94a3b8; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu) { + border-color: #334155; + background: #172033; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button) { + color: #dbe5f1; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button:hover:not(:disabled)) { + background: #243247; +} + +:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button.danger) { + color: #fca5a5; +}