feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
This commit is contained in:
Cheng Zhou
2026-07-01 17:04:23 +08:00
parent 593c60c782
commit 8c8327df92
19 changed files with 2098 additions and 612 deletions
+441 -165
View File
@@ -1,15 +1,12 @@
<template>
<div class="desktop-workbench">
<div class="desktop-workbench" @click="closeWorkspaceTabMenu">
<aside class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
<div>
<p class="sidebar-kicker">Workspace</p>
<h1>{{ sidebarTitle }}</h1>
<h1>{{ TEXT.common.appName }} Desktop</h1>
</div>
<button class="icon-button" type="button" title="搜索命令" @click="openCommandPalette">
<el-icon><Search /></el-icon>
</button>
</div>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
@@ -66,6 +63,47 @@
</button>
</section>
<section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.admin }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
<button
v-if="!item.children?.length"
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button
type="button"
class="sidebar-link group-title"
:class="{ active: isNavigationGroupActive(item) }"
:aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button>
<div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</template>
</section>
<section v-if="projectNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.currentProject }}</div>
<template v-for="item in projectNavigationItems" :key="item.path">
@@ -79,54 +117,33 @@
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<div v-else class="sidebar-group">
<div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button
type="button"
class="sidebar-link group-title"
:class="{ active: item.children.some((child) => activeMenu === child.path) }"
@click="router.push(item.path)"
:class="{ active: isNavigationGroupActive(item) }"
:aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button>
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
<div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</template>
</section>
<section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.admin }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
<button
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path || item.children?.some((child) => activeMenu === child.path) }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<button
v-for="child in item.children || []"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</template>
</section>
</div>
</aside>
@@ -150,6 +167,11 @@
</div>
<div class="toolbar-right">
<button v-if="desktopUpdateNoticeVisible" class="update-notice" type="button" title="打开系统偏好安装更新" @click="openDesktopPreferences">
<el-icon><Download /></el-icon>
<span>{{ desktopUpdateNoticeLabel }}</span>
</button>
<button class="command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>命令</span>
@@ -177,7 +199,7 @@
<span>客户端</span>
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
</div>
<el-button size="small" @click="router.push('/desktop/server-settings')">服务器设置</el-button>
<el-button size="small" @click="openDesktopPreferences">系统偏好</el-button>
</div>
</el-popover>
@@ -252,9 +274,15 @@
v-for="item in workspaceTabs"
:key="item.path"
class="workspace-tab"
:class="{ active: activeMenu === item.path }"
:class="{ active: activeMenu === item.path, dragging: draggingWorkspaceTabPath === item.path }"
draggable="true"
@dragstart="startWorkspaceTabDrag(item.path, $event)"
@dragover="allowWorkspaceTabDrop"
@drop.prevent="dropWorkspaceTab(item.path, $event)"
@dragend="draggingWorkspaceTabPath = ''"
@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"
>
<button class="workspace-tab-action" type="button" @click="router.push(item.path)">
<button class="workspace-tab-action" type="button" @click="navigateWorkspaceTab(item.path)">
<span>{{ item.title }}</span>
<span class="tab-group">{{ item.group }}</span>
</button>
@@ -267,24 +295,17 @@
<main class="desktop-content">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="desktop-route" mode="out-in">
<div :key="currentRoute.fullPath" class="desktop-route-shell">
<component v-if="Component" :is="Component" />
</div>
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
<component
:is="Component"
v-if="Component"
:key="desktopRouteCacheKey(currentRoute)"
class="desktop-route-shell"
/>
</KeepAlive>
</transition>
</router-view>
</main>
<footer class="desktop-statusbar">
<span class="status-item">
<span class="status-dot" :class="desktopConnectionClass"></span>
{{ desktopServerUrl || "未配置服务器" }}
</span>
<span v-if="study.currentStudy" class="status-item">{{ study.currentStudy.name }}</span>
<span v-if="accountProjectRoleLabel" class="status-item">{{ accountProjectRoleLabel }}</span>
<span v-if="projectStatusLabel" class="status-item">{{ projectStatusLabel }}</span>
<span class="status-spacer"></span>
<span class="status-item">CTMS Desktop {{ desktopMetadata.version }}</span>
</footer>
</section>
</div>
@@ -314,12 +335,31 @@
class="desktop-preferences-dialog"
:show-close="false"
:close-on-click-modal="true"
width="720px"
width="940px"
align-center
destroy-on-close
>
<DesktopPreferences @close-request="desktopPreferencesVisible = false" />
</el-dialog>
<div
v-if="workspaceTabMenu.visible && workspaceTabMenu.tab"
class="workspace-tab-context-menu"
:style="{ left: `${workspaceTabMenu.x}px`, top: `${workspaceTabMenu.y}px` }"
@click.stop
>
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
<button type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
</button>
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
<button type="button" :disabled="workspaceTabs.length <= 1" @click="closeOtherWorkspaceTabs(workspaceTabMenu.tab.path)">
关闭其他标签
</button>
<button type="button" :disabled="!lastClosedWorkspaceTab" @click="restoreLastClosedWorkspaceTab">
重新打开最近关闭
</button>
</div>
</template>
<script setup lang="ts">
@@ -337,6 +377,7 @@ import {
Coin,
DataAnalysis,
Document,
Download,
Files,
Flag,
Folder,
@@ -362,7 +403,12 @@ import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import {
checkDesktopUpdateAndPrompt,
getDesktopUpdateStatus,
listenDesktopUpdateStatus,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getAppMetadata,
@@ -387,6 +433,7 @@ import {
flattenLayoutNavigationItems,
getActiveLayoutPath,
type LayoutNavigationIcon,
type LayoutNavigationItem,
} from "./layout/navigation";
const auth = useAuthStore();
@@ -394,6 +441,7 @@ const study = useStudyStore();
const router = useRouter();
const route = useRoute();
const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
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");
@@ -407,13 +455,29 @@ const desktopPreferencesVisible = ref(false);
const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const workspaceTabMenu = ref<{
visible: boolean;
x: number;
y: number;
tab: DesktopRoutePreference | null;
}>({
visible: false,
x: 0,
y: 0,
tab: null,
});
const expandedNavigationGroups = ref<Set<string>>(new Set());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
const iconComponentMap = {
audit: Document,
@@ -436,6 +500,21 @@ const iconComponentMap = {
const iconFor = (icon: LayoutNavigationIcon) => iconComponentMap[icon] || Folder;
const activeMenu = computed(() => getActiveLayoutPath(route.path));
const navigationGroupKey = (item: LayoutNavigationItem) => `${item.group}:${item.label}:${item.path}`;
const isNavigationGroupActive = (item: LayoutNavigationItem) =>
Boolean(item.children?.some((child) => activeMenu.value === child.path));
const isNavigationGroupExpanded = (item: LayoutNavigationItem) =>
isNavigationGroupActive(item) || expandedNavigationGroups.value.has(navigationGroupKey(item));
const toggleNavigationGroup = (item: LayoutNavigationItem) => {
const key = navigationGroupKey(item);
const next = new Set(expandedNavigationGroups.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
expandedNavigationGroups.value = next;
};
const adminNavigationItems = computed(() =>
buildAdminNavigationItems({
hasUser: Boolean(auth.user),
@@ -450,8 +529,10 @@ const projectNavigationItems = computed(() =>
canAccessProjectPath,
}),
);
const adminDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(adminNavigationItems.value));
const projectDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(projectNavigationItems.value));
const desktopNavigationItems = computed(() =>
flattenLayoutNavigationItems([...adminNavigationItems.value, ...projectNavigationItems.value]),
[...adminDesktopNavigationItems.value, ...projectDesktopNavigationItems.value],
);
const userDisplayName = computed(
@@ -461,17 +542,18 @@ const userDisplayInitial = computed(() => {
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
return source.charAt(0).toUpperCase();
});
const sidebarTitle = computed(() => study.currentStudy?.name || TEXT.menu.admin);
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
const accountProjectRoleLabel = computed(() => {
if (isAdminContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
});
const projectStatusLabel = computed(() => {
const status = (study.currentStudy?.status || "").toUpperCase();
return status ? ((TEXT.enums.projectStatus as Record<string, string>)[status] || study.currentStudy?.status || "") : "";
const desktopUpdateNoticeVisible = computed(
() =>
Boolean(desktopUpdateStatus.value.pendingUpdate) &&
!desktopUpdateStatus.value.checking &&
!desktopUpdateStatus.value.installing &&
!["postponed", "suppressed"].includes(desktopUpdateStatus.value.lastStatus),
);
const desktopUpdateNoticeLabel = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
return pending ? `新版本 ${pending.version}` : "";
});
const currentDesktopRoutePreference = computed(() => {
@@ -486,9 +568,17 @@ const currentRouteFavorited = computed(() => {
const desktopBreadcrumbs = computed(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return [String(route.meta?.title || "")].filter(Boolean);
if (current.group === TEXT.menu.admin) return [TEXT.menu.admin, current.title];
const root = study.currentStudy?.name || TEXT.menu.currentProject;
return [root, current.group || TEXT.menu.currentProject, current.title].filter(Boolean);
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);
}
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);
}
return [current.group, current.title].filter(Boolean);
});
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
@@ -534,14 +624,78 @@ const refreshDesktopRoutePreferences = () => {
const addWorkspaceTab = (routePreference: Pick<DesktopRoutePreference, "path" | "title" | "group">) => {
const item = { ...routePreference, updatedAt: new Date().toISOString() };
workspaceTabs.value = [
...workspaceTabs.value.filter((tab) => tab.path !== item.path),
item,
].slice(-7);
const existingIndex = workspaceTabs.value.findIndex((tab) => tab.path === item.path);
if (existingIndex >= 0) {
const next = [...workspaceTabs.value];
next[existingIndex] = { ...next[existingIndex], ...item };
workspaceTabs.value = next;
return;
}
workspaceTabs.value = [...workspaceTabs.value, item];
};
const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;
const navigateWorkspaceTab = (path: string) => {
if (activeMenu.value === path) return;
void router.push(path);
};
const closeWorkspaceTabMenu = () => {
if (!workspaceTabMenu.value.visible) return;
workspaceTabMenu.value = { visible: false, x: 0, y: 0, tab: null };
};
const openWorkspaceTabMenu = (tab: DesktopRoutePreference, event: MouseEvent) => {
workspaceTabMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 196),
y: Math.min(event.clientY, window.innerHeight - 184),
tab,
};
};
const navigateWorkspaceTabFromMenu = (path: string) => {
navigateWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
refreshDesktopRoutePreferences();
closeWorkspaceTabMenu();
};
const startWorkspaceTabDrag = (path: string, event: DragEvent) => {
draggingWorkspaceTabPath.value = path;
event.dataTransfer?.setData("text/plain", path);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
};
const allowWorkspaceTabDrop = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
};
const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
const sourcePath = draggingWorkspaceTabPath.value || event.dataTransfer?.getData("text/plain") || "";
draggingWorkspaceTabPath.value = "";
if (!sourcePath || sourcePath === targetPath) return;
const sourceIndex = workspaceTabs.value.findIndex((tab) => tab.path === sourcePath);
const targetIndex = workspaceTabs.value.findIndex((tab) => tab.path === targetPath);
if (sourceIndex < 0 || targetIndex < 0) return;
const next = [...workspaceTabs.value];
const [moved] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, moved);
workspaceTabs.value = next;
};
const closeWorkspaceTab = (path: string) => {
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index];
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
if (activeMenu.value !== path) return;
const next = workspaceTabs.value[index] || workspaceTabs.value[index - 1] || desktopRecentRoutes.value[0];
@@ -552,6 +706,28 @@ const closeWorkspaceTab = (path: string) => {
}
};
const closeWorkspaceTabFromMenu = (path: string) => {
closeWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const closeOtherWorkspaceTabs = (path: string) => {
const target = workspaceTabs.value.find((tab) => tab.path === path);
if (!target) return;
workspaceTabs.value = [target];
if (activeMenu.value !== path) void router.push(path);
closeWorkspaceTabMenu();
};
const restoreLastClosedWorkspaceTab = () => {
const tab = lastClosedWorkspaceTab.value;
if (!tab) return;
addWorkspaceTab(tab);
void router.push(tab.path);
lastClosedWorkspaceTab.value = null;
closeWorkspaceTabMenu();
};
const updateDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
};
@@ -586,7 +762,7 @@ const handleDesktopMenuCommand = (command: string) => {
return;
}
if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings");
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.refresh") {
@@ -625,6 +801,14 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
await router.push("/project/overview");
},
}));
const workspaceTabCommands: DesktopCommand[] = workspaceTabs.value.map((item) => ({
id: `workspace-tab:${item.path}`,
title: `切换标签:${item.title}`,
group: "标签页",
keywords: [item.title, item.group, item.path].filter((value): value is string => Boolean(value)),
visible: true,
run: () => navigateWorkspaceTab(item.path),
}));
return [
{
id: "desktop:refresh",
@@ -644,12 +828,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
},
{
id: "desktop:server-settings",
title: "服务器设置",
title: "连接设置",
group: "桌面",
visible: true,
run: () => {
void router.push("/desktop/server-settings");
},
run: openDesktopPreferences,
},
{
id: "desktop:update",
@@ -667,6 +849,14 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
visible: Boolean(currentDesktopRoutePreference.value),
run: toggleCurrentFavorite,
},
{
id: "desktop:restore-closed-tab",
title: "重新打开最近关闭标签",
group: "标签页",
visible: Boolean(lastClosedWorkspaceTab.value),
run: restoreLastClosedWorkspaceTab,
},
...workspaceTabCommands,
...navigationCommands,
...projectCommands,
];
@@ -822,6 +1012,9 @@ onMounted(async () => {
}
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
if (auth.token && !auth.user) {
try {
await auth.fetchMe();
@@ -837,6 +1030,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
});
useDesktopShortcuts(
@@ -844,6 +1038,10 @@ useDesktopShortcuts(
{
openCommandPalette,
closeActiveLayer: () => {
if (workspaceTabMenu.value.visible) {
closeWorkspaceTabMenu();
return true;
}
if (commandPaletteVisible.value) {
commandPaletteVisible.value = false;
return true;
@@ -1076,12 +1274,28 @@ useDesktopShortcuts(
}
.group-title {
grid-template-columns: 18px minmax(0, 1fr) 14px;
font-weight: 700;
}
.group-expander {
justify-self: end;
color: #7a8ca2;
font-size: 12px;
transition: transform 0.16s ease;
}
.sidebar-group.expanded .group-expander {
transform: rotate(180deg);
}
.sidebar-children {
padding: 2px 0 4px;
}
.desktop-main {
display: grid;
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
grid-template-rows: 48px auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
@@ -1144,6 +1358,7 @@ useDesktopShortcuts(
}
.command-trigger,
.update-notice,
.connection-button,
.account-button {
appearance: none;
@@ -1160,6 +1375,21 @@ useDesktopShortcuts(
font-weight: 700;
}
.update-notice {
gap: 6px;
max-width: 138px;
padding: 0 9px;
border-color: #d8bd6c;
background: #fff8e5;
color: #6f5317;
}
.update-notice span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-trigger {
gap: 7px;
padding: 0 8px;
@@ -1180,16 +1410,14 @@ useDesktopShortcuts(
padding: 0 9px;
}
.connection-dot,
.status-dot {
.connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #c58b2a;
}
.connection-dot.is-connected,
.status-dot.is-connected {
.connection-dot.is-connected {
background: #3f8f6b;
}
@@ -1376,16 +1604,25 @@ useDesktopShortcuts(
border-radius: 8px;
background: #ffffff;
color: #3d5068;
cursor: grab;
font-size: 12px;
font-weight: 700;
}
.workspace-tab:active {
cursor: grabbing;
}
.workspace-tab.active {
border-color: #aebfd1;
background: #eaf1f8;
color: #183756;
}
.workspace-tab.dragging {
opacity: 0.58;
}
.workspace-tab-action {
appearance: none;
display: grid;
@@ -1430,6 +1667,48 @@ useDesktopShortcuts(
color: #142033;
}
.workspace-tab-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 184px;
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);
}
.workspace-tab-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;
}
.workspace-tab-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.workspace-tab-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.desktop-content {
min-width: 0;
min-height: 0;
@@ -1442,32 +1721,6 @@ useDesktopShortcuts(
min-height: 100%;
}
.desktop-statusbar {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 0 12px;
border-top: 1px solid #d7e0ea;
background: #f8fafc;
color: #66778d;
font-size: 11px;
}
.status-item {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-spacer {
flex: 1;
}
.desktop-route-enter-active,
.desktop-route-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
@@ -1487,6 +1740,8 @@ useDesktopShortcuts(
:global(.desktop-preferences-dialog) {
overflow: hidden;
border-radius: 14px;
/* dialog 容器本身设置侧边栏同色背景,彻底兜底 */
background: #f2f4f7 !important;
}
:global(.profile-settings-dialog .el-dialog__header),
@@ -1499,147 +1754,167 @@ useDesktopShortcuts(
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px;
background: #f8fafc;
/* 背景与侧边栏一致,避免内容高度不足时白色透出 */
padding: 0;
background: #f2f4f7;
display: flex;
flex-direction: column;
}
:global([data-ctms-theme="dark"]) .desktop-workbench {
:global(.desktop-preferences-dialog .el-dialog__body > *) {
flex: 1;
min-height: 0;
}
:global([data-ctms-theme="dark"] .desktop-workbench) {
background: #0f172a;
color: #e5edf7;
}
:global([data-ctms-theme="dark"]) .desktop-sidebar {
:global([data-ctms-theme="dark"] .desktop-sidebar) {
border-right-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"]) .sidebar-head {
:global([data-ctms-theme="dark"] .sidebar-head) {
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"]) .desktop-statusbar,
:global([data-ctms-theme="dark"]) .status-item {
: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) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"]) .sidebar-head h1,
:global([data-ctms-theme="dark"]) .panel-title,
:global([data-ctms-theme="dark"]) .panel-row code,
:global([data-ctms-theme="dark"]) .reminder-body span {
:global([data-ctms-theme="dark"] .sidebar-head h1),
:global([data-ctms-theme="dark"] .panel-title),
:global([data-ctms-theme="dark"] .panel-row code),
:global([data-ctms-theme="dark"] .reminder-body span) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .icon-button {
:global([data-ctms-theme="dark"] .icon-button) {
color: #9aaabd;
}
:global([data-ctms-theme="dark"]) .icon-button:hover {
:global([data-ctms-theme="dark"] .icon-button:hover) {
border-color: #334155;
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .study-switcher-trigger,
:global([data-ctms-theme="dark"]) .command-trigger,
: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 {
:global([data-ctms-theme="dark"] .study-switcher-trigger),
:global([data-ctms-theme="dark"] .command-trigger),
: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;
color: #dbe5f1;
}
:global([data-ctms-theme="dark"]) .study-switcher-trigger.empty {
:global([data-ctms-theme="dark"] .study-switcher-trigger.empty) {
color: #bfdbfe;
}
:global([data-ctms-theme="dark"]) .sidebar-link {
:global([data-ctms-theme="dark"] .sidebar-link) {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"]) .sidebar-link.child {
:global([data-ctms-theme="dark"] .sidebar-link.child) {
color: #9aaabd;
}
:global([data-ctms-theme="dark"]) .sidebar-link:hover {
:global([data-ctms-theme="dark"] .sidebar-link:hover) {
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .sidebar-link.active,
:global([data-ctms-theme="dark"]) .workspace-tab.active {
:global([data-ctms-theme="dark"] .sidebar-link.active),
:global([data-ctms-theme="dark"] .workspace-tab.active) {
border-color: #3e5c77;
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"]) .desktop-toolbar {
:global([data-ctms-theme="dark"] .desktop-toolbar) {
border-bottom-color: #26364a;
background: rgba(17, 24, 39, 0.92);
}
:global([data-ctms-theme="dark"]) .workspace-tabs {
:global([data-ctms-theme="dark"] .workspace-tabs) {
border-bottom-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"]) .desktop-content {
:global([data-ctms-theme="dark"] .desktop-content) {
background: #0f172a;
}
:global([data-ctms-theme="dark"]) .desktop-statusbar {
border-top-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"]) .command-trigger kbd {
:global([data-ctms-theme="dark"] .command-trigger kbd) {
border-color: #334155;
background: #0f172a;
color: #94a3b8;
}
:global([data-ctms-theme="dark"]) .account-avatar {
:global([data-ctms-theme="dark"] .account-avatar) {
background: #3e5c77;
}
:global([data-ctms-theme="dark"]) .tab-close {
:global([data-ctms-theme="dark"] .workspace-tab-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:disabled) {
color: #64748b;
}
:global([data-ctms-theme="dark"] .tab-close) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"]) .tab-close:hover {
:global([data-ctms-theme="dark"] .tab-close:hover) {
background: rgba(226, 232, 240, 0.1);
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .connection-panel {
:global([data-ctms-theme="dark"] .connection-panel) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"]) .panel-row {
:global([data-ctms-theme="dark"] .panel-row) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"]) .reminder-total {
:global([data-ctms-theme="dark"] .reminder-total) {
background: rgba(242, 139, 139, 0.14);
color: #fecaca;
}
:global([data-ctms-theme="dark"]) .reminder-body small,
:global([data-ctms-theme="dark"]) .reminder-empty {
:global([data-ctms-theme="dark"] .reminder-body small),
:global([data-ctms-theme="dark"] .reminder-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"]) .sidebar-scroll::-webkit-scrollbar-thumb {
:global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb) {
border-color: #111827;
background: #334155;
}
:global([data-ctms-theme="dark"]) .sidebar-scroll::-webkit-scrollbar-thumb:hover {
:global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb:hover) {
background: #475569;
}
@@ -1649,7 +1924,8 @@ useDesktopShortcuts(
}
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
background: #111827;
/* 与深色侧边栏 --pref-bg-sidebar 保持一致 */
background: #131b28;
}
@media (prefers-reduced-motion: reduce) {
@@ -6,6 +6,13 @@ 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 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 readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.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");
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
@@ -34,4 +41,192 @@ describe("desktop layout shell", () => {
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath");
});
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).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("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
const webLayout = readWebLayoutSource();
expect(preferences).toContain('class="preferences-sidebar"');
expect(preferences).toContain('class="preferences-pane"');
expect(preferences).toContain("const activeSectionId = ref<PreferenceSectionId>");
expect(preferences).toContain('grid-template-columns: 232px minmax(0, 1fr);');
expect(preferences).toContain("height: min(700px, calc(100vh - 96px));");
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
expect(preferences).toContain('{ id: "diagnostics", label: "诊断信息"');
expect(preferences).toContain("testDesktopServerConnection");
expect(preferences).toContain("saveDesktopServerConfig");
expect(preferences).toContain("测试连通性");
expect(preferences).not.toContain("保存服务器");
expect(preferences).toContain('ElMessage.success("服务器设置已保存")');
expect(preferences).toContain("ElMessage.error(`保存失败:${message}`)");
expect(preferences).toContain("normalizeDesktopServerUrl");
expect(preferences).toContain("确认切换服务器");
expect(preferences).toContain('<Transition name="preferences-header" mode="out-in">');
expect(preferences).toContain('<Transition name="preferences-panel" mode="out-in">');
expect(preferences).toContain('<Transition name="connection-feedback">');
expect(preferences).toContain("const selectSection = (sectionId: PreferenceSectionId)");
expect(preferences).toContain("const setConnectionPending = (message: string)");
expect(preferences).toContain("const scheduleConnectionPending = (message: string)");
expect(preferences).toContain("}, 180);");
expect(preferences).toContain('scrollTo({ top: 0, behavior: "smooth" })');
expect(preferences).not.toContain("connectionStatus.value = null");
expect(preferences).toContain("min-width: 100px;");
expect(preferences).toContain("connection-feedback-enter-active");
expect(preferences).toContain("@media (prefers-reduced-motion: reduce)");
expect(preferences).toContain(':global([data-ctms-theme="dark"] .desktop-preferences)');
expect(preferences).not.toContain(':global([data-ctms-theme="dark"]) .desktop-preferences');
expect(desktopLayout).toContain(':global([data-ctms-theme="dark"] .desktop-workbench)');
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
expect(desktopLayout).toContain('width="940px"');
expect(webLayout).toContain('width="940px"');
expect(desktopLayout).toContain('title: "连接设置"');
expect(webLayout).toContain('title: "连接设置"');
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
expect(webLayout).not.toContain('router.push("/desktop/server-settings")');
expect(desktopLayout).not.toContain('width="720px"');
expect(webLayout).not.toContain('width="720px"');
});
it("keeps desktop notification and diagnostics settings out of profile settings", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(profile).not.toContain("form-section--desktop");
expect(profile).not.toContain("客户端与通知");
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("clientMetadataRows");
expect(profile).not.toContain("desktopNotificationsEnabled");
expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows");
expect(preferences).not.toContain('{ label: "主题"');
expect(preferences).not.toContain('{ label: "服务器"');
});
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div class="workspace-tabs"');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('@click="navigateWorkspaceTab(item.path)"');
expect(tabsTemplate).toContain('draggable="true"');
expect(tabsTemplate).toContain('@dragstart="startWorkspaceTabDrag(item.path, $event)"');
expect(tabsTemplate).toContain('@drop.prevent="dropWorkspaceTab(item.path, $event)"');
expect(source).toContain("const draggingWorkspaceTabPath = ref(\"\")");
expect(source).toContain("const navigateWorkspaceTab = (path: string) => {");
expect(source).toContain("if (activeMenu.value === path) return;");
expect(source).toContain("next[existingIndex] = { ...next[existingIndex], ...item };");
expect(source).toContain("const [moved] = next.splice(sourceIndex, 1);");
expect(source).toContain("<KeepAlive :max=\"DESKTOP_WORKSPACE_TAB_CACHE_MAX\">");
expect(source).toContain(":key=\"desktopRouteCacheKey(currentRoute)\"");
expect(source).toContain("const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;");
expect(source).not.toContain('<div :key="currentRoute.fullPath" class="desktop-route-shell">');
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
expect(source).toContain("const restoreLastClosedWorkspaceTab = () => {");
expect(source).toContain('id: `workspace-tab:${item.path}`');
expect(source).toContain('title: `切换标签:${item.title}`');
expect(source).toContain('title: "重新打开最近关闭标签"');
});
it("keeps desktop navigation hierarchy aligned with the web sidebar", () => {
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
const sidebarStart = desktopLayout.indexOf('<div class="sidebar-scroll">');
const sidebarEnd = desktopLayout.indexOf("</aside>", sidebarStart);
const sidebarTemplate = desktopLayout.slice(sidebarStart, sidebarEnd);
expect(sidebarTemplate.indexOf('v-if="adminNavigationItems.length"')).toBeLessThan(
sidebarTemplate.indexOf('v-if="projectNavigationItems.length"'),
);
expect(sidebarTemplate).toContain('@click="toggleNavigationGroup(item)"');
expect(sidebarTemplate).toContain(':aria-expanded="isNavigationGroupExpanded(item)"');
expect(sidebarTemplate).toContain('v-show="isNavigationGroupExpanded(item)"');
expect(desktopLayout).toContain("const expandedNavigationGroups = ref<Set<string>>(new Set());");
expect(navigation).toContain('label: "系统设置"');
expect(navigation).toContain('label: "邮件服务"');
expect(navigation).toContain('group: "系统设置"');
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("keeps notification and update preferences lightweight", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
expect(serverSettings).toContain("connectionDiagnostic");
expect(serverSettings).not.toContain("复制连接诊断");
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
expect(serverSettings).toContain("确认切换服务器");
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
expect(serverSettings).not.toContain("服务器连接已确认");
expect(preferences).toContain("connectionDiagnostic");
expect(preferences).not.toContain("复制连接诊断");
expect(preferences).not.toContain("copyConnectionDiagnostic");
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).not.toContain("系统通知已开启");
expect(preferences).not.toContain("系统通知已关闭");
expect(preferences).not.toContain("通知诊断");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
expect(preferences).not.toContain("发送测试通知");
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).not.toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
expect(desktopLayout).toContain("新版本 ${pending.version}");
});
it("routes user-facing file actions through feedback helpers", () => {
const helper = readFileTaskFeedbackSource();
const attachments = readAttachmentListSource();
const documentDetail = readDocumentDetailSource();
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback");
expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
});
});
@@ -154,7 +154,7 @@ import type {
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime";
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false,
@@ -519,7 +519,7 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
await saveFileWithFeedback({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
};
const downloadInterfaceLog = () => {
+3 -2
View File
@@ -29,7 +29,8 @@
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime";
import { clientRuntime } from "../runtime";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -65,7 +66,7 @@ const fileListProxy = computed({
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => {
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
const files = await pickFilesWithFeedback({ multiple: true, title: TEXT.common.labels.attachments });
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`))
+2 -2
View File
@@ -47,8 +47,8 @@
<script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display";
import { saveFileWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -97,7 +97,7 @@ const loadImageUrls = async () => {
const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id);
await saveFile({
await saveFileWithFeedback({
suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data,
+6 -8
View File
@@ -405,7 +405,7 @@
class="desktop-preferences-dialog"
:show-close="false"
:close-on-click-modal="true"
width="720px"
width="940px"
align-center
destroy-on-close
>
@@ -779,7 +779,7 @@ const handleDesktopMenuCommand = (command: string) => {
return;
}
if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings");
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.refresh") {
@@ -839,12 +839,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
},
{
id: "desktop:server-settings",
title: "服务器设置",
title: "连接设置",
group: "桌面",
visible: true,
run: () => {
void router.push("/desktop/server-settings");
},
run: openDesktopPreferences,
},
{
id: "desktop:update",
@@ -2529,7 +2527,7 @@ useDesktopShortcuts(
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px;
background: #f8fafc;
padding: 0;
background: #ffffff;
}
</style>
@@ -152,7 +152,8 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type AttachmentEntityGroup = {
entityType: string;
@@ -280,7 +281,7 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label });
const selected = await pickFilesWithFeedback({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file));
};
@@ -351,7 +352,7 @@ const uploadImmediate = async (options: any) => {
};
const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
const [file] = await pickFilesWithFeedback({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file });
};
@@ -399,7 +400,7 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
const download = async (row: any) => {
try {
await saveFile({
await saveFileWithFeedback({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
@@ -411,7 +412,7 @@ const download = async (row: any) => {
const openExternally = async (row: any) => {
try {
await openFile({
await openFileWithFeedback({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
+12 -3
View File
@@ -95,11 +95,20 @@ export const buildAdminNavigationItems = (options: {
keywords: ["monitoring"],
},
{
label: "邮件服务",
label: "系统设置",
path: "/admin/email-settings",
group: TEXT.menu.admin,
icon: "settings",
keywords: ["email"],
keywords: ["settings"],
children: [
{
label: "邮件服务",
path: "/admin/email-settings",
group: "系统设置",
icon: "settings",
keywords: ["email", "settings"],
},
],
},
);
}
@@ -295,4 +304,4 @@ export const buildProjectNavigationItems = (options: {
};
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
items.flatMap((item) => [item, ...(item.children || [])]);
items.flatMap((item) => item.children?.length ? item.children : [item]);