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
+325 -58
View File
@@ -4,8 +4,7 @@
<header class="sidebar-head">
<div class="sidebar-title-row">
<div>
<p class="sidebar-kicker">Workspace</p>
<h1>{{ TEXT.common.appName }} Desktop</h1>
<h1>{{ TEXT.common.appName }}</h1>
</div>
</div>
@@ -64,7 +63,7 @@
</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">
<button
v-if="!item.children?.length"
@@ -149,20 +148,10 @@
<section class="desktop-main">
<header class="desktop-toolbar">
<div class="toolbar-left">
<div class="history-controls">
<button class="icon-button" type="button" title="后退" @click="router.back()">
<el-icon><ArrowLeft /></el-icon>
</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 class="toolbar-left" data-tauri-drag-region>
<div class="toolbar-context" data-tauri-drag-region>
<div class="toolbar-title" data-tauri-drag-region>{{ desktopToolbarTitle }}</div>
<div class="toolbar-subtitle" data-tauri-drag-region>{{ desktopToolbarSubtitle }}</div>
</div>
</div>
@@ -172,6 +161,48 @@
<span>{{ desktopUpdateNoticeLabel }}</span>
</button>
<el-popover placement="bottom-end" width="340" trigger="click" popper-class="desktop-activity-popover">
<template #reference>
<button class="icon-button activity-button" type="button" title="活动">
<el-badge v-if="desktopActivityBadgeCount > 0" :value="desktopActivityBadgeValue" class="activity-badge">
<el-icon><Clock /></el-icon>
</el-badge>
<el-icon v-else><Clock /></el-icon>
</button>
</template>
<div class="activity-panel">
<div class="activity-head">
<div>
<div class="panel-title">活动</div>
<div class="panel-subtitle">上传下载导出与更新</div>
</div>
<button
v-if="hasFinishedDesktopActivities"
class="activity-clear"
type="button"
@click="clearFinishedDesktopActivities"
>
清除已结束
</button>
</div>
<div v-if="desktopActivities.length" class="activity-list">
<div v-for="item in desktopActivities" :key="item.id" class="activity-item" :class="`is-${item.status}`">
<span class="activity-mark" :class="`is-${item.status}`"></span>
<span class="activity-body">
<span class="activity-title">{{ item.title }}</span>
<span class="activity-detail">{{ item.detail || desktopActivityStatusLabel(item.status) }}</span>
</span>
<span class="activity-meta">
<span v-if="item.status === 'running' && item.progress !== null">{{ item.progress }}%</span>
<span v-else>{{ desktopActivityStatusLabel(item.status) }}</span>
<small>{{ formatDesktopActivityTime(item.updatedAt) }}</small>
</span>
</div>
</div>
<div v-else class="activity-empty">暂无活动</div>
</div>
</el-popover>
<button class="command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>命令</span>
@@ -367,8 +398,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Box,
Calendar,
@@ -409,6 +438,13 @@ import {
listenDesktopUpdateStatus,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import {
clearFinishedDesktopActivities,
getDesktopActivities,
listenDesktopActivities,
type DesktopActivityItem,
type DesktopActivityStatus,
} from "../session/desktopActivityCenter";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getAppMetadata,
@@ -442,6 +478,7 @@ const router = useRouter();
const route = useRoute();
const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
@@ -456,6 +493,7 @@ const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
@@ -478,6 +516,7 @@ const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
const iconComponentMap = {
audit: Document,
@@ -555,6 +594,32 @@ const desktopUpdateNoticeLabel = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
return pending ? `新版本 ${pending.version}` : "";
});
const desktopActivityBadgeCount = computed(() =>
desktopActivities.value.filter((item) => item.status === "running" || item.status === "failed").length
);
const desktopActivityBadgeValue = computed(() => (desktopActivityBadgeCount.value > 9 ? "9+" : desktopActivityBadgeCount.value));
const hasFinishedDesktopActivities = computed(() =>
desktopActivities.value.some((item) => item.status === "completed" || item.status === "cancelled" || item.status === "failed")
);
const desktopActivityStatusLabel = (status: DesktopActivityStatus) => {
switch (status) {
case "running":
return "进行中";
case "completed":
return "完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "";
}
};
const formatDesktopActivityTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
@@ -565,20 +630,26 @@ const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
});
const desktopBreadcrumbs = computed(() => {
const desktopToolbarTitle = computed(() => {
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);
if (isAdminRoute) {
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);
if (isProjectRoute) {
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"));
@@ -1015,6 +1086,9 @@ onMounted(async () => {
desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
desktopActivityUnlisten = listenDesktopActivities((items) => {
desktopActivities.value = items;
});
if (auth.token && !auth.user) {
try {
await auth.fetchMe();
@@ -1031,6 +1105,7 @@ onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
});
useDesktopShortcuts(
@@ -1098,7 +1173,7 @@ useDesktopShortcuts(
.sidebar-head {
flex: 0 0 auto;
padding: 18px 16px 14px;
padding: 54px 16px 14px;
border-bottom: 1px solid #dfe7f0;
}
@@ -1109,14 +1184,6 @@ useDesktopShortcuts(
gap: 12px;
}
.sidebar-kicker {
margin: 0 0 4px;
color: #66778d;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.sidebar-head h1 {
margin: 0;
overflow: hidden;
@@ -1295,7 +1362,7 @@ useDesktopShortcuts(
.desktop-main {
display: grid;
grid-template-rows: 48px auto minmax(0, 1fr);
grid-template-rows: 52px auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
@@ -1312,15 +1379,16 @@ useDesktopShortcuts(
}
.toolbar-left,
.toolbar-right,
.history-controls {
.toolbar-right {
display: flex;
align-items: center;
min-width: 0;
}
.toolbar-left {
gap: 10px;
align-self: stretch;
cursor: default;
user-select: none;
}
.toolbar-right {
@@ -1328,35 +1396,55 @@ useDesktopShortcuts(
gap: 8px;
}
.history-controls {
gap: 3px;
.toolbar-right,
.toolbar-right :deep(*),
.desktop-sidebar button,
.desktop-sidebar :deep(.el-dropdown),
.workspace-tabs,
.workspace-tabs *,
.workspace-tab-context-menu,
.workspace-tab-context-menu * {
-webkit-app-region: no-drag;
}
.breadcrumb-strip {
display: flex;
.toolbar-left[data-tauri-drag-region],
.toolbar-context[data-tauri-drag-region],
.toolbar-title[data-tauri-drag-region],
.toolbar-subtitle[data-tauri-drag-region] {
-webkit-app-region: drag;
}
.toolbar-context {
display: grid;
min-width: 0;
align-items: center;
gap: 6px;
align-content: center;
gap: 2px;
width: 100%;
height: 100%;
overflow: hidden;
}
.breadcrumb-chip {
display: inline-flex;
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;
.toolbar-title,
.toolbar-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-title {
color: #142033;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
}
.toolbar-subtitle {
color: #66778d;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.command-trigger,
.update-notice,
.connection-button,
@@ -1555,6 +1643,151 @@ useDesktopShortcuts(
text-align: center;
}
.activity-button {
position: relative;
}
.activity-badge {
display: inline-flex;
}
.activity-badge :deep(.el-badge__content) {
height: 16px;
min-width: 16px;
padding: 0 4px;
border: 2px solid #ffffff;
background: #3f5d75;
font-size: 10px;
line-height: 12px;
}
.activity-panel {
width: 320px;
padding: 10px;
}
.activity-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 4px 4px 10px;
}
.activity-clear {
appearance: none;
height: 24px;
padding: 0 7px;
border: 1px solid #d2dce8;
border-radius: 6px;
background: #ffffff;
color: #52667e;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.activity-clear:hover {
background: #eef4fb;
color: #183756;
}
.activity-list {
display: flex;
max-height: 286px;
flex-direction: column;
gap: 3px;
overflow-y: auto;
}
.activity-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-height: 46px;
padding: 7px;
border-radius: 7px;
}
.activity-item:hover {
background: #f4f7fa;
}
.activity-mark {
width: 7px;
height: 28px;
border-radius: 999px;
background: #7b8da3;
}
.activity-mark.is-running {
background: #3f5d75;
}
.activity-mark.is-completed {
background: #3f8f6b;
}
.activity-mark.is-failed {
background: #c24b4b;
}
.activity-mark.is-cancelled {
background: #9aaabd;
}
.activity-body {
display: inline-flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.activity-title,
.activity-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-title {
color: #142033;
font-size: 13px;
font-weight: 800;
}
.activity-detail {
color: #7b8da3;
font-size: 12px;
font-weight: 600;
}
.activity-meta {
display: inline-flex;
min-width: 44px;
flex-direction: column;
align-items: flex-end;
gap: 2px;
color: #52667e;
font-size: 12px;
font-weight: 800;
}
.activity-meta small {
color: #9aaabd;
font-size: 10px;
font-weight: 700;
}
.activity-empty {
padding: 24px 8px;
color: #8a9bb0;
font-size: 13px;
text-align: center;
}
.account-button {
gap: 7px;
max-width: 188px;
@@ -1780,14 +2013,15 @@ useDesktopShortcuts(
border-bottom-color: #26364a;
}
:global([data-ctms-theme="dark"] .sidebar-kicker),
:global([data-ctms-theme="dark"] .section-label),
: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;
}
:global([data-ctms-theme="dark"] .sidebar-head h1),
:global([data-ctms-theme="dark"] .toolbar-title),
:global([data-ctms-theme="dark"] .panel-title),
:global([data-ctms-theme="dark"] .panel-row code),
:global([data-ctms-theme="dark"] .reminder-body span) {
@@ -1809,7 +2043,6 @@ useDesktopShortcuts(
:global([data-ctms-theme="dark"] .update-notice),
:global([data-ctms-theme="dark"] .connection-button),
:global([data-ctms-theme="dark"] .account-button),
:global([data-ctms-theme="dark"] .breadcrumb-chip),
:global([data-ctms-theme="dark"] .workspace-tab) {
border-color: #334155;
background: #172033;
@@ -1895,6 +2128,40 @@ useDesktopShortcuts(
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-item:hover) {
background: #1f2d3d;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-title) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-detail),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta small),
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-meta) {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear) {
border-color: #334155;
background: #172033;
color: #cbd5e1;
}
:global([data-ctms-theme="dark"] .desktop-activity-popover .activity-clear:hover) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .panel-row) {
color: #94a3b8;
}
+78 -3
View File
@@ -6,14 +6,17 @@ const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"),
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
const readTauriConfigSource = () => readFileSync(resolve(__dirname, "../../src-tauri/tauri.conf.json"), "utf8");
const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8");
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8");
const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8");
const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.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", () => {
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", () => {
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('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel");
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 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");
});
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", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -218,7 +251,7 @@ describe("desktop layout shell", () => {
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).not.toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
@@ -233,9 +266,15 @@ describe("desktop layout shell", () => {
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
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("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(attachments).toContain("startDesktopActivity");
expect(attachments).toContain('title: "下载附件"');
expect(attachments).toContain('title: "上传附件"');
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
@@ -243,6 +282,42 @@ describe("desktop layout shell", () => {
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", () => {
const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource();
@@ -519,7 +519,14 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => {
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 = () => {
@@ -154,6 +154,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
type AttachmentEntityGroup = {
entityType: string;
@@ -253,6 +254,7 @@ const tableProgress = computed(() => {
return group ? progressMap[group.key] || 0 : 0;
});
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const desktopActivityEnabled = () => nativeFiles;
const validateFile = (file: File) => {
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)}`));
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 (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
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;
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) {
const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = [];
@@ -320,19 +339,36 @@ const uploadPending = async (entityId?: string) => {
try {
item.status = "uploading";
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) {
hasFailure = true;
item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item);
} finally {
completedCount += 1;
if (activityId && totalCount) {
updateDesktopActivity(activityId, {
detail: `已处理 ${completedCount}/${totalCount}`,
progress: Math.min(99, Math.round((completedCount / totalCount) * 100)),
});
}
progressMap[group.key] = 0;
}
}
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) => {
@@ -340,11 +376,18 @@ const uploadImmediate = async (options: any) => {
const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return;
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "upload", title: "上传附件", detail: "正在上传附件", progress: 0 })
: "";
try {
await uploadFile(group, file, props.entityId);
ElMessage.success(TEXT.common.messages.uploadSuccess);
await uploadFile(group, file, props.entityId, (progress) => {
updateDesktopActivity(activityId, { detail: "正在上传附件", progress });
});
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
if (!desktopActivityEnabled()) ElMessage.success(TEXT.common.messages.uploadSuccess);
load();
} catch (e: any) {
finishDesktopActivity(activityId, "failed", { detail: "附件上传失败" });
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[group.key] = 0;
@@ -399,25 +442,48 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
};
const download = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "download", title: "下载附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "等待选择保存位置", progress: 65 });
await saveFileWithFeedback({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "下载附件",
pendingDetail: "等待选择保存位置",
completedDetail: "附件已保存",
cancelledDetail: "已取消保存附件",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件下载失败" });
ElMessage.error(TEXT.common.messages.downloadFailed);
}
};
const openExternally = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "open", title: "打开附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "正在准备文件", progress: 65 });
await openFileWithFeedback({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "打开附件",
pendingDetail: "正在准备文件",
completedDetail: "已交给系统打开",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件打开失败" });
ElMessage.error(TEXT.common.messages.previewNotSupported);
}
};