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,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]);
|
||||
|
||||
Reference in New Issue
Block a user