Compare commits

2 Commits

Author SHA1 Message Date
Cheng Zhou 46f488c8ae feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
2026-07-01 17:04:23 +08:00
Cheng Zhou d60a2fa5b2 fix(auth): 支持无邮箱后缀时手动输入 2026-07-01 14:37:57 +08:00
21 changed files with 2159 additions and 623 deletions
@@ -1,7 +1,7 @@
import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from "..";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const BOM = "\ufeff";
@@ -24,7 +24,7 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events);
const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFile({
await saveFileWithFeedback({
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
+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]);
+107 -5
View File
@@ -10,6 +10,7 @@ const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const POSTPONE_MS = 24 * 60 * 60 * 1000;
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
export const DESKTOP_UPDATE_STATUS_CHANGED_EVENT = "ctms:desktop-update-status-changed";
let initialized = false;
let checkTimer: number | null = null;
@@ -18,10 +19,60 @@ let promptVisible = false;
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed";
export interface DesktopUpdateStatusSnapshot {
available: boolean;
checking: boolean;
installing: boolean;
lastStatus: DesktopUpdateCheckStatus | "idle";
lastCheckedAt: string | null;
lastError: string;
pendingUpdate: DesktopUpdateInfo | null;
postponedUntil: string | null;
}
export interface DesktopUpdateCheckOptions {
notifyWhenCurrent?: boolean;
promptWhenAvailable?: boolean;
}
const updateStatus: DesktopUpdateStatusSnapshot = {
available: isDesktopUpdaterAvailable(),
checking: false,
installing: false,
lastStatus: "idle",
lastCheckedAt: null,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
};
const snapshotUpdateStatus = (): DesktopUpdateStatusSnapshot => ({ ...updateStatus });
const emitUpdateStatus = () => {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, {
detail: snapshotUpdateStatus(),
}),
);
};
const setUpdateStatus = (patch: Partial<DesktopUpdateStatusSnapshot>) => {
Object.assign(updateStatus, patch);
emitUpdateStatus();
};
export const getDesktopUpdateStatus = (): DesktopUpdateStatusSnapshot => snapshotUpdateStatus();
export const listenDesktopUpdateStatus = (listener: (status: DesktopUpdateStatusSnapshot) => void) => {
if (typeof window === "undefined") return () => {};
const onStatusChange = (event: Event) => {
listener((event as CustomEvent<DesktopUpdateStatusSnapshot>).detail);
};
window.addEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
return () => window.removeEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
};
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
const getPostponeUntil = (version: string): number => {
@@ -34,11 +85,13 @@ const getPostponeUntil = (version: string): number => {
};
const postponeVersion = (version: string) => {
const until = Date.now() + POSTPONE_MS;
try {
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS));
window.localStorage.setItem(postponeKey(version), String(until));
} catch {
/* ignore */
}
setUpdateStatus({ lastStatus: "postponed", postponedUntil: new Date(until).toISOString() });
};
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
@@ -62,13 +115,16 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
distinguishCancelAndClose: true,
type: "info",
});
setUpdateStatus({ installing: true, lastError: "" });
await installPendingDesktopUpdate();
setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update });
return "available";
} catch (error) {
if (error === "cancel" || error === "close") {
postponeVersion(update.version);
return "postponed";
}
setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" });
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
return "failed";
} finally {
@@ -76,21 +132,66 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
}
};
export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => {
if (updateStatus.pendingUpdate) {
return promptForUpdate(updateStatus.pendingUpdate);
}
return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
};
export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => {
if (!isDesktopUpdaterAvailable()) {
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新");
setUpdateStatus({
available: false,
checking: false,
installing: false,
lastStatus: "disabled",
lastCheckedAt: new Date().toISOString(),
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
return "disabled";
}
setUpdateStatus({ available: true, checking: true, lastError: "" });
try {
const update = await checkForDesktopUpdate();
const checkedAt = new Date().toISOString();
if (update) {
return promptForUpdate(update);
const postponedUntilMs = getPostponeUntil(update.version);
const suppressed = postponedUntilMs > Date.now();
setUpdateStatus({
checking: false,
lastStatus: suppressed ? "suppressed" : "available",
lastCheckedAt: checkedAt,
pendingUpdate: update,
postponedUntil: postponedUntilMs ? new Date(postponedUntilMs).toISOString() : null,
});
const shouldPrompt = options.promptWhenAvailable ?? options.notifyWhenCurrent ?? false;
if (shouldPrompt && !suppressed) {
return promptForUpdate(update);
}
return suppressed ? "suppressed" : "available";
}
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本");
setUpdateStatus({
checking: false,
lastStatus: "up-to-date",
lastCheckedAt: checkedAt,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
return "up-to-date";
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : "桌面端更新检查失败";
setUpdateStatus({
checking: false,
lastStatus: "failed",
lastCheckedAt: new Date().toISOString(),
lastError: message,
});
// 启动和定时检查不打断录入;下一轮继续检查。
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed";
@@ -119,4 +220,5 @@ export const stopDesktopUpdateManager = () => {
}
initialized = false;
promptVisible = false;
setUpdateStatus({ checking: false, installing: false });
};
+20 -6
View File
@@ -7,6 +7,20 @@
--unified-shell-padding-y: 10px;
--unified-title-color: #0f2345;
--unified-muted-color: #6f84a8;
--unified-table-header-bg: #f7f9fc;
--unified-table-header-color: #34506f;
--unified-row-divider: #edf2f8;
}
:root[data-ctms-theme="dark"] {
--unified-shell-bg: var(--ctms-bg-card);
--unified-shell-border: var(--ctms-border-color);
--unified-shell-divider: var(--ctms-border-color);
--unified-title-color: var(--ctms-text-main);
--unified-muted-color: var(--ctms-text-secondary);
--unified-table-header-bg: var(--ctms-bg-muted);
--unified-table-header-color: var(--ctms-text-main);
--unified-row-divider: var(--ctms-border-color);
}
.ctms-page-shell {
@@ -85,7 +99,7 @@
.page>.page-header.unified-action-bar {
border: 0;
border-radius: var(--unified-shell-radius);
background: #ffffff;
background: var(--unified-shell-bg);
box-shadow: none;
}
@@ -156,15 +170,15 @@
}
.unified-shell .el-table th.el-table__cell {
background-color: #f7f9fc;
color: #34506f;
background-color: var(--unified-table-header-bg);
color: var(--unified-table-header-color);
font-weight: 600;
height: 40px;
font-size: 13px;
}
.unified-shell .el-table td.el-table__cell {
border-bottom-color: #edf2f8;
border-bottom-color: var(--unified-row-divider);
padding: 7px 0;
}
@@ -178,7 +192,7 @@
}
.unified-shell .el-tabs__nav-wrap::after {
background-color: #edf2f8;
background-color: var(--unified-row-divider);
}
.unified-shell .el-tabs__item {
@@ -263,4 +277,4 @@
padding-left: 14px;
padding-right: 14px;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { ElMessage } from "element-plus";
import {
clientRuntime,
openFile,
pickFiles,
saveFile,
type FileOutput,
type FilePickerOptions,
type SaveFileResult,
} from "../runtime";
const selectedFilesMessage = (count: number) => (count > 1 ? `已选择 ${count} 个文件` : "已选择 1 个文件");
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options);
if (files.length) {
ElMessage.success(selectedFilesMessage(files.length));
}
return files;
};
export const saveFileWithFeedback = async (output: FileOutput): Promise<SaveFileResult> => {
const result = await saveFile(output);
if (result === "cancelled") {
ElMessage.info("已取消保存文件");
return result;
}
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
return result;
};
export const openFileWithFeedback = async (output: FileOutput): Promise<void> => {
await openFile(output);
ElMessage.success("文件已打开");
};
File diff suppressed because it is too large Load Diff
+116 -14
View File
@@ -22,6 +22,19 @@
:closable="false"
/>
<div v-if="connectionDiagnostic" class="connection-diagnostic">
<div class="diagnostic-grid">
<span>检查时间</span>
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
<span>健康检查</span>
<code>{{ connectionDiagnostic.healthUrl }}</code>
<span>耗时</span>
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
<span>HTTP</span>
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
</div>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
@@ -33,10 +46,6 @@
/>
</el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost 或 http://127.0.0.1。
</div>
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref("");
const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000;
const formatDiagnosticTime = (date = new Date()) =>
date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const toConnectionError = (
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => Object.assign(new Error(message), details);
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try {
const response = await fetch(healthUrl, {
method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
}
return {
...details(),
httpStatus: response.status,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态");
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
}
if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
}
throw error;
} finally {
@@ -99,6 +148,21 @@ const clearSessionForServerChange = async () => {
studyStore.clearCurrentStudy();
};
const confirmServerChange = async (previous: string | null, next: string) => {
if (!previous || previous === next) return true;
const confirmed = await ElMessageBox.confirm(
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
"确认切换服务器",
{
type: "warning",
confirmButtonText: "切换并退出登录",
cancelButtonText: "继续编辑",
distinguishCancelAndClose: true,
},
).catch(() => null);
return Boolean(confirmed);
};
const save = async () => {
urlError.value = "";
connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true;
try {
await checkHealth(normalized.url);
const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url);
if (!result.ok) {
urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认",
message: result.url,
};
ElMessage.success("服务器连接已确认");
connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login");
} catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = {
type: "error",
title: "连接检查失败",
message,
};
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message;
} finally {
saving.value = false;
@@ -215,11 +297,31 @@ h1 {
margin-bottom: 18px;
}
.hint {
margin-top: -8px;
.connection-diagnostic {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 12px;
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #fbfdff;
}
.diagnostic-grid {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 10px;
min-width: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
}
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
}
.actions {
+7 -2
View File
@@ -96,12 +96,17 @@ describe("Login protocol agreement", () => {
expect(source).toContain("max-width: calc(100vw - 40px);");
});
it("uses only server-configured email domains on web and desktop", () => {
it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView();
expect(source).toContain('fetchEmailDomains()');
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
expect(source).toContain('v-if="hasConfiguredEmailDomains"');
expect(source).toContain("未检测到邮箱后缀配置,请输入完整邮箱地址。");
expect(source).toContain("form.email = local;");
expect(source).toContain("if (domains.length === 0)");
expect(source).toContain("configuredEmailDomains.value.includes(domain)");
expect(source).not.toContain("preservedEmailDomain");
expect(source).not.toContain("showDomainSelect");
+54 -9
View File
@@ -145,19 +145,19 @@
<!-- 登录表单 -->
<el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form">
<el-form-item label="账号" prop="email">
<div class="email-unified-box">
<div class="email-unified-box" :class="{ 'email-unified-box--manual': manualEmailInput }">
<input
id="email"
v-model="form.emailLocal"
type="text"
placeholder="请输入账号"
:type="manualEmailInput ? 'email' : 'text'"
:placeholder="emailInputPlaceholder"
name="username"
autocomplete="username"
class="email-local-field"
@paste="handleAccountPaste"
/>
<span class="email-at-sign">@</span>
<div class="email-domain-wrapper">
<span v-if="hasConfiguredEmailDomains" class="email-at-sign">@</span>
<div v-if="hasConfiguredEmailDomains" class="email-domain-wrapper">
<select
v-model="form.emailDomain"
class="email-domain-field"
@@ -169,6 +169,7 @@
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<p v-if="manualEmailInput" class="email-manual-hint">未检测到邮箱后缀配置请输入完整邮箱地址</p>
</el-form-item>
<el-form-item label="密码" prop="password">
@@ -294,9 +295,19 @@ const refreshDesktopServerUrl = () => {
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => configuredEmailDomains.value);
const hasConfiguredEmailDomains = computed(() =>
availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)
);
const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value);
const emailInputPlaceholder = computed(() => manualEmailInput.value ? "请输入完整邮箱" : "请输入账号");
const syncEmailFromParts = () => {
const local = form.emailLocal.trim().toLowerCase();
if (manualEmailInput.value) {
form.emailDomain = "";
form.email = local;
return;
}
const domain = normalizeDomain(form.emailDomain);
form.emailDomain = domain;
form.email = local && domain ? `${local}@${domain}` : "";
@@ -304,6 +315,12 @@ const syncEmailFromParts = () => {
const applyEmailValue = (email: string) => {
const normalized = email.trim().toLowerCase();
if (availableEmailDomains.value.length === 0) {
form.emailLocal = normalized;
form.emailDomain = "";
syncEmailFromParts();
return;
}
const separator = normalized.lastIndexOf("@");
if (separator > 0) {
form.emailLocal = normalized.slice(0, separator);
@@ -321,16 +338,32 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => {
try {
const { data } = await fetchEmailDomains();
configuredEmailDomains.value = Array.from(new Set(
data.items.map(normalizeDomain).filter(Boolean)
const domains = Array.from(new Set(
(Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
.filter(Boolean)
));
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
form.emailDomain = configuredEmailDomains.value[0] || "";
configuredEmailDomains.value = domains;
if (domains.length === 0) {
form.emailDomain = "";
syncEmailFromParts();
return;
}
if (form.emailLocal.includes("@")) {
applyEmailValue(form.emailLocal);
return;
}
if (!domains.includes(form.emailDomain)) {
form.emailDomain = domains[0];
}
} catch {
configuredEmailDomains.value = [];
form.emailDomain = "";
}
syncEmailFromParts();
};
const handleAccountPaste = (event: ClipboardEvent) => {
@@ -367,6 +400,7 @@ const confirmProtocol = () => { form.agreeProtocol = true; protocolDialogVisible
const onSubmit = async () => {
if (!formRef.value) return;
syncEmailFromParts();
const valid = await formRef.value.validate();
if (!valid) return;
if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; }
@@ -948,6 +982,10 @@ const onSubmit = async () => {
border-bottom-color: #2563eb;
}
.email-unified-box--manual .email-local-field {
width: 100%;
}
.email-local-field,
.email-domain-field {
min-width: 0;
@@ -969,6 +1007,13 @@ const onSubmit = async () => {
color: #94a3b8;
}
.email-manual-hint {
margin: 8px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.email-at-sign {
flex-shrink: 0;
padding: 0 4px;
+2 -202
View File
@@ -64,45 +64,6 @@
</el-form-item>
</section>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<h4>客户端与通知</h4>
</div>
<el-form-item v-if="isDesktop" label="系统通知">
<div class="desktop-setting-stack">
<div class="desktop-setting-row">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</div>
</el-form-item>
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
<div class="desktop-setting-row">
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
</div>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata-panel">
<dl class="client-metadata-list">
<template v-for="row in clientMetadataRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
@@ -118,25 +79,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{
"close-request": [];
@@ -144,31 +89,6 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const clientMetadataRows = [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -247,72 +167,6 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const notificationPermissionText = computed(() => {
if (!isDesktop) return "不可用";
if (notificationPermission.value === "granted") return "系统已允许";
if (notificationPermission.value === "denied") return "系统已拒绝";
if (notificationPermission.value === "prompt") return "等待授权";
return "不可用";
});
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
notificationPermission.value = permission;
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -345,7 +199,7 @@ const onSubmit = async () => {
};
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
@@ -368,7 +222,6 @@ const selectAndUploadAvatar = async () => {
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
@@ -483,59 +336,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.desktop-setting-hint {
color: #7f92ad;
font-size: 12px;
}
.client-metadata-panel {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
flex: 1;
min-width: 0;
margin: 0;
color: #40566f;
font-size: 12px;
}
.client-metadata-list dt {
color: #7f92ad;
font-weight: 700;
}
.client-metadata-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
+2 -2
View File
@@ -1954,7 +1954,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey =
| "project-info"
@@ -5345,7 +5345,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({
await saveFileWithFeedback({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
@@ -378,8 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute();
const auth = useAuthStore();
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
}));
const triggerFileInput = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
@@ -813,7 +813,7 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
@@ -822,7 +822,7 @@ const openVersion = async (version: DocumentVersion) => {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
+11 -11
View File
@@ -480,7 +480,7 @@ watch(
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f2345;
color: var(--unified-title-color);
}
.card-subtitle {
@@ -520,11 +520,11 @@ watch(
border-radius: 50%;
border: 3px solid #2f84c6;
box-sizing: border-box;
background: #ffffff;
background: var(--ctms-bg-card);
}
.plan-time-label {
color: #4b5563;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -546,10 +546,10 @@ watch(
}
.time-editor-group {
border: 1px solid #e8eef6;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
padding: 14px 16px 10px;
background: #fbfcfe;
background: var(--ctms-bg-muted);
}
.time-editor-group + .time-editor-group {
@@ -566,7 +566,7 @@ watch(
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
color: #1a3560;
color: var(--ctms-text-main);
display: flex;
align-items: center;
gap: 8px;
@@ -600,14 +600,14 @@ watch(
.duration-label {
font-size: 13px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.duration-value {
font-size: 14px;
font-weight: 700;
color: #1a3560;
color: var(--ctms-text-main);
}
.time-editor-footer {
@@ -628,7 +628,7 @@ watch(
.time-editor-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
color: var(--ctms-text-regular);
padding-bottom: 4px;
}
@@ -655,7 +655,7 @@ watch(
.status-detail {
padding-left: 14px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -684,7 +684,7 @@ watch(
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -466,7 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -978,7 +978,7 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -1010,7 +1010,7 @@ const importIssueFile = async (file: File) => {
};
const selectImportFile = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",