完善桌面端交互体验与发布检查
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visibleProxy"
|
||||
class="desktop-command-dialog"
|
||||
width="640px"
|
||||
align-center
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
destroy-on-close
|
||||
@opened="focusSearch"
|
||||
>
|
||||
<div class="desktop-command-palette">
|
||||
<div class="command-search-row">
|
||||
<el-icon><Search /></el-icon>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
class="command-search-input"
|
||||
autocomplete="off"
|
||||
placeholder="搜索模块、项目或桌面操作"
|
||||
@keydown.enter.prevent="runFirstCommand"
|
||||
@keydown.esc.prevent="visibleProxy = false"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="command-list" role="listbox">
|
||||
<template v-if="groupedCommands.length">
|
||||
<section v-for="group in groupedCommands" :key="group.name" class="command-group">
|
||||
<div class="command-group-title">{{ group.name }}</div>
|
||||
<button
|
||||
v-for="command in group.items"
|
||||
:key="command.id"
|
||||
type="button"
|
||||
class="command-item"
|
||||
@click="runCommand(command)"
|
||||
>
|
||||
<span class="command-item-title">{{ command.title }}</span>
|
||||
<kbd v-if="command.shortcut">{{ command.shortcut }}</kbd>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else class="command-empty">没有匹配的命令</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { getVisibleDesktopCommands, type DesktopCommand } from "../types/desktopCommands";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
commands: DesktopCommand[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const filteredCommands = computed(() => getVisibleDesktopCommands(props.commands, query.value));
|
||||
|
||||
const groupedCommands = computed(() => {
|
||||
const map = new Map<string, DesktopCommand[]>();
|
||||
filteredCommands.value.forEach((command) => {
|
||||
const items = map.get(command.group) || [];
|
||||
items.push(command);
|
||||
map.set(command.group, items);
|
||||
});
|
||||
return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||
});
|
||||
|
||||
const focusSearch = () => {
|
||||
nextTick(() => searchInputRef.value?.focus());
|
||||
};
|
||||
|
||||
const runCommand = async (command: DesktopCommand) => {
|
||||
visibleProxy.value = false;
|
||||
await command.run();
|
||||
};
|
||||
|
||||
const runFirstCommand = () => {
|
||||
const [first] = filteredCommands.value;
|
||||
if (first) void runCommand(first);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
query.value = "";
|
||||
focusSearch();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-command-palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.command-search-input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
max-height: min(58vh, 520px);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.command-group + .command-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.command-group-title {
|
||||
padding: 6px 8px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-item:hover,
|
||||
.command-item:focus-visible {
|
||||
background: #eef4ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-empty {
|
||||
padding: 36px 12px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.desktop-command-dialog .el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-command-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
|
||||
describe("desktop layout shell", () => {
|
||||
it("keeps desktop-only controls behind the Tauri runtime flag", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime()");
|
||||
expect(source).toContain('v-if="isDesktop"');
|
||||
expect(source).toContain("DesktopCommandPalette");
|
||||
expect(source).toContain("DesktopPreferences");
|
||||
});
|
||||
|
||||
it("uses route-only desktop preference storage", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("readDesktopRecentRoutes");
|
||||
expect(source).toContain("readDesktopFavoriteRoutes");
|
||||
expect(source).toContain("recordDesktopRecentRoute");
|
||||
expect(source).toContain("currentDesktopRoutePreference");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,26 @@
|
||||
class="aside-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-menu-item-group v-if="isDesktop && desktopFavoriteRoutes.length" class="menu-group desktop-menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">桌面收藏</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in desktopFavoriteRoutes" :key="`favorite:${item.path}`" :index="item.path">
|
||||
<el-icon><StarFilled /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group v-if="isDesktop && desktopRecentRoutes.length" class="menu-group desktop-menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">最近访问</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in desktopRecentRoutes" :key="`recent:${item.path}`" :index="item.path">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group v-if="auth.user" class="menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
||||
@@ -211,6 +231,45 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索命令</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
|
||||
<el-tooltip v-if="isDesktop" :content="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" placement="bottom">
|
||||
<button class="desktop-icon-button" type="button" @click="toggleCurrentFavorite">
|
||||
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-popover v-if="isDesktop" placement="bottom-end" width="320" trigger="click" popper-class="desktop-connection-popover">
|
||||
<template #reference>
|
||||
<button class="desktop-connection-button" type="button">
|
||||
<span class="connection-dot" :class="desktopConnectionClass"></span>
|
||||
<span class="connection-label">{{ desktopConnectionLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="desktop-connection-panel">
|
||||
<div class="connection-panel-title">在线桌面客户端</div>
|
||||
<div class="connection-panel-row">
|
||||
<span>服务器</span>
|
||||
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||
</div>
|
||||
<div class="connection-panel-row">
|
||||
<span>客户端</span>
|
||||
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
|
||||
</div>
|
||||
<el-button size="small" @click="openDesktopPreferences">桌面偏好</el-button>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<el-tooltip v-if="isDesktop" content="桌面偏好" placement="bottom">
|
||||
<button class="desktop-icon-button" type="button" @click="openDesktopPreferences">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
|
||||
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
|
||||
<el-badge
|
||||
@@ -287,6 +346,10 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDesktop" command="desktopPreferences">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>桌面偏好</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" :disabled="loggingOut" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出..." : TEXT.menu.logout }}</span>
|
||||
@@ -330,6 +393,25 @@
|
||||
@saved="profileDialogVisible = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<DesktopCommandPalette
|
||||
v-if="isDesktop"
|
||||
v-model="commandPaletteVisible"
|
||||
:commands="desktopCommands"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-if="isDesktop"
|
||||
v-model="desktopPreferencesVisible"
|
||||
class="desktop-preferences-dialog"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
width="720px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<DesktopPreferences @close-request="desktopPreferencesVisible = false" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -344,17 +426,38 @@ import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
||||
import { TEXT } from "../locales";
|
||||
import {
|
||||
User, Suitcase, House, Calendar, Flag,
|
||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting
|
||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting,
|
||||
Search, Star, StarFilled, Clock, Monitor
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
getAppMetadata,
|
||||
getDesktopServerUrl,
|
||||
isTauriRuntime,
|
||||
listenDesktopMenuCommand,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
recordDesktopRecentRoute,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
} from "../runtime";
|
||||
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
||||
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const desktopMetadata = getAppMetadata();
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
@@ -366,6 +469,11 @@ const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(project
|
||||
const loggingOut = ref(false);
|
||||
const profileDialogVisible = ref(false);
|
||||
const profileDialogDirty = ref(false);
|
||||
const commandPaletteVisible = ref(false);
|
||||
const desktopPreferencesVisible = ref(false);
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
|
||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||
const headerOverviewStats = ref<{
|
||||
centerActual: number;
|
||||
enrollmentActual: number;
|
||||
@@ -379,6 +487,7 @@ const headerReminderStats = ref({
|
||||
});
|
||||
const headerClockNow = ref(new Date());
|
||||
let headerClockTimer: number | undefined;
|
||||
let desktopMenuUnlisten: (() => void) | undefined;
|
||||
|
||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||
@@ -555,6 +664,13 @@ const accountProjectRoleLabel = computed(() => {
|
||||
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
|
||||
});
|
||||
|
||||
type DesktopNavigationItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
group: string;
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
@@ -586,6 +702,198 @@ const activeMenu = computed(() => {
|
||||
|
||||
const studies = ref<any[]>([]);
|
||||
|
||||
const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
|
||||
const items: DesktopNavigationItem[] = [];
|
||||
if (auth.user) {
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: TEXT.menu.accountManagement, path: "/admin/users", group: TEXT.menu.admin, keywords: ["user", "account"] });
|
||||
}
|
||||
items.push({ label: TEXT.menu.projectManagement, path: "/admin/projects", group: TEXT.menu.admin, keywords: ["project"] });
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs", group: TEXT.menu.admin, keywords: ["audit"] });
|
||||
items.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring", group: TEXT.menu.admin, keywords: ["monitoring"] });
|
||||
items.push({ label: "邮件服务", path: "/admin/email-settings", group: TEXT.menu.admin, keywords: ["email"] });
|
||||
}
|
||||
if (canAccessAdminPermissions.value) {
|
||||
items.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system", group: TEXT.menu.admin, keywords: ["permission"] });
|
||||
}
|
||||
}
|
||||
|
||||
if (study.currentStudy && hasAnyProjectModuleAccess.value) {
|
||||
const projectItems: DesktopNavigationItem[] = [
|
||||
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
|
||||
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
|
||||
{ label: TEXT.menu.feeContracts, path: "/fees/contracts", group: TEXT.menu.currentProject, keywords: ["fee", "contract"] },
|
||||
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", group: TEXT.menu.materialManagement, keywords: ["drug", "shipment"] },
|
||||
{ label: TEXT.menu.materialEquipment, path: "/materials/equipment", group: TEXT.menu.materialManagement, keywords: ["material", "equipment"] },
|
||||
{ label: TEXT.menu.fileVersionManagement, path: "/file-versions", group: TEXT.menu.currentProject, keywords: ["document", "file", "version"] },
|
||||
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", group: TEXT.menu.currentProject, keywords: ["startup", "ethics"] },
|
||||
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", group: TEXT.menu.currentProject, keywords: ["meeting", "training"] },
|
||||
{ label: TEXT.menu.subjects, path: "/subjects", group: TEXT.menu.currentProject, keywords: ["subject"] },
|
||||
{ label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae", group: TEXT.menu.riskIssues, keywords: ["sae", "risk"] },
|
||||
{ label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd", group: TEXT.menu.riskIssues, keywords: ["pd", "risk"] },
|
||||
{ label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits", group: TEXT.menu.riskIssues, keywords: ["monitoring", "visit"] },
|
||||
{ label: TEXT.menu.etmf, path: "/etmf", group: TEXT.menu.currentProject, keywords: ["etmf"] },
|
||||
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "medical"] },
|
||||
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
|
||||
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
|
||||
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
|
||||
];
|
||||
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const refreshDesktopRoutePreferences = () => {
|
||||
if (!isDesktop) return;
|
||||
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
|
||||
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
|
||||
);
|
||||
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
|
||||
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
|
||||
);
|
||||
};
|
||||
|
||||
const currentDesktopRoutePreference = computed(() => {
|
||||
const activePath = activeMenu.value;
|
||||
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activePath);
|
||||
if (!item) return null;
|
||||
return { path: item.path, title: item.label, group: item.group };
|
||||
});
|
||||
|
||||
const currentRouteFavorited = computed(() => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
|
||||
});
|
||||
|
||||
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
|
||||
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
|
||||
|
||||
const updateDesktopServerUrl = () => {
|
||||
desktopServerUrl.value = getDesktopServerUrl();
|
||||
};
|
||||
|
||||
const openCommandPalette = () => {
|
||||
if (!isDesktop) return;
|
||||
commandPaletteVisible.value = true;
|
||||
};
|
||||
|
||||
const openDesktopPreferences = () => {
|
||||
if (!isDesktop) return;
|
||||
desktopPreferencesVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleCurrentFavorite = () => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (!current) return;
|
||||
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute(current);
|
||||
refreshDesktopRoutePreferences();
|
||||
};
|
||||
|
||||
const refreshCurrentDesktopView = () => {
|
||||
const handled = dispatchDesktopRefreshCurrentView();
|
||||
if (!handled) router.go(0);
|
||||
};
|
||||
|
||||
const handleDesktopMenuCommand = (command: string) => {
|
||||
if (command === "ctms.desktop.commandPalette") {
|
||||
openCommandPalette();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.preferences") {
|
||||
openDesktopPreferences();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.serverSettings") {
|
||||
router.push("/desktop/server-settings");
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.refresh") {
|
||||
refreshCurrentDesktopView();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.back") {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.forward") {
|
||||
router.forward();
|
||||
}
|
||||
};
|
||||
|
||||
const desktopCommands = computed<DesktopCommand[]>(() => {
|
||||
const navigationCommands: DesktopCommand[] = desktopNavigationItems.value.map((item) => ({
|
||||
id: `route:${item.path}`,
|
||||
title: item.label,
|
||||
group: item.group,
|
||||
keywords: item.keywords,
|
||||
visible: true,
|
||||
run: () => {
|
||||
void router.push(item.path);
|
||||
},
|
||||
}));
|
||||
|
||||
const projectCommands: DesktopCommand[] = studies.value.map((item) => ({
|
||||
id: `study:${item.id}`,
|
||||
title: `切换项目:${item.name}`,
|
||||
group: "项目",
|
||||
keywords: [item.name, item.project_no, item.protocol_no].filter(Boolean),
|
||||
visible: Boolean(item?.id),
|
||||
run: async () => {
|
||||
study.setCurrentStudy(item);
|
||||
await study.loadCurrentStudyPermissions().catch(() => {});
|
||||
await router.push("/project/overview");
|
||||
},
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: "desktop:refresh",
|
||||
title: "刷新当前视图",
|
||||
group: "桌面",
|
||||
shortcut: "⌘R",
|
||||
visible: true,
|
||||
run: refreshCurrentDesktopView,
|
||||
},
|
||||
{
|
||||
id: "desktop:preferences",
|
||||
title: "打开桌面偏好",
|
||||
group: "桌面",
|
||||
shortcut: "⌘,",
|
||||
visible: true,
|
||||
run: openDesktopPreferences,
|
||||
},
|
||||
{
|
||||
id: "desktop:server-settings",
|
||||
title: "服务器设置",
|
||||
group: "桌面",
|
||||
visible: true,
|
||||
run: () => {
|
||||
void router.push("/desktop/server-settings");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "desktop:update",
|
||||
title: "检查桌面更新",
|
||||
group: "桌面",
|
||||
visible: true,
|
||||
run: async () => {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "desktop:favorite-current",
|
||||
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
|
||||
group: "桌面",
|
||||
visible: Boolean(currentDesktopRoutePreference.value),
|
||||
run: toggleCurrentFavorite,
|
||||
},
|
||||
...navigationCommands,
|
||||
...projectCommands,
|
||||
];
|
||||
});
|
||||
|
||||
const loadStudies = async () => {
|
||||
try {
|
||||
const { data } = await fetchStudies();
|
||||
@@ -802,6 +1110,13 @@ const handleReminderCommand = (cmd: string) => {
|
||||
|
||||
watch(() => route.path, () => {
|
||||
study.setViewContext(null);
|
||||
if (isDesktop) {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
|
||||
refreshDesktopRoutePreferences();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -809,9 +1124,15 @@ watch(
|
||||
() => {
|
||||
loadHeaderOverviewStats();
|
||||
loadHeaderReminders();
|
||||
refreshDesktopRoutePreferences();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => desktopNavigationItems.value.map((item) => item.path).join("|"),
|
||||
() => refreshDesktopRoutePreferences(),
|
||||
);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
@@ -851,6 +1172,15 @@ onMounted(async () => {
|
||||
headerClockTimer = window.setInterval(() => {
|
||||
headerClockNow.value = new Date();
|
||||
}, 1000);
|
||||
if (isDesktop) {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
|
||||
}
|
||||
refreshDesktopRoutePreferences();
|
||||
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
@@ -869,6 +1199,8 @@ onBeforeUnmount(() => {
|
||||
if (headerClockTimer) {
|
||||
window.clearInterval(headerClockTimer);
|
||||
}
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
desktopMenuUnlisten?.();
|
||||
});
|
||||
|
||||
const onCommand = (cmd: string) => {
|
||||
@@ -876,6 +1208,8 @@ const onCommand = (cmd: string) => {
|
||||
onLogout();
|
||||
} else if (cmd === "profile") {
|
||||
profileDialogVisible.value = true;
|
||||
} else if (cmd === "desktopPreferences") {
|
||||
openDesktopPreferences();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -904,6 +1238,27 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
useDesktopShortcuts(
|
||||
() => isDesktop,
|
||||
{
|
||||
openCommandPalette,
|
||||
closeActiveLayer: () => {
|
||||
if (commandPaletteVisible.value) {
|
||||
commandPaletteVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (desktopPreferencesVisible.value) {
|
||||
desktopPreferencesVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
navigateBack: () => router.back(),
|
||||
navigateForward: () => router.forward(),
|
||||
refreshCurrentView: refreshCurrentDesktopView,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1297,10 +1652,120 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-command-trigger {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, auto) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 32px;
|
||||
max-width: 176px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d7e2f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.desktop-command-trigger:hover,
|
||||
.desktop-icon-button:hover,
|
||||
.desktop-connection-button:hover {
|
||||
border-color: #b7c8dd;
|
||||
background: #f8fbff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.desktop-command-trigger span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-command-trigger kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.desktop-icon-button,
|
||||
.desktop-connection-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.desktop-icon-button {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.desktop-connection-button {
|
||||
gap: 7px;
|
||||
padding: 0 9px;
|
||||
border-color: #d7e2f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.connection-dot.is-connected {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.connection-label {
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-connection-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.connection-panel-title {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connection-panel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connection-panel-row code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2039,4 +2504,18 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
:global(.profile-settings-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog) {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__body) {
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user