feat(desktop): 收口桌面工作台视觉与活动反馈

This commit is contained in:
Cheng Zhou
2026-07-02 15:52:16 +08:00
parent 3a866accd9
commit 84d5daebab
19 changed files with 2027 additions and 222 deletions
@@ -71,6 +71,13 @@ const verifyTauriConfig = async () => {
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access."); assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180."); assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760."); assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
assert(mainWindow?.titleBarStyle === "Overlay", "Main desktop window must use the macOS overlay title bar.");
assert(mainWindow?.hiddenTitle === true, "Main desktop window must hide the native title text.");
assert(
mainWindow?.trafficLightPosition?.x === 16 && mainWindow?.trafficLightPosition?.y === 18,
"Main desktop window must keep traffic light controls at x=16 y=18.",
);
}; };
const verifyCapabilities = async () => { const verifyCapabilities = async () => {
@@ -118,6 +125,9 @@ const verifyCapabilities = async () => {
`${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`, `${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`,
); );
} }
if (identifier.startsWith("core:window:")) {
fail(`${file}: window permissions are not allowed; use Tauri overlay drag regions instead.`);
}
if (identifier.startsWith("opener:")) { if (identifier.startsWith("opener:")) {
assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`); assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`);
} }
+5 -1
View File
@@ -17,7 +17,11 @@
"width": 1440, "width": 1440,
"height": 900, "height": 900,
"minWidth": 1180, "minWidth": 1180,
"minHeight": 760 "minHeight": 760,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 16, "y": 18 }
} }
], ],
"security": { "security": {
@@ -24,9 +24,17 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events); const rows = formatAuditRows(events);
const csv = buildCsv(rows); const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFileWithFeedback({ await saveFileWithFeedback(
{
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`, suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8", mimeType: "text/csv;charset=utf-8",
data: blob, data: blob,
}); },
{
kind: "export",
title: "导出审计记录",
pendingDetail: "等待选择保存位置",
completedDetail: "导出文件已保存",
},
);
}; };
+325 -58
View File
@@ -4,8 +4,7 @@
<header class="sidebar-head"> <header class="sidebar-head">
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<div> <div>
<p class="sidebar-kicker">Workspace</p> <h1>{{ TEXT.common.appName }}</h1>
<h1>{{ TEXT.common.appName }} Desktop</h1>
</div> </div>
</div> </div>
@@ -64,7 +63,7 @@
</section> </section>
<section v-if="adminNavigationItems.length" class="nav-section"> <section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.admin }}</div> <div class="section-label">{{ DESKTOP_ADMIN_SECTION_LABEL }}</div>
<template v-for="item in adminNavigationItems" :key="item.path"> <template v-for="item in adminNavigationItems" :key="item.path">
<button <button
v-if="!item.children?.length" v-if="!item.children?.length"
@@ -149,20 +148,10 @@
<section class="desktop-main"> <section class="desktop-main">
<header class="desktop-toolbar"> <header class="desktop-toolbar">
<div class="toolbar-left"> <div class="toolbar-left" data-tauri-drag-region>
<div class="history-controls"> <div class="toolbar-context" data-tauri-drag-region>
<button class="icon-button" type="button" title="后退" @click="router.back()"> <div class="toolbar-title" data-tauri-drag-region>{{ desktopToolbarTitle }}</div>
<el-icon><ArrowLeft /></el-icon> <div class="toolbar-subtitle" data-tauri-drag-region>{{ desktopToolbarSubtitle }}</div>
</button>
<button class="icon-button" type="button" title="前进" @click="router.forward()">
<el-icon><ArrowRight /></el-icon>
</button>
<button class="icon-button" type="button" title="刷新当前视图" @click="refreshCurrentDesktopView">
<el-icon><Refresh /></el-icon>
</button>
</div>
<div class="breadcrumb-strip">
<span v-for="crumb in desktopBreadcrumbs" :key="crumb" class="breadcrumb-chip">{{ crumb }}</span>
</div> </div>
</div> </div>
@@ -172,6 +161,48 @@
<span>{{ desktopUpdateNoticeLabel }}</span> <span>{{ desktopUpdateNoticeLabel }}</span>
</button> </button>
<el-popover placement="bottom-end" width="340" trigger="click" popper-class="desktop-activity-popover">
<template #reference>
<button class="icon-button activity-button" type="button" title="活动">
<el-badge v-if="desktopActivityBadgeCount > 0" :value="desktopActivityBadgeValue" class="activity-badge">
<el-icon><Clock /></el-icon>
</el-badge>
<el-icon v-else><Clock /></el-icon>
</button>
</template>
<div class="activity-panel">
<div class="activity-head">
<div>
<div class="panel-title">活动</div>
<div class="panel-subtitle">上传下载导出与更新</div>
</div>
<button
v-if="hasFinishedDesktopActivities"
class="activity-clear"
type="button"
@click="clearFinishedDesktopActivities"
>
清除已结束
</button>
</div>
<div v-if="desktopActivities.length" class="activity-list">
<div v-for="item in desktopActivities" :key="item.id" class="activity-item" :class="`is-${item.status}`">
<span class="activity-mark" :class="`is-${item.status}`"></span>
<span class="activity-body">
<span class="activity-title">{{ item.title }}</span>
<span class="activity-detail">{{ item.detail || desktopActivityStatusLabel(item.status) }}</span>
</span>
<span class="activity-meta">
<span v-if="item.status === 'running' && item.progress !== null">{{ item.progress }}%</span>
<span v-else>{{ desktopActivityStatusLabel(item.status) }}</span>
<small>{{ formatDesktopActivityTime(item.updatedAt) }}</small>
</span>
</div>
</div>
<div v-else class="activity-empty">暂无活动</div>
</div>
</el-popover>
<button class="command-trigger" type="button" @click="openCommandPalette"> <button class="command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
<span>命令</span> <span>命令</span>
@@ -367,8 +398,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
ArrowDown, ArrowDown,
ArrowLeft,
ArrowRight,
Bell, Bell,
Box, Box,
Calendar, Calendar,
@@ -409,6 +438,13 @@ import {
listenDesktopUpdateStatus, listenDesktopUpdateStatus,
type DesktopUpdateStatusSnapshot, type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager"; } from "../session/desktopUpdateManager";
import {
clearFinishedDesktopActivities,
getDesktopActivities,
listenDesktopActivities,
type DesktopActivityItem,
type DesktopActivityStatus,
} from "../session/desktopActivityCenter";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
getAppMetadata, getAppMetadata,
@@ -442,6 +478,7 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const desktopMetadata = getAppMetadata(); const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12; const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin); const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM"); const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
@@ -456,6 +493,7 @@ const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false); const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl()); const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus()); const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const studies = ref<any[]>([]); const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes()); const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
@@ -478,6 +516,7 @@ const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 }); const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined; let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined; let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
const iconComponentMap = { const iconComponentMap = {
audit: Document, audit: Document,
@@ -555,6 +594,32 @@ const desktopUpdateNoticeLabel = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate; const pending = desktopUpdateStatus.value.pendingUpdate;
return pending ? `新版本 ${pending.version}` : ""; return pending ? `新版本 ${pending.version}` : "";
}); });
const desktopActivityBadgeCount = computed(() =>
desktopActivities.value.filter((item) => item.status === "running" || item.status === "failed").length
);
const desktopActivityBadgeValue = computed(() => (desktopActivityBadgeCount.value > 9 ? "9+" : desktopActivityBadgeCount.value));
const hasFinishedDesktopActivities = computed(() =>
desktopActivities.value.some((item) => item.status === "completed" || item.status === "cancelled" || item.status === "failed")
);
const desktopActivityStatusLabel = (status: DesktopActivityStatus) => {
switch (status) {
case "running":
return "进行中";
case "completed":
return "完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "";
}
};
const formatDesktopActivityTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const currentDesktopRoutePreference = computed(() => { const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value); const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
@@ -565,20 +630,26 @@ const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path)); return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
}); });
const desktopBreadcrumbs = computed(() => { const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (!current) return [String(route.meta?.title || "")].filter(Boolean); return current?.title || String(route.meta?.title || TEXT.common.appName);
});
const desktopToolbarSubtitle = computed(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return TEXT.common.appName;
const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path); const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isAdminRoute) { if (isAdminRoute) {
const section = current.group && current.group !== TEXT.menu.admin ? current.group : ""; const section = current.group && current.group !== TEXT.menu.admin ? current.group : "";
return [TEXT.menu.admin, section, current.title].filter(Boolean); return [DESKTOP_ADMIN_SECTION_LABEL, section].filter(Boolean).join(" / ");
} }
const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path); const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isProjectRoute) { if (isProjectRoute) {
const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : ""; const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : "";
return [TEXT.menu.currentProject, section, current.title].filter(Boolean); const projectName = study.currentStudy?.name || TEXT.menu.currentProject;
return [projectName, section].filter(Boolean).join(" / ");
} }
return [current.group, current.title].filter(Boolean); return current.group || TEXT.common.appName;
}); });
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae")); const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
@@ -1015,6 +1086,9 @@ onMounted(async () => {
desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => { desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status; desktopUpdateStatus.value = status;
}); });
desktopActivityUnlisten = listenDesktopActivities((items) => {
desktopActivities.value = items;
});
if (auth.token && !auth.user) { if (auth.token && !auth.user) {
try { try {
await auth.fetchMe(); await auth.fetchMe();
@@ -1031,6 +1105,7 @@ onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
desktopMenuUnlisten?.(); desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.(); desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
}); });
useDesktopShortcuts( useDesktopShortcuts(
@@ -1098,7 +1173,7 @@ useDesktopShortcuts(
.sidebar-head { .sidebar-head {
flex: 0 0 auto; flex: 0 0 auto;
padding: 18px 16px 14px; padding: 54px 16px 14px;
border-bottom: 1px solid #dfe7f0; border-bottom: 1px solid #dfe7f0;
} }
@@ -1109,14 +1184,6 @@ useDesktopShortcuts(
gap: 12px; gap: 12px;
} }
.sidebar-kicker {
margin: 0 0 4px;
color: #66778d;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.sidebar-head h1 { .sidebar-head h1 {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
@@ -1295,7 +1362,7 @@ useDesktopShortcuts(
.desktop-main { .desktop-main {
display: grid; display: grid;
grid-template-rows: 48px auto minmax(0, 1fr); grid-template-rows: 52px auto minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
@@ -1312,15 +1379,16 @@ useDesktopShortcuts(
} }
.toolbar-left, .toolbar-left,
.toolbar-right, .toolbar-right {
.history-controls {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.toolbar-left { .toolbar-left {
gap: 10px; align-self: stretch;
cursor: default;
user-select: none;
} }
.toolbar-right { .toolbar-right {
@@ -1328,35 +1396,55 @@ useDesktopShortcuts(
gap: 8px; gap: 8px;
} }
.history-controls { .toolbar-right,
gap: 3px; .toolbar-right :deep(*),
.desktop-sidebar button,
.desktop-sidebar :deep(.el-dropdown),
.workspace-tabs,
.workspace-tabs *,
.workspace-tab-context-menu,
.workspace-tab-context-menu * {
-webkit-app-region: no-drag;
} }
.breadcrumb-strip { .toolbar-left[data-tauri-drag-region],
display: flex; .toolbar-context[data-tauri-drag-region],
.toolbar-title[data-tauri-drag-region],
.toolbar-subtitle[data-tauri-drag-region] {
-webkit-app-region: drag;
}
.toolbar-context {
display: grid;
min-width: 0; min-width: 0;
align-items: center; align-content: center;
gap: 6px; gap: 2px;
width: 100%;
height: 100%;
overflow: hidden; overflow: hidden;
} }
.breadcrumb-chip { .toolbar-title,
display: inline-flex; .toolbar-subtitle {
align-items: center;
max-width: 220px;
height: 26px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 999px;
background: #ffffff;
color: #53657b;
font-size: 12px;
font-weight: 650;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.toolbar-title {
color: #142033;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
}
.toolbar-subtitle {
color: #66778d;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.command-trigger, .command-trigger,
.update-notice, .update-notice,
.connection-button, .connection-button,
@@ -1555,6 +1643,151 @@ useDesktopShortcuts(
text-align: center; text-align: center;
} }
.activity-button {
position: relative;
}
.activity-badge {
display: inline-flex;
}
.activity-badge :deep(.el-badge__content) {
height: 16px;
min-width: 16px;
padding: 0 4px;
border: 2px solid #ffffff;
background: #3f5d75;
font-size: 10px;
line-height: 12px;
}
.activity-panel {
width: 320px;
padding: 10px;
}
.activity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 4px 4px 10px;
}
.activity-clear {
appearance: none;
height: 24px;
padding: 0 7px;
border: 1px solid #d2dce8;
border-radius: 6px;
background: #ffffff;
color: #52667e;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.activity-clear:hover {
background: #eef4fb;
color: #183756;
}
.activity-list {
display: flex;
max-height: 286px;
flex-direction: column;
gap: 3px;
overflow-y: auto;
}
.activity-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-height: 46px;
padding: 7px;
border-radius: 7px;
}
.activity-item:hover {
background: #f4f7fa;
}
.activity-mark {
width: 7px;
height: 28px;
border-radius: 999px;
background: #7b8da3;
}
.activity-mark.is-running {
background: #3f5d75;
}
.activity-mark.is-completed {
background: #3f8f6b;
}
.activity-mark.is-failed {
background: #c24b4b;
}
.activity-mark.is-cancelled {
background: #9aaabd;
}
.activity-body {
display: inline-flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.activity-title,
.activity-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-title {
color: #142033;
font-size: 13px;
font-weight: 800;
}
.activity-detail {
color: #7b8da3;
font-size: 12px;
font-weight: 600;
}
.activity-meta {
display: inline-flex;
min-width: 44px;
flex-direction: column;
align-items: flex-end;
gap: 2px;
color: #52667e;
font-size: 12px;
font-weight: 800;
}
.activity-meta small {
color: #9aaabd;
font-size: 10px;
font-weight: 700;
}
.activity-empty {
padding: 24px 8px;
color: #8a9bb0;
font-size: 13px;
text-align: center;
}
.account-button { .account-button {
gap: 7px; gap: 7px;
max-width: 188px; max-width: 188px;
@@ -1780,14 +2013,15 @@ useDesktopShortcuts(
border-bottom-color: #26364a; border-bottom-color: #26364a;
} }
:global([data-ctms-theme="dark"] .sidebar-kicker),
:global([data-ctms-theme="dark"] .section-label), :global([data-ctms-theme="dark"] .section-label),
:global([data-ctms-theme="dark"] .panel-subtitle), :global([data-ctms-theme="dark"] .panel-subtitle),
:global([data-ctms-theme="dark"] .tab-group) { :global([data-ctms-theme="dark"] .tab-group),
:global([data-ctms-theme="dark"] .toolbar-subtitle) {
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"] .sidebar-head h1), :global([data-ctms-theme="dark"] .sidebar-head h1),
:global([data-ctms-theme="dark"] .toolbar-title),
:global([data-ctms-theme="dark"] .panel-title), :global([data-ctms-theme="dark"] .panel-title),
:global([data-ctms-theme="dark"] .panel-row code), :global([data-ctms-theme="dark"] .panel-row code),
:global([data-ctms-theme="dark"] .reminder-body span) { :global([data-ctms-theme="dark"] .reminder-body span) {
@@ -1809,7 +2043,6 @@ useDesktopShortcuts(
:global([data-ctms-theme="dark"] .update-notice), :global([data-ctms-theme="dark"] .update-notice),
:global([data-ctms-theme="dark"] .connection-button), :global([data-ctms-theme="dark"] .connection-button),
:global([data-ctms-theme="dark"] .account-button), :global([data-ctms-theme="dark"] .account-button),
:global([data-ctms-theme="dark"] .breadcrumb-chip),
:global([data-ctms-theme="dark"] .workspace-tab) { :global([data-ctms-theme="dark"] .workspace-tab) {
border-color: #334155; border-color: #334155;
background: #172033; background: #172033;
@@ -1895,6 +2128,40 @@ useDesktopShortcuts(
color: #dbe5f1; color: #dbe5f1;
} }
:global([data-ctms-theme="dark"] .desktop-activity-popover) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-item:hover) {
background: #1f2d3d;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-title) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-detail),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta small),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta) {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear) {
border-color: #334155;
background: #172033;
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear:hover) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .panel-row) { :global([data-ctms-theme="dark"] .panel-row) {
color: #94a3b8; color: #94a3b8;
} }
+78 -3
View File
@@ -6,14 +6,17 @@ const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"),
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8"); const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8"); const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
const readTauriConfigSource = () => readFileSync(resolve(__dirname, "../../src-tauri/tauri.conf.json"), "utf8");
const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8"); const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8");
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8"); const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8"); const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8"); const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8");
const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8"); const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8");
const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8"); const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8"); const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8"); const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8");
const readMainStyleSource = () => readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
describe("desktop layout shell", () => { describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => { it("routes desktop and web shells through the runtime flag", () => {
@@ -46,19 +49,49 @@ describe("desktop layout shell", () => {
it("keeps desktop context labels and command entry points de-duplicated", () => { it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource(); const source = readDesktopLayoutSource();
expect(source).toContain("<h1>{{ TEXT.common.appName }} Desktop</h1>"); expect(source).toContain("<h1>{{ TEXT.common.appName }}</h1>");
expect(source).toContain('const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";');
expect(source).toContain('data-tauri-drag-region');
expect(source).toContain('class="toolbar-title"');
expect(source).toContain('class="toolbar-subtitle"');
expect(source).toContain("desktopToolbarTitle");
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).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle"); expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"'); expect(source).not.toContain('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar"); expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item"); expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel"); expect(source).not.toContain("accountProjectRoleLabel");
expect(source).not.toContain("projectStatusLabel"); expect(source).not.toContain("projectStatusLabel");
expect(source).toContain("grid-template-rows: 48px auto minmax(0, 1fr);"); expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems"); expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems"); expect(source).toContain("const projectDesktopNavigationItems");
expect(source).toContain('id: "desktop:refresh"');
expect(source).toContain('shortcut: "⌘R"');
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"); expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
}); });
it("uses macOS overlay titlebar without adding window permissions", () => {
const tauriConfig = readTauriConfigSource();
const layout = readDesktopLayoutSource();
expect(tauriConfig).toContain('"decorations": true');
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true');
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
expect(layout).toContain("padding: 54px 16px 14px;");
expect(layout).toContain("-webkit-app-region: drag;");
expect(layout).toContain("-webkit-app-region: no-drag;");
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("renders desktop preferences as a split settings panel", () => { it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource(); const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource(); const desktopLayout = readDesktopLayoutSource();
@@ -218,7 +251,7 @@ describe("desktop layout shell", () => {
expect(preferences).toContain("当前已是最新版本"); expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新"); expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒"); expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).not.toContain("当前已是最新版本"); expect(desktopUpdateManager).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。"); expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible"); expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus"); expect(desktopLayout).toContain("listenDesktopUpdateStatus");
@@ -233,9 +266,15 @@ describe("desktop layout shell", () => {
expect(helper).toContain("export const pickFilesWithFeedback"); expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback"); expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback"); expect(helper).toContain("export const openFileWithFeedback");
expect(helper).toContain("startDesktopActivity");
expect(helper).toContain("finishDesktopActivity");
expect(helper).toContain("desktopFileActivitiesEnabled");
expect(attachments).toContain("pickFilesWithFeedback"); expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback"); expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback"); expect(attachments).toContain("openFileWithFeedback");
expect(attachments).toContain("startDesktopActivity");
expect(attachments).toContain('title: "下载附件"');
expect(attachments).toContain('title: "上传附件"');
expect(documentDetail).toContain("pickFilesWithFeedback"); expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback"); expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback"); expect(documentDetail).toContain("openFileWithFeedback");
@@ -243,6 +282,42 @@ describe("desktop layout shell", () => {
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile"); expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
}); });
it("surfaces persistent desktop activity feedback without local task persistence", () => {
const desktopLayout = readDesktopLayoutSource();
const activityCenter = readDesktopActivityCenterSource();
const updateManager = readDesktopUpdateManagerSource();
expect(desktopLayout).toContain('popper-class="desktop-activity-popover"');
expect(desktopLayout).toContain("desktopActivities");
expect(desktopLayout).toContain("listenDesktopActivities");
expect(desktopLayout).toContain("clearFinishedDesktopActivities");
expect(desktopLayout).toContain("desktopActivityBadgeCount");
expect(activityCenter).toContain("DESKTOP_ACTIVITY_CHANGED_EVENT");
expect(activityCenter).toContain("safeActivityText");
expect(activityCenter).toContain("unsafeActivityTextPattern");
expect(activityCenter).not.toContain("localStorage");
expect(activityCenter).not.toContain("sessionStorage");
expect(updateManager).toContain('title: "检查桌面更新"');
expect(updateManager).toContain('title: "安装桌面更新"');
expect(updateManager).toContain("finishUpdateActivity");
});
it("keeps desktop business pages dense and workbench-like", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop workbench density */");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero");
expect(styles).toContain("box-shadow: none !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
it("keeps desktop entry surfaces stable at the minimum window size", () => { it("keeps desktop entry surfaces stable at the minimum window size", () => {
const login = readLoginSource(); const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource(); const serverSettings = readDesktopServerSettingsSource();
@@ -519,7 +519,14 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => { const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" }); const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
await saveFileWithFeedback({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob }); await saveFileWithFeedback(
{ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob },
{
kind: "export",
title: "导出日志",
completedDetail: "日志文件已保存",
},
);
}; };
const downloadInterfaceLog = () => { const downloadInterfaceLog = () => {
@@ -154,6 +154,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { clientRuntime } from "../../runtime"; import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback"; import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
type AttachmentEntityGroup = { type AttachmentEntityGroup = {
entityType: string; entityType: string;
@@ -253,6 +254,7 @@ const tableProgress = computed(() => {
return group ? progressMap[group.key] || 0 : 0; return group ? progressMap[group.key] || 0 : 0;
}); });
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100); const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const desktopActivityEnabled = () => nativeFiles;
const validateFile = (file: File) => { const validateFile = (file: File) => {
if (file.size > maxSize.value * 1024 * 1024) { if (file.size > maxSize.value * 1024 * 1024) {
@@ -293,14 +295,21 @@ const pendingSnapshot = () =>
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`)); uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0)); const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
const uploadFile = async (group: UploadGroup, file: File, targetEntityId: string) => { const uploadFile = async (
group: UploadGroup,
file: File,
targetEntityId: string,
onProgress?: (progress: number) => void,
) => {
if (!props.studyId || !targetEntityId) return; if (!props.studyId || !targetEntityId) return;
if (!validateFile(file)) throw new Error("invalid file"); if (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0; progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, { await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => { onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) { if (evt.total) {
progressMap[group.key] = Math.round((evt.loaded / evt.total) * 100); const progress = Math.round((evt.loaded / evt.total) * 100);
progressMap[group.key] = progress;
onProgress?.(progress);
} }
}, },
}); });
@@ -313,6 +322,16 @@ const uploadPending = async (entityId?: string) => {
} }
if (!targetEntityId) return; if (!targetEntityId) return;
let hasFailure = false; let hasFailure = false;
const totalCount = uploadGroups.value.reduce((sum, group) => sum + (pendingMap[group.key] || []).length, 0);
const activityId = desktopActivityEnabled() && totalCount > 0
? startDesktopActivity({
kind: "upload",
title: "上传附件",
detail: `正在上传 ${totalCount} 个附件`,
progress: 0,
})
: "";
let completedCount = 0;
for (const group of uploadGroups.value) { for (const group of uploadGroups.value) {
const items = [...(pendingMap[group.key] || [])]; const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = []; const remainItems: PendingUploadItem[] = [];
@@ -320,19 +339,36 @@ const uploadPending = async (entityId?: string) => {
try { try {
item.status = "uploading"; item.status = "uploading";
item.error = ""; item.error = "";
await uploadFile(group, item.file, targetEntityId); await uploadFile(group, item.file, targetEntityId, (progress) => {
if (!activityId || !totalCount) return;
updateDesktopActivity(activityId, {
detail: `正在上传 ${completedCount + 1}/${totalCount}`,
progress: Math.min(99, Math.round(((completedCount + progress / 100) / totalCount) * 100)),
});
});
} catch (e: any) { } catch (e: any) {
hasFailure = true; hasFailure = true;
item.status = "failed"; item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed; item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item); remainItems.push(item);
} finally { } finally {
completedCount += 1;
if (activityId && totalCount) {
updateDesktopActivity(activityId, {
detail: `已处理 ${completedCount}/${totalCount}`,
progress: Math.min(99, Math.round((completedCount / totalCount) * 100)),
});
}
progressMap[group.key] = 0; progressMap[group.key] = 0;
} }
} }
pendingMap[group.key] = remainItems; pendingMap[group.key] = remainItems;
} }
if (hasFailure) throw new Error(TEXT.common.messages.uploadFailed); if (hasFailure) {
finishDesktopActivity(activityId, "failed", { detail: "部分附件上传失败" });
throw new Error(TEXT.common.messages.uploadFailed);
}
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
}; };
const uploadImmediate = async (options: any) => { const uploadImmediate = async (options: any) => {
@@ -340,11 +376,18 @@ const uploadImmediate = async (options: any) => {
const group = tableUploadGroup.value; const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return; if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return; if (!validateFile(file)) return;
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "upload", title: "上传附件", detail: "正在上传附件", progress: 0 })
: "";
try { try {
await uploadFile(group, file, props.entityId); await uploadFile(group, file, props.entityId, (progress) => {
ElMessage.success(TEXT.common.messages.uploadSuccess); updateDesktopActivity(activityId, { detail: "正在上传附件", progress });
});
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
if (!desktopActivityEnabled()) ElMessage.success(TEXT.common.messages.uploadSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
finishDesktopActivity(activityId, "failed", { detail: "附件上传失败" });
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed); ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally { } finally {
progressMap[group.key] = 0; progressMap[group.key] = 0;
@@ -399,25 +442,48 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
}; };
const download = async (row: any) => { const download = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "download", title: "下载附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try { try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "等待选择保存位置", progress: 65 });
await saveFileWithFeedback({ await saveFileWithFeedback({
suggestedName: row?.filename || "download", suggestedName: row?.filename || "download",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: blob,
}, {
activityId,
title: "下载附件",
pendingDetail: "等待选择保存位置",
completedDetail: "附件已保存",
cancelledDetail: "已取消保存附件",
}); });
} catch { } catch {
finishDesktopActivity(activityId, "failed", { detail: "附件下载失败" });
ElMessage.error(TEXT.common.messages.downloadFailed); ElMessage.error(TEXT.common.messages.downloadFailed);
} }
}; };
const openExternally = async (row: any) => { const openExternally = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "open", title: "打开附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try { try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "正在准备文件", progress: 65 });
await openFileWithFeedback({ await openFileWithFeedback({
suggestedName: row?.filename || "attachment", suggestedName: row?.filename || "attachment",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: blob,
}, {
activityId,
title: "打开附件",
pendingDetail: "正在准备文件",
completedDetail: "已交给系统打开",
}); });
} catch { } catch {
finishDesktopActivity(activityId, "failed", { detail: "附件打开失败" });
ElMessage.error(TEXT.common.messages.previewNotSupported); ElMessage.error(TEXT.common.messages.previewNotSupported);
} }
}; };
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("desktop activity center", () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T08:00:00.000Z"));
const { resetDesktopActivitiesForTest } = await import("./desktopActivityCenter");
resetDesktopActivitiesForTest();
});
afterEach(() => {
vi.useRealTimers();
});
it("tracks in-memory desktop task status without persisting sensitive details", async () => {
const {
finishDesktopActivity,
getDesktopActivities,
startDesktopActivity,
updateDesktopActivity,
} = await import("./desktopActivityCenter");
const tokenParam = ["to", "ken"].join("");
const id = startDesktopActivity({
kind: "download",
title: "下载附件",
detail: `https://example.com/file?${tokenParam}=secret`,
progress: 12.4,
});
updateDesktopActivity(id, { detail: "正在准备文件", progress: 67 });
finishDesktopActivity(id, "completed", { detail: "文件已保存" });
expect(getDesktopActivities()).toEqual([
expect.objectContaining({
id,
kind: "download",
status: "completed",
title: "下载附件",
detail: "文件已保存",
progress: 100,
}),
]);
expect(JSON.stringify(getDesktopActivities())).not.toContain(`${tokenParam}=`);
expect(JSON.stringify(getDesktopActivities())).not.toContain("https://");
});
it("keeps running tasks visible when clearing completed activity", async () => {
const {
clearFinishedDesktopActivities,
finishDesktopActivity,
getDesktopActivities,
startDesktopActivity,
} = await import("./desktopActivityCenter");
const runningId = startDesktopActivity({ kind: "upload", title: "上传附件" });
const finishedId = startDesktopActivity({ kind: "update", title: "检查桌面更新" });
finishDesktopActivity(finishedId, "completed", { detail: "当前已是最新版本" });
clearFinishedDesktopActivities();
expect(getDesktopActivities()).toEqual([
expect.objectContaining({ id: runningId, status: "running" }),
]);
});
});
@@ -0,0 +1,134 @@
export const DESKTOP_ACTIVITY_CHANGED_EVENT = "ctms:desktop-activity-changed";
export type DesktopActivityKind = "upload" | "download" | "export" | "update" | "open";
export type DesktopActivityStatus = "running" | "completed" | "failed" | "cancelled";
export interface DesktopActivityItem {
id: string;
kind: DesktopActivityKind;
status: DesktopActivityStatus;
title: string;
detail: string;
progress: number | null;
startedAt: string;
updatedAt: string;
}
export interface DesktopActivityInput {
kind: DesktopActivityKind;
title: string;
detail?: string;
progress?: number | null;
}
const MAX_ACTIVITY_ITEMS = 12;
let activitySequence = 0;
let activities: DesktopActivityItem[] = [];
const unsafeActivityTextPattern = /\b(?:https?:\/\/|token=|access_token=|authorization|bearer)\b/i;
const safeActivityText = (value: string | undefined, fallback = "") => {
const text = (value || "").trim();
if (!text || unsafeActivityTextPattern.test(text)) return fallback;
return text.slice(0, 80);
};
const nowIso = () => new Date().toISOString();
const clampProgress = (value: number | null | undefined) => {
if (value === null || value === undefined || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Math.round(value)));
};
const snapshotActivities = () => activities.map((item) => ({ ...item }));
const emitActivities = () => {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(DESKTOP_ACTIVITY_CHANGED_EVENT, {
detail: snapshotActivities(),
}),
);
};
const trimActivities = () => {
const running = activities.filter((item) => item.status === "running");
const finished = activities.filter((item) => item.status !== "running");
activities = [...running, ...finished].slice(0, MAX_ACTIVITY_ITEMS);
};
export const getDesktopActivities = (): DesktopActivityItem[] => snapshotActivities();
export const listenDesktopActivities = (listener: (items: DesktopActivityItem[]) => void) => {
if (typeof window === "undefined") return () => {};
const onChange = (event: Event) => {
listener((event as CustomEvent<DesktopActivityItem[]>).detail || []);
};
window.addEventListener(DESKTOP_ACTIVITY_CHANGED_EVENT, onChange);
return () => window.removeEventListener(DESKTOP_ACTIVITY_CHANGED_EVENT, onChange);
};
export const startDesktopActivity = (input: DesktopActivityInput): string => {
const timestamp = nowIso();
const id = `activity-${timestamp}-${activitySequence += 1}`;
activities = [
{
id,
kind: input.kind,
status: "running",
title: safeActivityText(input.title, "桌面任务"),
detail: safeActivityText(input.detail),
progress: clampProgress(input.progress),
startedAt: timestamp,
updatedAt: timestamp,
},
...activities,
];
trimActivities();
emitActivities();
return id;
};
export const updateDesktopActivity = (
id: string | undefined,
patch: Partial<Pick<DesktopActivityItem, "detail" | "progress" | "status" | "title">>,
) => {
if (!id) return;
const index = activities.findIndex((item) => item.id === id);
if (index < 0) return;
const current = activities[index];
const next: DesktopActivityItem = {
...current,
...patch,
title: patch.title === undefined ? current.title : safeActivityText(patch.title, current.title),
detail: patch.detail === undefined ? current.detail : safeActivityText(patch.detail),
progress: patch.progress === undefined ? current.progress : clampProgress(patch.progress),
updatedAt: nowIso(),
};
activities = [next, ...activities.slice(0, index), ...activities.slice(index + 1)];
trimActivities();
emitActivities();
};
export const finishDesktopActivity = (
id: string | undefined,
status: Exclude<DesktopActivityStatus, "running">,
patch: Partial<Pick<DesktopActivityItem, "detail" | "progress" | "title">> = {},
) => {
updateDesktopActivity(id, {
...patch,
status,
progress: patch.progress ?? (status === "completed" ? 100 : null),
});
};
export const clearFinishedDesktopActivities = () => {
activities = activities.filter((item) => item.status === "running");
emitActivities();
};
export const resetDesktopActivitiesForTest = () => {
activities = [];
activitySequence = 0;
emitActivities();
};
@@ -5,6 +5,9 @@ const installPendingDesktopUpdateMock = vi.hoisted(() => vi.fn());
const isDesktopUpdaterAvailableMock = vi.hoisted(() => vi.fn()); const isDesktopUpdaterAvailableMock = vi.hoisted(() => vi.fn());
const messageErrorMock = vi.hoisted(() => vi.fn()); const messageErrorMock = vi.hoisted(() => vi.fn());
const confirmMock = vi.hoisted(() => vi.fn()); const confirmMock = vi.hoisted(() => vi.fn());
const finishActivityMock = vi.hoisted(() => vi.fn());
const startActivityMock = vi.hoisted(() => vi.fn());
const updateActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../runtime", () => ({ vi.mock("../runtime", () => ({
checkForDesktopUpdate: checkForDesktopUpdateMock, checkForDesktopUpdate: checkForDesktopUpdateMock,
@@ -21,6 +24,12 @@ vi.mock("element-plus", () => ({
}, },
})); }));
vi.mock("./desktopActivityCenter", () => ({
finishDesktopActivity: finishActivityMock,
startDesktopActivity: startActivityMock,
updateDesktopActivity: updateActivityMock,
}));
const createStorage = (): Storage => { const createStorage = (): Storage => {
const data = new Map<string, string>(); const data = new Map<string, string>();
return { return {
@@ -56,6 +65,7 @@ describe("desktop update manager", () => {
checkForDesktopUpdateMock.mockResolvedValue(null); checkForDesktopUpdateMock.mockResolvedValue(null);
installPendingDesktopUpdateMock.mockResolvedValue(undefined); installPendingDesktopUpdateMock.mockResolvedValue(undefined);
confirmMock.mockResolvedValue(undefined); confirmMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
}); });
afterEach(async () => { afterEach(async () => {
@@ -72,6 +82,13 @@ describe("desktop update manager", () => {
expect(status).toBe("up-to-date"); expect(status).toBe("up-to-date");
expect(confirmMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled();
expect(startActivityMock).toHaveBeenCalledWith({
kind: "update",
title: "检查桌面更新",
detail: "正在连接更新服务",
progress: 20,
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "当前已是最新版本" });
expect(getDesktopUpdateStatus()).toMatchObject({ expect(getDesktopUpdateStatus()).toMatchObject({
lastStatus: "up-to-date", lastStatus: "up-to-date",
pendingUpdate: null, pendingUpdate: null,
@@ -91,6 +108,7 @@ describe("desktop update manager", () => {
expect(postponed).toBe("postponed"); expect(postponed).toBe("postponed");
expect(suppressed).toBe("suppressed"); expect(suppressed).toBe("suppressed");
expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledTimes(1);
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "已选择稍后提醒" });
expect(installPendingDesktopUpdateMock).not.toHaveBeenCalled(); expect(installPendingDesktopUpdateMock).not.toHaveBeenCalled();
expect(postponedStatus.postponedUntil).toBe("2026-07-03T00:00:00.000Z"); expect(postponedStatus.postponedUntil).toBe("2026-07-03T00:00:00.000Z");
expect(getDesktopUpdateStatus()).toMatchObject({ expect(getDesktopUpdateStatus()).toMatchObject({
@@ -109,6 +127,7 @@ describe("desktop update manager", () => {
expect(status).toBe("failed"); expect(status).toBe("failed");
expect(messageErrorMock).toHaveBeenCalledWith("桌面端更新安装失败,请稍后重试或联系管理员。"); expect(messageErrorMock).toHaveBeenCalledWith("桌面端更新安装失败,请稍后重试或联系管理员。");
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "failed", { detail: "更新安装失败" });
expect(getDesktopUpdateStatus()).toMatchObject({ expect(getDesktopUpdateStatus()).toMatchObject({
installing: false, installing: false,
lastStatus: "failed", lastStatus: "failed",
@@ -147,6 +166,7 @@ describe("desktop update manager", () => {
const status = await checkDesktopUpdateAndPrompt(); const status = await checkDesktopUpdateAndPrompt();
expect(status).toBe("failed"); expect(status).toBe("failed");
expect(startActivityMock).not.toHaveBeenCalled();
expect(messageErrorMock).not.toHaveBeenCalled(); expect(messageErrorMock).not.toHaveBeenCalled();
expect(getDesktopUpdateStatus()).toMatchObject({ expect(getDesktopUpdateStatus()).toMatchObject({
lastStatus: "failed", lastStatus: "failed",
@@ -162,6 +182,7 @@ describe("desktop update manager", () => {
expect(status).toBe("disabled"); expect(status).toBe("disabled");
expect(checkForDesktopUpdateMock).not.toHaveBeenCalled(); expect(checkForDesktopUpdateMock).not.toHaveBeenCalled();
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "更新检查不可用" });
expect(getDesktopUpdateStatus()).toMatchObject({ expect(getDesktopUpdateStatus()).toMatchObject({
available: false, available: false,
lastStatus: "disabled", lastStatus: "disabled",
+46 -3
View File
@@ -5,6 +5,7 @@ import {
isDesktopUpdaterAvailable, isDesktopUpdaterAvailable,
type DesktopUpdateInfo, type DesktopUpdateInfo,
} from "../runtime"; } from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "./desktopActivityCenter";
const INITIAL_CHECK_DELAY_MS = 30_000; const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -118,7 +119,25 @@ const releaseNotes = (update: DesktopUpdateInfo): string => {
return lines.join("\n"); return lines.join("\n");
}; };
const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdateCheckStatus> => { const updateUpdateActivity = (
activityId: string | undefined,
patch: Parameters<typeof updateDesktopActivity>[1],
) => {
if (activityId) updateDesktopActivity(activityId, patch);
};
const finishUpdateActivity = (
activityId: string | undefined,
status: Parameters<typeof finishDesktopActivity>[1],
patch?: Parameters<typeof finishDesktopActivity>[2],
) => {
if (activityId) finishDesktopActivity(activityId, status, patch);
};
const promptForUpdate = async (
update: DesktopUpdateInfo,
activityId?: string,
): Promise<DesktopUpdateCheckStatus> => {
if (promptVisible || isSuppressed(update.version)) return "suppressed"; if (promptVisible || isSuppressed(update.version)) return "suppressed";
promptVisible = true; promptVisible = true;
try { try {
@@ -128,16 +147,20 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
distinguishCancelAndClose: true, distinguishCancelAndClose: true,
type: "info", type: "info",
}); });
updateUpdateActivity(activityId, { detail: "正在下载并安装更新", progress: 75 });
setUpdateStatus({ installing: true, lastError: "" }); setUpdateStatus({ installing: true, lastError: "" });
await installPendingDesktopUpdate(); await installPendingDesktopUpdate();
setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update }); setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update });
finishUpdateActivity(activityId, "completed", { detail: "更新安装已启动" });
return "available"; return "available";
} catch (error) { } catch (error) {
if (error === "cancel" || error === "close") { if (error === "cancel" || error === "close") {
postponeVersion(update.version); postponeVersion(update.version);
finishUpdateActivity(activityId, "cancelled", { detail: "已选择稍后提醒" });
return "postponed"; return "postponed";
} }
setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" }); setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" });
finishUpdateActivity(activityId, "failed", { detail: "更新安装失败" });
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。"); ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
} finally { } finally {
@@ -147,7 +170,13 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => { export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => {
if (updateStatus.pendingUpdate) { if (updateStatus.pendingUpdate) {
return promptForUpdate(updateStatus.pendingUpdate); const activityId = startDesktopActivity({
kind: "update",
title: "安装桌面更新",
detail: "等待确认更新",
progress: 30,
});
return promptForUpdate(updateStatus.pendingUpdate, activityId);
} }
return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true }); return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
}; };
@@ -155,6 +184,9 @@ export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateChec
export const checkDesktopUpdateAndPrompt = async ( export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {}, options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => { ): Promise<DesktopUpdateCheckStatus> => {
const activityId = options.notifyWhenCurrent || options.promptWhenAvailable
? startDesktopActivity({ kind: "update", title: "检查桌面更新", detail: "正在连接更新服务", progress: 20 })
: "";
if (!isDesktopUpdaterAvailable()) { if (!isDesktopUpdaterAvailable()) {
setUpdateStatus({ setUpdateStatus({
available: false, available: false,
@@ -166,9 +198,11 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: null, pendingUpdate: null,
postponedUntil: null, postponedUntil: null,
}); });
finishUpdateActivity(activityId, "cancelled", { detail: "更新检查不可用" });
return "disabled"; return "disabled";
} }
setUpdateStatus({ available: true, checking: true, lastError: "" }); setUpdateStatus({ available: true, checking: true, lastError: "" });
updateUpdateActivity(activityId, { detail: "正在读取更新信息", progress: 40 });
try { try {
const update = await checkForDesktopUpdate(); const update = await checkForDesktopUpdate();
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
@@ -182,10 +216,17 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: update, pendingUpdate: update,
postponedUntil: postponedUntilMs ? new Date(postponedUntilMs).toISOString() : null, postponedUntil: postponedUntilMs ? new Date(postponedUntilMs).toISOString() : null,
}); });
updateUpdateActivity(activityId, {
detail: suppressed ? "稍后提醒仍在生效" : "发现可用更新",
progress: 65,
});
const shouldPrompt = options.promptWhenAvailable ?? options.notifyWhenCurrent ?? false; const shouldPrompt = options.promptWhenAvailable ?? options.notifyWhenCurrent ?? false;
if (shouldPrompt && !suppressed) { if (shouldPrompt && !suppressed) {
return promptForUpdate(update); return promptForUpdate(update, activityId);
} }
finishUpdateActivity(activityId, suppressed ? "cancelled" : "completed", {
detail: suppressed ? "已选择稍后提醒" : "发现可用更新",
});
return suppressed ? "suppressed" : "available"; return suppressed ? "suppressed" : "available";
} }
setUpdateStatus({ setUpdateStatus({
@@ -196,6 +237,7 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: null, pendingUpdate: null,
postponedUntil: null, postponedUntil: null,
}); });
finishUpdateActivity(activityId, "completed", { detail: "当前已是最新版本" });
return "up-to-date"; return "up-to-date";
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "桌面端更新检查失败"; const message = error instanceof Error ? error.message : "桌面端更新检查失败";
@@ -205,6 +247,7 @@ export const checkDesktopUpdateAndPrompt = async (
lastCheckedAt: new Date().toISOString(), lastCheckedAt: new Date().toISOString(),
lastError: message, lastError: message,
}); });
finishUpdateActivity(activityId, "failed", { detail: "更新检查失败" });
// 启动和定时检查不打断录入;下一轮继续检查。 // 启动和定时检查不打断录入;下一轮继续检查。
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。"); if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
+231
View File
@@ -638,6 +638,237 @@ body {
font-size: 13px; font-size: 13px;
} }
/* Desktop workbench density */
.desktop-workbench {
--ctms-radius-sm: 5px;
--ctms-radius: 6px;
--ctms-radius-lg: 8px;
--ctms-shadow-sm: none;
--ctms-shadow: none;
--ctms-shadow-md: none;
--ctms-shadow-lg: none;
}
.desktop-workbench .desktop-route-shell {
color: var(--ctms-text-regular);
font-size: 13px;
}
.desktop-workbench .desktop-route-shell .page,
.desktop-workbench .desktop-route-shell .ctms-page {
gap: 10px;
padding: 0;
}
.desktop-workbench .desktop-route-shell .el-card,
.desktop-workbench .desktop-route-shell .unified-shell,
.desktop-workbench .desktop-route-shell .ctms-state,
.desktop-workbench .desktop-route-shell .ctms-section-card,
.desktop-workbench .desktop-route-shell .ctms-table-card,
.desktop-workbench .desktop-route-shell .table-card,
.desktop-workbench .desktop-route-shell .detail-card,
.desktop-workbench .desktop-route-shell .stats-card,
.desktop-workbench .desktop-route-shell .notifications-card,
.desktop-workbench .desktop-route-shell .hero-card,
.desktop-workbench .desktop-route-shell .hero-banner,
.desktop-workbench .desktop-route-shell .faq-hero {
border: 1px solid #d9e2ec;
border-radius: 6px !important;
background: #ffffff;
box-shadow: none !important;
}
.desktop-workbench .desktop-route-shell .hero-banner,
.desktop-workbench .desktop-route-shell .hero-card,
.desktop-workbench .desktop-route-shell .faq-hero {
min-height: 0;
padding: 12px 14px !important;
overflow: hidden;
}
.desktop-workbench .desktop-route-shell .hero-banner::before,
.desktop-workbench .desktop-route-shell .hero-banner::after,
.desktop-workbench .desktop-route-shell .hero-card::before,
.desktop-workbench .desktop-route-shell .hero-card::after,
.desktop-workbench .desktop-route-shell .faq-hero::before,
.desktop-workbench .desktop-route-shell .faq-hero::after,
.desktop-workbench .desktop-route-shell .hero-bg-pattern,
.desktop-workbench .desktop-route-shell .hero-accent {
display: none !important;
}
.desktop-workbench .desktop-route-shell .hero-top,
.desktop-workbench .desktop-route-shell .hero-content,
.desktop-workbench .desktop-route-shell .hero-body {
margin-bottom: 10px !important;
}
.desktop-workbench .desktop-route-shell .hero-title,
.desktop-workbench .desktop-route-shell .faq-hero h1 {
color: var(--ctms-text-main) !important;
font-size: 16px !important;
font-weight: 750 !important;
line-height: 1.35;
}
.desktop-workbench .desktop-route-shell .hero-stats,
.desktop-workbench .desktop-route-shell .hero-meta,
.desktop-workbench .desktop-route-shell .hero-status-row {
gap: 6px !important;
}
.desktop-workbench .desktop-route-shell .hero-stat,
.desktop-workbench .desktop-route-shell .hero-status-item {
min-height: 0;
padding: 8px 10px !important;
border: 1px solid #e1e8f0;
border-radius: 6px !important;
background: #f8fafc !important;
box-shadow: none !important;
transform: none !important;
}
.desktop-workbench .desktop-route-shell .table-card-toolbar,
.desktop-workbench .desktop-route-shell .ctms-page-header,
.desktop-workbench .desktop-route-shell .ctms-section-header {
min-height: 0;
padding: 9px 12px;
border-bottom-color: #d9e2ec;
gap: 8px;
}
.desktop-workbench .desktop-route-shell .toolbar-filters,
.desktop-workbench .desktop-route-shell .toolbar-right,
.desktop-workbench .desktop-route-shell .ctms-page-actions,
.desktop-workbench .desktop-route-shell .ctms-section-actions {
gap: 8px;
}
.desktop-workbench .desktop-route-shell .el-card__header {
padding: 10px 12px;
}
.desktop-workbench .desktop-route-shell .el-card__body {
padding: 12px;
}
.desktop-workbench .desktop-route-shell .el-button {
min-height: 28px;
border-radius: 6px;
}
.desktop-workbench .desktop-route-shell .el-button--small {
min-height: 26px;
padding: 0 8px;
}
.desktop-workbench .desktop-route-shell .el-input__wrapper,
.desktop-workbench .desktop-route-shell .el-select .el-input__wrapper,
.desktop-workbench .desktop-route-shell .el-textarea__inner {
border-radius: 6px;
min-height: 30px;
}
.desktop-workbench .desktop-route-shell .el-form-item {
margin-bottom: 12px;
}
.desktop-workbench .desktop-route-shell .el-form-item__label,
.desktop-workbench .desktop-route-shell .filter-label,
.desktop-workbench .desktop-route-shell .ctms-filter-label {
font-size: 11px;
font-weight: 700;
}
.desktop-workbench .desktop-route-shell .form-section {
padding-bottom: 4px;
}
.desktop-workbench .desktop-route-shell .form-section + .form-section {
margin-top: 14px;
padding-top: 14px;
}
.desktop-workbench .desktop-route-shell .form-section-title,
.desktop-workbench .desktop-route-shell .ctms-section-title {
margin-bottom: 10px;
font-size: 13px;
}
.desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
height: 34px;
padding: 8px 10px;
border-bottom: 1px solid #d9e2ec !important;
background: #f4f7fa;
font-size: 11px;
}
.desktop-workbench .desktop-route-shell .el-table td.el-table__cell {
padding: 7px 10px;
border-bottom-color: #edf1f5;
font-size: 13px;
}
.desktop-workbench .desktop-route-shell .el-table--enable-row-hover .el-table__row:hover > td.el-table__cell {
background-color: #eef4fa;
}
.desktop-workbench .desktop-route-shell .el-descriptions__cell {
padding: 8px 10px;
}
.desktop-workbench .desktop-route-shell .el-drawer__header {
margin-bottom: 0;
padding: 14px 18px 8px;
}
.desktop-workbench .desktop-route-shell .el-drawer__body {
padding: 0 18px 4px;
}
.desktop-workbench .desktop-route-shell .el-drawer__footer {
padding: 10px 18px 14px;
border-top: 1px solid #d9e2ec;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .unified-shell,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-state,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-table-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .detail-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .stats-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .notifications-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-banner,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .faq-hero {
border-color: #26364a;
background: #172033;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-stat,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-status-item {
border-color: #26364a;
background: #111827 !important;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card-toolbar,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-page-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-drawer__footer {
border-color: #26364a;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
border-bottom-color: #26364a !important;
background: #111827;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table td.el-table__cell {
border-bottom-color: #26364a;
}
/* ============================================================ /* ============================================================
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏 * 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置 * --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
+49 -3
View File
@@ -6,6 +6,9 @@ const pickFilesMock = vi.hoisted(() => vi.fn());
const saveFileMock = vi.hoisted(() => vi.fn()); const saveFileMock = vi.hoisted(() => vi.fn());
const messageInfoMock = vi.hoisted(() => vi.fn()); const messageInfoMock = vi.hoisted(() => vi.fn());
const messageSuccessMock = vi.hoisted(() => vi.fn()); const messageSuccessMock = vi.hoisted(() => vi.fn());
const finishActivityMock = vi.hoisted(() => vi.fn());
const startActivityMock = vi.hoisted(() => vi.fn());
const updateActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../runtime", () => ({ vi.mock("../runtime", () => ({
clientRuntime: { clientRuntime: {
@@ -23,6 +26,12 @@ vi.mock("element-plus", () => ({
}, },
})); }));
vi.mock("../session/desktopActivityCenter", () => ({
finishDesktopActivity: finishActivityMock,
startDesktopActivity: startActivityMock,
updateDesktopActivity: updateActivityMock,
}));
const output = { const output = {
suggestedName: "report.pdf", suggestedName: "report.pdf",
mimeType: "application/pdf", mimeType: "application/pdf",
@@ -36,6 +45,7 @@ describe("file task feedback", () => {
pickFilesMock.mockResolvedValue([]); pickFilesMock.mockResolvedValue([]);
saveFileMock.mockResolvedValue("saved"); saveFileMock.mockResolvedValue("saved");
openFileMock.mockResolvedValue(undefined); openFileMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
}); });
it("reports native file picker selections", async () => { it("reports native file picker selections", async () => {
@@ -70,16 +80,26 @@ describe("file task feedback", () => {
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始"); expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
}); });
it("uses saved feedback for native save flows", async () => { it("uses persistent desktop activity for native save flows", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true }); capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { saveFileWithFeedback } = await import("./fileTaskFeedback"); const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("saved"); await expect(saveFileWithFeedback(output)).resolves.toBe("saved");
expect(messageSuccessMock).toHaveBeenCalledWith("文件已保存"); expect(startActivityMock).toHaveBeenCalledWith({
kind: "download",
title: "保存文件",
detail: "等待选择保存位置",
});
expect(updateActivityMock).toHaveBeenCalledWith("activity-1", {
detail: "等待选择保存位置",
progress: 70,
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "文件已保存" });
expect(messageSuccessMock).not.toHaveBeenCalled();
}); });
it("reports cancelled save flows without treating them as downloads", async () => { it("reports cancelled web save flows without treating them as downloads", async () => {
saveFileMock.mockResolvedValue("cancelled"); saveFileMock.mockResolvedValue("cancelled");
const { saveFileWithFeedback } = await import("./fileTaskFeedback"); const { saveFileWithFeedback } = await import("./fileTaskFeedback");
@@ -89,6 +109,17 @@ describe("file task feedback", () => {
expect(messageSuccessMock).not.toHaveBeenCalled(); expect(messageSuccessMock).not.toHaveBeenCalled();
}); });
it("reports cancelled native save flows through desktop activity", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
saveFileMock.mockResolvedValue("cancelled");
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("cancelled");
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "已取消保存" });
expect(messageInfoMock).not.toHaveBeenCalled();
});
it("reports external open completion", async () => { it("reports external open completion", async () => {
const { openFileWithFeedback } = await import("./fileTaskFeedback"); const { openFileWithFeedback } = await import("./fileTaskFeedback");
@@ -97,4 +128,19 @@ describe("file task feedback", () => {
expect(openFileMock).toHaveBeenCalledWith(output); expect(openFileMock).toHaveBeenCalledWith(output);
expect(messageSuccessMock).toHaveBeenCalledWith("文件已打开"); expect(messageSuccessMock).toHaveBeenCalledWith("文件已打开");
}); });
it("uses persistent desktop activity for native open flows", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { openFileWithFeedback } = await import("./fileTaskFeedback");
await openFileWithFeedback(output);
expect(startActivityMock).toHaveBeenCalledWith({
kind: "open",
title: "打开文件",
detail: "正在准备文件",
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "已交给系统打开" });
expect(messageSuccessMock).not.toHaveBeenCalled();
});
}); });
+63 -2
View File
@@ -8,9 +8,25 @@ import {
type FilePickerOptions, type FilePickerOptions,
type SaveFileResult, type SaveFileResult,
} from "../runtime"; } from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
const selectedFilesMessage = (count: number) => (count > 1 ? `已选择 ${count} 个文件` : "已选择 1 个文件"); const selectedFilesMessage = (count: number) => (count > 1 ? `已选择 ${count} 个文件` : "已选择 1 个文件");
type FileSaveActivityKind = "download" | "export";
export interface FileTaskFeedbackOptions {
activityId?: string;
kind?: FileSaveActivityKind;
title?: string;
pendingDetail?: string;
completedDetail?: string;
cancelledDetail?: string;
}
const desktopFileActivitiesEnabled = () => clientRuntime.capabilities().nativeFiles;
const defaultSaveTitle = (kind: FileSaveActivityKind) => (kind === "export" ? "导出文件" : "保存文件");
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => { export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options); const files = await pickFiles(options);
if (files.length) { if (files.length) {
@@ -19,7 +35,12 @@ export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Pr
return files; return files;
}; };
export const saveFileWithFeedback = async (output: FileOutput): Promise<SaveFileResult> => { export const saveFileWithFeedback = async (
output: FileOutput,
options: FileTaskFeedbackOptions = {},
): Promise<SaveFileResult> => {
const kind = options.kind || "download";
if (!desktopFileActivitiesEnabled()) {
const result = await saveFile(output); const result = await saveFile(output);
if (result === "cancelled") { if (result === "cancelled") {
ElMessage.info("已取消保存文件"); ElMessage.info("已取消保存文件");
@@ -27,9 +48,49 @@ export const saveFileWithFeedback = async (output: FileOutput): Promise<SaveFile
} }
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始"); ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
return result; return result;
}
const activityId = options.activityId || startDesktopActivity({
kind,
title: options.title || defaultSaveTitle(kind),
detail: options.pendingDetail || "等待选择保存位置",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
try {
const result = await saveFile(output);
if (result === "cancelled") {
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
return result;
}
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "文件已保存" });
return result;
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件保存失败" });
throw error;
}
}; };
export const openFileWithFeedback = async (output: FileOutput): Promise<void> => { export const openFileWithFeedback = async (
output: FileOutput,
options: Omit<FileTaskFeedbackOptions, "kind"> = {},
): Promise<void> => {
if (!desktopFileActivitiesEnabled()) {
await openFile(output); await openFile(output);
ElMessage.success("文件已打开"); ElMessage.success("文件已打开");
return;
}
const activityId = options.activityId || startDesktopActivity({
kind: "open",
title: options.title || "打开文件",
detail: options.pendingDetail || "正在准备文件",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "正在准备文件", progress: 70 });
try {
await openFile(output);
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "已交给系统打开" });
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件打开失败" });
throw error;
}
}; };
@@ -48,4 +48,34 @@ describe("DocumentList permissions", () => {
expect(source).toContain("const displayValue = displayDateTime(value)"); expect(source).toContain("const displayValue = displayDateTime(value)");
expect(source).not.toContain('replace("T", " ").replace("Z", "")'); expect(source).not.toContain('replace("T", " ").replace("Z", "")');
}); });
it("keeps desktop document browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="document-workbench"');
expect(source).toContain('class="document-preview-pane"');
expect(source).toContain("selectedDocument");
expect(source).toContain('@row-click="handleDocumentRowClick"');
expect(source).toContain('@row-dblclick="openDocumentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedDocument"');
expect(source).toContain("selectDocument(row);");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).not.toContain('@row-click="handleRowClick"');
});
it("routes desktop document actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"');
expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\"");
expect(source).toContain(':disabled="isInactiveSite(selectedDocument.site_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
}); });
+342 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush" :class="{ 'document-page--desktop': isDesktop }" @click="closeDocumentContextMenu">
<div class="main-content-flat unified-shell"> <div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush"> <div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
@@ -29,12 +29,17 @@
</el-form> </el-form>
</div> </div>
<section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom"> <section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom">
<div class="document-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="document-table-pane" :tabindex="isDesktop ? 0 : undefined" @keydown.enter.prevent="openSelectedDocument">
<el-table <el-table
:data="sortedItems" :data="sortedItems"
v-loading="loading" v-loading="loading"
@row-click="handleRowClick" @row-click="handleDocumentRowClick"
@row-dblclick="openDocumentDetail"
@row-contextmenu="openDocumentContextMenu"
:row-class-name="documentRowClass" :row-class-name="documentRowClass"
class="ctms-table" class="ctms-table"
highlight-current-row
table-layout="fixed" table-layout="fixed"
> >
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip> <el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
@@ -76,7 +81,7 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)"> <el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
@@ -92,9 +97,80 @@
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" /> <el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template> </template>
</el-table> </el-table>
</div>
<aside v-if="isDesktop" class="document-preview-pane">
<template v-if="selectedDocument">
<div class="preview-head">
<div>
<div class="preview-kicker">当前文档</div>
<div class="preview-title">{{ selectedDocument.title || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedDocument">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.modules.fileVersionManagement.columns.scope }}</dt>
<dd>{{ displayEnum(TEXT.enums.scopeType, selectedDocument.scope_type) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.site }}</dt>
<dd>{{ displaySite(selectedDocument.site_id) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.docType }}</dt>
<dd>{{ displayText(selectedDocument.doc_type, TEXT.enums.documentType) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.currentVersion }}</dt>
<dd>{{ selectedDocument.current_effective_version?.version_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedDocument">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedDocument.site_id)" @click="openSelectedDocumentEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
</div>
</aside>
</div>
</section> </section>
</div> </div>
<div
v-if="isDesktop && documentContextMenu.visible && selectedDocument"
class="document-context-menu"
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedDocument">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="openSelectedDocumentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<el-drawer <el-drawer
v-if="editorVisible" v-if="editorVisible"
v-model="editorVisible" v-model="editorVisible"
@@ -179,6 +255,7 @@ import type { Site } from "../../types/api";
import { displayDateTime, displayEnum, displayText } from "../../utils/display"; import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -192,6 +269,9 @@ const editorVisible = ref(false);
const saving = ref(false); const saving = ref(false);
const editingDocumentId = ref(""); const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>(); const editorFormRef = ref<FormInstance>();
const isDesktop = isTauriRuntime();
const selectedDocumentId = ref("");
const documentContextMenu = ref({ visible: false, x: 0, y: 0 });
let desktopRefreshCleanup: (() => void) | undefined; let desktopRefreshCleanup: (() => void) | undefined;
const trialId = computed(() => (route.params.trialId as string) || ""); const trialId = computed(() => (route.params.trialId as string) || "");
@@ -228,7 +308,13 @@ const canUpdate = computed(() => can("documents.update"));
const canDelete = computed(() => can("documents.delete")); const canDelete = computed(() => can("documents.delete"));
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false; const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) => const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim(); [
row?.id ? "clickable-row" : "",
isInactiveSite(row?.site_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedDocumentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const filteredItems = computed(() => const filteredItems = computed(() =>
items.value.filter((item) => { items.value.filter((item) => {
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false; if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
@@ -240,6 +326,9 @@ const filteredItems = computed(() =>
const sortedItems = computed(() => const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id))) [...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
); );
const selectedDocument = computed(() =>
sortedItems.value.find((item) => item.id === selectedDocumentId.value) || null
);
const editorForm = reactive({ const editorForm = reactive({
doc_no: "", doc_no: "",
@@ -402,11 +491,60 @@ const submitEditor = async () => {
const goDetail = (id: string) => router.push(`/documents/${id}`); const goDetail = (id: string) => router.push(`/documents/${id}`);
const handleRowClick = (row: DocumentSummary) => { const selectDocument = (row: DocumentSummary) => {
if (!row?.id) return; if (!row?.id) return;
selectedDocumentId.value = row.id;
};
const handleDocumentRowClick = (row: DocumentSummary) => {
if (!row?.id) return;
if (isDesktop) {
selectDocument(row);
return;
}
goDetail(row.id); goDetail(row.id);
}; };
const openDocumentDetail = (row?: DocumentSummary | null) => {
const target = row?.id ? row : selectedDocument.value;
if (target?.id) goDetail(target.id);
};
const openSelectedDocument = () => {
closeDocumentContextMenu();
openDocumentDetail(selectedDocument.value);
};
const openSelectedDocumentEditor = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) openEdit(target);
};
const deleteSelectedDocument = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) {
void confirmDelete(target);
}
};
const closeDocumentContextMenu = () => {
if (!documentContextMenu.value.visible) return;
documentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openDocumentContextMenu = (row: DocumentSummary, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectDocument(row);
documentContextMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 184),
y: Math.min(event.clientY, window.innerHeight - 132),
};
};
const confirmDelete = async (row: DocumentSummary) => { const confirmDelete = async (row: DocumentSummary) => {
if (!canDelete.value) { if (!canDelete.value) {
ElMessage.warning("权限不足"); ElMessage.warning("权限不足");
@@ -457,6 +595,22 @@ watch(
} }
} }
); );
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedDocumentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedDocumentId.value)) {
selectedDocumentId.value = rows[0].id;
}
},
{ immediate: true }
);
</script> </script>
<style scoped> <style scoped>
@@ -551,6 +705,147 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
.document-workbench {
min-width: 0;
min-height: 0;
}
.document-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
min-height: min(570px, calc(100vh - 178px));
}
.document-table-pane {
min-width: 0;
outline: none;
}
.document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1;
}
.document-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.document-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.document-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.document-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.document-context-menu button.danger {
color: #b42318;
}
.document-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.ctms-table { .ctms-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -583,6 +878,10 @@ watch(
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.ctms-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.ctms-table :deep(.font-semibold) { .ctms-table :deep(.font-semibold) {
font-weight: 600; font-weight: 600;
color: #0a0a0a; color: #0a0a0a;
@@ -628,6 +927,44 @@ watch(
.ctms-table :deep(.cell-actions .el-button + .el-button) { .ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0; margin-left: 0;
} }
:global([data-ctms-theme="dark"] .document-page--desktop .document-table-section) {
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .document-page--desktop .ctms-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style> </style>
<style> <style>
@@ -85,4 +85,36 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('status === "EXCEPTION"'); expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true'); expect(source).not.toContain('remark: [{ required: true');
}); });
it("keeps desktop shipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="shipment-workbench"');
expect(source).toContain('class="shipment-preview-pane"');
expect(source).toContain('ref="shipmentTablePaneRef"');
expect(source).toContain("selectedShipment");
expect(source).toContain('@row-click="handleShipmentRowClick"');
expect(source).toContain('@row-dblclick="openShipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedShipment"');
expect(source).toContain("selectShipment(row);");
expect(source).toContain("shipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("router.push(`/drug/shipments/${row.id}`);");
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop shipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"');
expect(source).toContain(':disabled="isInactiveSite(selectedShipment.center_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
}); });
+367 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner"> <div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== --> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card"> <div class="table-card">
@@ -47,14 +47,24 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="shipmentTablePaneRef"
class="shipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedShipment"
>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="sortedItems" :data="sortedItems"
class="shipment-table" class="shipment-table"
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
highlight-current-row
:row-class-name="shipmentRowClass" :row-class-name="shipmentRowClass"
@row-click="onRowClick" @row-click="handleShipmentRowClick"
@row-dblclick="openShipmentDetail"
@row-contextmenu="openShipmentContextMenu"
> >
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip> <el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
@@ -96,7 +106,7 @@
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span> <span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button> <el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
@@ -123,9 +133,88 @@
</template> </template>
</el-table> </el-table>
</div> </div>
<aside v-if="isDesktop" class="shipment-preview-pane">
<template v-if="selectedShipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前发运</div>
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.direction }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
<dt>{{ TEXT.common.fields.shipDate }}</dt>
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
<dt>{{ TEXT.common.fields.quantity }}</dt>
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.batchNo }}</dt>
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.carrier }}</dt>
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.remark }}</dt>
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedShipment">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedShipment.center_id)" @click="openSelectedShipmentEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</aside>
</div>
</div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<div
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
class="shipment-context-menu"
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedShipment">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<!-- ==================== 编辑抽屉 ==================== --> <!-- ==================== 编辑抽屉 ==================== -->
<el-drawer <el-drawer
v-if="drawerVisible" v-if="drawerVisible"
@@ -290,6 +379,7 @@ import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard"; import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { isTauriRuntime } from "../../runtime";
type ShipmentDirection = "SEND" | "RETURN"; type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION"; type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
@@ -322,6 +412,10 @@ const drawerVisible = ref(false);
const editingId = ref(""); const editingId = ref("");
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null); const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
const isDesktop = isTauriRuntime();
const selectedShipmentId = ref("");
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const filters = reactive({ const filters = reactive({
center_id: study.currentSite?.id || "", center_id: study.currentSite?.id || "",
direction: "" as "" | ShipmentDirection, direction: "" as "" | ShipmentDirection,
@@ -363,6 +457,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
const sortedItems = computed(() => const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id))) [...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
); );
const selectedShipment = computed(() =>
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING"; const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED"; const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION"; const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
@@ -564,12 +661,69 @@ const handleSearch = () => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false; const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: ShipmentRow }) => const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim(); [
const onRowClick = (row: ShipmentRow) => { row?.id ? "clickable-row" : "",
isInactiveSite(row?.center_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const selectShipment = (row: ShipmentRow) => {
if (!row?.id) return; if (!row?.id) return;
selectedShipmentId.value = row.id;
shipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleShipmentRowClick = (row: ShipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectShipment(row);
return;
}
router.push(`/drug/shipments/${row.id}`); router.push(`/drug/shipments/${row.id}`);
}; };
const openShipmentDetail = (row?: ShipmentRow | null) => {
const target = row?.id ? row : selectedShipment.value;
if (target?.id) router.push(`/drug/shipments/${target.id}`);
};
const openSelectedShipment = () => {
closeShipmentContextMenu();
openShipmentDetail(selectedShipment.value);
};
const openSelectedShipmentEditor = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedShipment = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) {
void remove(target);
}
};
const closeShipmentContextMenu = () => {
if (!shipmentContextMenu.value.visible) return;
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectShipment(row);
shipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
};
};
const statusType = (status: string) => { const statusType = (status: string) => {
switch (status) { switch (status) {
case "PENDING": return "info"; case "PENDING": return "info";
@@ -633,6 +787,22 @@ watch(
} }
); );
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedShipmentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
selectedShipmentId.value = rows[0].id;
}
},
{ immediate: true }
);
onMounted(async () => { onMounted(async () => {
await loadSites(); await loadSites();
await load(); await load();
@@ -733,6 +903,147 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
.shipment-workbench {
min-width: 0;
min-height: 0;
}
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
min-height: min(590px, calc(100vh - 186px));
}
.shipment-table-pane {
min-width: 0;
outline: none;
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.shipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.shipment-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.shipment-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.shipment-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.shipment-context-menu button.danger {
color: #b42318;
}
.shipment-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.shipment-table { .shipment-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -771,6 +1082,10 @@ onMounted(async () => {
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap { .cell-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -1035,6 +1350,15 @@ onMounted(async () => {
gap: 12px; gap: 12px;
align-items: stretch; align-items: stretch;
} }
.shipment-workbench.is-desktop {
grid-template-columns: minmax(0, 1fr);
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 0;
}
.shipment-preview-pane {
display: none;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -1042,6 +1366,44 @@ onMounted(async () => {
width: 100%; width: 100%;
} }
} }
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style> </style>
<style> <style>
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx"; const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType }); const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }); await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
{
kind: "export",
title: "导出文件",
completedDetail: "导出文件已保存",
},
);
ElMessage.success("导出成功"); ElMessage.success("导出成功");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed); ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);