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:
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: "导入监查访视问题",
|
||||
|
||||
Reference in New Issue
Block a user