Files
ctms/frontend/src/views/DesktopPreferences.vue
T
Cheng Zhou ab59476d10
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
2026-07-13 16:03:20 +08:00

1618 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="desktop-preferences">
<aside class="preferences-sidebar">
<button class="window-close" type="button" aria-label="关闭系统偏好" @click="emit('close-request')">
<el-icon><Close /></el-icon>
</button>
<div class="sidebar-heading">
<p>CTMS Desktop</p>
<h2>设置</h2>
</div>
<nav class="preferences-nav" aria-label="系统偏好分类">
<button
v-for="section in preferenceSections"
:key="section.id"
type="button"
class="preferences-nav-item"
:class="{ active: activeSectionId === section.id }"
@click="selectSection(section.id)"
>
<el-icon><component :is="section.icon" /></el-icon>
<span>{{ section.label }}</span>
</button>
</nav>
</aside>
<section class="preferences-pane">
<header class="pane-header">
<Transition name="preferences-header" mode="out-in">
<div :key="activeSectionId" class="pane-header-content">
<div class="pane-icon">
<el-icon><component :is="activeSection.icon" /></el-icon>
</div>
<div>
<p>系统偏好</p>
<h3>{{ activeSection.label }}</h3>
<span>{{ activeSection.description }}</span>
</div>
</div>
</Transition>
</header>
<div ref="paneBodyRef" class="pane-body">
<Transition name="preferences-panel" mode="out-in">
<section v-if="activeSectionId === 'connection'" key="connection" class="preference-panel">
<div class="server-overview">
<span class="server-status-dot" :class="{ connected: Boolean(desktopServerUrl) }"></span>
<div>
<div class="row-title">{{ desktopServerUrl ? "已连接" : "未配置服务器" }}</div>
<code>{{ desktopServerUrl || "未配置" }}</code>
</div>
</div>
<Transition name="connection-feedback">
<el-alert
v-if="connectionStatus"
class="connection-alert"
:type="connectionStatus.type"
:title="connectionStatus.title"
:description="connectionStatus.message"
show-icon
:closable="false"
/>
</Transition>
<Transition name="connection-feedback">
<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>
</Transition>
<el-form class="server-config-form" label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
v-model.trim="serverUrl"
placeholder="https://ctms.example.com"
autocomplete="url"
@keyup.enter="testDesktopServerConnection"
/>
</el-form-item>
<div class="connection-actions">
<el-button :loading="serverChecking" :disabled="!serverUrl.trim()" @click="testDesktopServerConnection">
测试连通性
</el-button>
<el-button type="primary" :loading="serverSaving" :disabled="!serverUrl.trim()" @click="saveDesktopServerConfig">
保存
</el-button>
</div>
</el-form>
</section>
<section v-else-if="activeSectionId === 'appearance'" key="appearance" class="preference-panel">
<div class="preference-row">
<div>
<div class="row-title">界面主题</div>
<div class="row-desc">桌面工作台与系统弹窗配色</div>
</div>
<div class="theme-segmented" role="group" aria-label="界面主题">
<button
v-for="option in themeOptions"
:key="option.value"
type="button"
class="theme-option"
:class="{ active: desktopTheme === option.value }"
@click="setTheme(option.value)"
>
<el-icon><component :is="option.icon" /></el-icon>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</section>
<section v-else-if="activeSectionId === 'shortcuts'" key="shortcuts" class="preference-panel">
<div class="shortcut-settings">
<div class="shortcut-settings-head">
<div>
<div class="row-title">标签页导航</div>
<div class="row-desc">快捷键仅作用于当前激活标签点击快捷键后按下新的组合键</div>
</div>
<el-button size="small" @click="resetDesktopShortcuts">恢复默认</el-button>
</div>
<div class="shortcut-list">
<div v-for="item in shortcutDefinitions" :key="item.action" class="shortcut-row">
<div>
<div class="row-title">{{ item.label }}</div>
<div class="row-desc">{{ item.description }}</div>
</div>
<button
class="shortcut-recorder"
:class="{ capturing: capturingShortcutAction === item.action }"
type="button"
@click="startDesktopShortcutCapture($event, item.action)"
>
<kbd>{{ formatDesktopShortcut(desktopShortcuts[item.action]) }}</kbd>
<span>{{ capturingShortcutAction === item.action ? "请按组合键" : "点击修改" }}</span>
</button>
</div>
</div>
</div>
</section>
<section v-else-if="activeSectionId === 'notifications'" key="notifications" class="preference-panel">
<div class="preference-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">不含项目详情的文件更新提示</div>
</div>
<div class="row-control">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</el-button>
</div>
</div>
</section>
<section v-else-if="activeSectionId === 'updates'" key="updates" class="preference-panel">
<div class="preference-row">
<div>
<div class="row-title">桌面更新</div>
<div class="row-desc">{{ desktopUpdateDescription }}</div>
</div>
<div class="row-control">
<el-button
v-if="desktopUpdateStatus.pendingUpdate"
size="small"
type="primary"
:loading="desktopUpdateStatus.installing"
@click="installPendingDesktopUpdate"
>
安装更新
</el-button>
<el-button size="small" :disabled="!desktopUpdaterAvailable" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
</div>
</div>
</section>
<section v-else key="diagnostics" class="preference-panel">
<div class="metadata-panel">
<dl>
<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>
</section>
</Transition>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, type Component } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Bell, Close, Download, InfoFilled, Key, Link, Monitor, Moon, Sunny } from "@element-plus/icons-vue";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import {
clientRuntime,
desktopShortcutFromKeyboardEvent,
formatDesktopShortcut,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
normalizeDesktopServerUrl,
readDesktopShortcutPreferences,
readDesktopThemePreference,
requestNotificationPermission,
resetDesktopShortcutPreferences,
setDesktopShortcutPreference,
setDesktopServerUrl,
setDesktopThemePreference,
showSystemNotificationProbe,
type DesktopThemePreference,
type DesktopShortcutAction,
type DesktopShortcutPreferences,
type NotificationPermissionState,
} from "../runtime";
import {
checkDesktopUpdateAndPrompt,
getDesktopUpdateStatus,
listenDesktopUpdateStatus,
promptForPendingDesktopUpdate,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
const emit = defineEmits<{
"close-request": [];
}>();
const router = useRouter();
const auth = useAuthStore();
const studyStore = useStudyStore();
type PreferenceSectionId = "connection" | "appearance" | "shortcuts" | "notifications" | "updates" | "diagnostics";
const preferenceSections: Array<{
id: PreferenceSectionId;
label: string;
description: string;
icon: Component;
}> = [
{ id: "connection", label: "连接", description: "服务器入口与在线状态", icon: Link },
{ id: "appearance", label: "外观", description: "主题与界面偏好", icon: Monitor },
{ id: "shortcuts", label: "快捷键", description: "当前标签的导航与刷新操作", icon: Key },
{ id: "notifications", label: "通知", description: "系统通知权限与投递状态", icon: Bell },
{ id: "updates", label: "更新", description: "桌面端签名更新与安装状态", icon: Download },
{ id: "diagnostics", label: "诊断信息", description: "客户端版本、平台与能力", icon: InfoFilled },
];
const activeSectionId = ref<PreferenceSectionId>("connection");
const activeSection = computed(
() => preferenceSections.find((section) => section.id === activeSectionId.value) || preferenceSections[0],
);
const paneBodyRef = ref<HTMLElement | null>(null);
const desktopServerUrl = ref(getDesktopServerUrl());
const serverUrl = ref(desktopServerUrl.value || "");
const urlError = ref("");
const serverChecking = ref(false);
const serverSaving = ref(false);
type ConnectionStatusType = "success" | "warning" | "error" | "info";
const connectionStatus = ref<{ type: ConnectionStatusType; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopNotificationTestLoading = ref(false);
const desktopUpdateChecking = ref(false);
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
const desktopShortcuts = ref<DesktopShortcutPreferences>(readDesktopShortcutPreferences());
const capturingShortcutAction = ref<DesktopShortcutAction | null>(null);
const shortcutCaptureElement = ref<HTMLElement | null>(null);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
let updateStatusUnlisten: (() => void) | undefined;
let connectionPendingTimer: number | undefined;
const themeOptions = [
{ value: "system" as const, label: "跟随系统", icon: Monitor },
{ value: "light" as const, label: "明亮", icon: Sunny },
{ value: "dark" as const, label: "暗黑", icon: Moon },
];
const shortcutDefinitions: Array<{
action: DesktopShortcutAction;
label: string;
description: string;
}> = [
{ action: "back", label: "后退", description: "返回当前标签的上一页面" },
{ action: "forward", label: "前进", description: "前往当前标签的下一页面" },
{ action: "refresh", label: "刷新", description: "重新加载当前标签的数据" },
];
const HEALTH_TIMEOUT_MS = 10_000;
const clientMetadataRows = computed(() => [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const desktopUpdateDescription = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
if (desktopUpdateStatus.value.checking) return "正在检查签名更新";
if (desktopUpdateStatus.value.installing) return "正在安装更新并准备重启";
if (pending && ["postponed", "suppressed"].includes(desktopUpdateStatus.value.lastStatus)) {
return `发现 ${pending.version},已选择稍后提醒`;
}
if (pending) return `发现 ${pending.version},当前 ${pending.currentVersion}`;
if (desktopUpdateStatus.value.lastStatus === "disabled") return "当前构建未启用桌面端自动更新";
if (desktopUpdateStatus.value.lastStatus === "up-to-date") return "当前已是最新版本";
if (desktopUpdateStatus.value.lastStatus === "failed") {
return desktopUpdateStatus.value.lastError || "桌面端更新检查失败";
}
return "正式版本按当前服务器发布源检查签名更新";
});
const selectSection = (sectionId: PreferenceSectionId) => {
if (activeSectionId.value === sectionId) return;
paneBodyRef.value?.scrollTo({ top: 0, behavior: "smooth" });
activeSectionId.value = sectionId;
};
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",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
}
return {
...details(),
httpStatus: response.status,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
}
if (error instanceof TypeError) {
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
}
throw error;
} finally {
window.clearTimeout(timeout);
}
};
const writeConnectionResult = (
type: ConnectionStatusType,
title: string,
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => {
connectionStatus.value = { type, title, message };
connectionDiagnostic.value = {
healthUrl: details.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs,
httpStatus: details.httpStatus,
};
};
const setConnectionPending = (message: string) => {
connectionStatus.value = {
type: "info",
title: "正在检查",
message,
};
};
const clearConnectionPendingTimer = () => {
if (connectionPendingTimer === undefined) return;
window.clearTimeout(connectionPendingTimer);
connectionPendingTimer = undefined;
};
const scheduleConnectionPending = (message: string) => {
clearConnectionPendingTimer();
connectionPendingTimer = window.setTimeout(() => {
connectionPendingTimer = undefined;
if (serverChecking.value || serverSaving.value) {
setConnectionPending(message);
}
}, 180);
};
const clearSessionForServerChange = async () => {
await auth.logout({ rememberCurrentStudy: false });
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 validateServerUrl = () => {
urlError.value = "";
const normalized = normalizeDesktopServerUrl(serverUrl.value);
if (!normalized.ok) {
urlError.value = normalized.reason;
return null;
}
return normalized.url;
};
const testDesktopServerConnection = async () => {
if (serverChecking.value || serverSaving.value) return;
const normalizedUrl = validateServerUrl();
if (!normalizedUrl) return;
serverChecking.value = true;
scheduleConnectionPending("正在请求服务器健康检查");
try {
const health = await checkHealth(normalizedUrl);
clearConnectionPendingTimer();
writeConnectionResult("success", "连通性正常", "服务器健康检查通过", health);
} catch (error) {
clearConnectionPendingTimer();
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
writeConnectionResult("error", "连接检查失败", message, {
serverUrl: normalizedUrl,
healthUrl: details.healthUrl || new URL("health", normalizedUrl).toString(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
});
urlError.value = message;
} finally {
serverChecking.value = false;
}
};
const saveDesktopServerConfig = async () => {
if (serverChecking.value || serverSaving.value) return;
const normalizedUrl = validateServerUrl();
if (!normalizedUrl) {
ElMessage.error(urlError.value || "服务器地址无效");
return;
}
serverSaving.value = true;
scheduleConnectionPending("保存前正在检查服务器连通性");
try {
const health = await checkHealth(normalizedUrl);
clearConnectionPendingTimer();
const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalizedUrl))) {
connectionStatus.value = {
type: "warning",
title: "未保存",
message: "服务器地址未更改",
};
return;
}
const result = setDesktopServerUrl(normalizedUrl);
if (!result.ok) {
urlError.value = result.reason;
connectionStatus.value = {
type: "error",
title: "保存失败",
message: result.reason,
};
ElMessage.error(`保存失败:${result.reason}`);
return;
}
desktopServerUrl.value = result.url;
serverUrl.value = result.url;
writeConnectionResult(
"success",
"连接已确认",
result.url,
{
serverUrl: result.url,
healthUrl: health.healthUrl,
durationMs: health.durationMs,
httpStatus: health.httpStatus,
},
);
if (previous !== result.url) {
await clearSessionForServerChange();
emit("close-request");
void router.replace("/login");
}
ElMessage.success("服务器设置已保存");
} catch (error) {
clearConnectionPendingTimer();
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
writeConnectionResult("error", "保存失败", message, {
serverUrl: normalizedUrl,
healthUrl: details.healthUrl || new URL("health", normalizedUrl).toString(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
});
urlError.value = message;
ElMessage.error(`保存失败:${message}`);
} finally {
serverSaving.value = false;
}
};
const loadNotificationState = async () => {
notificationPermission.value = await getNotificationPermission();
try {
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
} catch {
desktopNotificationsEnabled.value = false;
}
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
if (value) {
notificationPermission.value = await requestNotificationPermission();
if (notificationPermission.value !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(Boolean(value));
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 sendDesktopNotificationTest = async () => {
if (desktopNotificationTestLoading.value) return;
desktopNotificationTestLoading.value = true;
try {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value !== "granted") {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
const sent = await showSystemNotificationProbe();
if (!sent) {
ElMessage.warning("系统通知未发送,请确认系统通知权限已开启");
return;
}
ElMessage.success("已调用系统通知");
} catch {
ElMessage.error("系统通知发送失败");
} finally {
desktopNotificationTestLoading.value = false;
}
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const installPendingDesktopUpdate = async () => {
await promptForPendingDesktopUpdate();
};
const setTheme = (theme: DesktopThemePreference) => {
desktopTheme.value = setDesktopThemePreference(theme);
};
const clearDesktopShortcutCapture = () => {
capturingShortcutAction.value = null;
shortcutCaptureElement.value = null;
};
const startDesktopShortcutCapture = (event: MouseEvent, action: DesktopShortcutAction) => {
event.preventDefault();
event.stopPropagation();
capturingShortcutAction.value = action;
shortcutCaptureElement.value = event.currentTarget as HTMLElement | null;
shortcutCaptureElement.value?.focus();
};
const captureDesktopShortcut = (event: KeyboardEvent) => {
const action = capturingShortcutAction.value;
if (!action) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (event.key === "Escape") {
clearDesktopShortcutCapture();
return;
}
const shortcut = desktopShortcutFromKeyboardEvent(event);
if (!shortcut) {
if (!["Meta", "Control", "Shift", "Alt"].includes(event.key)) {
ElMessage.warning("请按下包含 Command(或 Ctrl)的组合键");
}
return;
}
const result = setDesktopShortcutPreference(action, shortcut);
if (!result.ok) {
ElMessage.warning(result.reason);
return;
}
desktopShortcuts.value = result.shortcuts;
ElMessage.success("快捷键已更新");
clearDesktopShortcutCapture();
};
const cancelDesktopShortcutCaptureOnPointerDown = (event: PointerEvent) => {
if (!capturingShortcutAction.value) return;
const target = event.target;
if (target instanceof Node && shortcutCaptureElement.value?.contains(target)) return;
clearDesktopShortcutCapture();
};
const resetDesktopShortcuts = () => {
desktopShortcuts.value = resetDesktopShortcutPreferences();
clearDesktopShortcutCapture();
ElMessage.success("快捷键已恢复默认");
};
const copyClientMetadata = async () => {
const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(text);
ElMessage.success("诊断信息已复制");
};
onMounted(() => {
window.addEventListener("keydown", captureDesktopShortcut, { capture: true });
window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true });
updateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
void loadNotificationState();
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", captureDesktopShortcut, { capture: true });
window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true });
clearConnectionPendingTimer();
updateStatusUnlisten?.();
});
</script>
<style scoped>
/* =====================================================
* 设计变量(色彩 / 尺寸)
* ===================================================== */
.desktop-preferences {
/* 品牌主色 */
--pref-accent: #1e4d82;
--pref-accent-light: #e8f0fb;
--pref-accent-active: #d0e2f8;
/* 中性色
* 侧边栏用极浅灰,内容区用纯白,分隔线用半透明细线
* 避免两块色块对撞产生割裂感 */
--pref-bg-sidebar: #f2f4f7;
--pref-bg-pane: #ffffff;
--pref-bg-card: #ffffff;
--pref-sidebar-border: rgba(0, 0, 0, 0.07);
--pref-border: #e6eaef;
--pref-border-card: #dde5f0;
/* 文字 */
--pref-text-primary: #0f172a;
--pref-text-secondary: #4b5563;
--pref-text-muted: #8b97a8;
/* 状态色 */
--pref-green: #22a06b;
--pref-amber: #b45309;
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
width: 100%;
height: 700px;
max-height: calc(100vh - 96px);
overflow: hidden;
background: var(--pref-bg-pane);
border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
}
/* =====================================================
* 侧边栏
* ===================================================== */
.preferences-sidebar {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 20px;
padding: 20px 12px;
/* 用半透明线替代硬边框,消除左右割裂感 */
border-right: 1px solid var(--pref-sidebar-border);
background: var(--pref-bg-sidebar);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.window-close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: 1.5px solid rgba(220, 38, 38, 0.5);
border-radius: 999px;
background: #ff5f57;
color: transparent;
cursor: pointer;
transition: box-shadow 160ms ease;
}
.window-close :deep(.el-icon) {
width: 9px;
height: 9px;
}
.window-close:hover {
color: rgba(127, 29, 29, 0.85);
box-shadow: 0 0 0 3px rgba(255, 95, 87, 0.25);
}
.sidebar-heading {
padding: 4px 10px 0;
}
.sidebar-heading p,
.pane-header p {
margin: 0 0 3px;
color: var(--pref-text-muted);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sidebar-heading h2,
h3 {
margin: 0;
color: var(--pref-text-primary);
font-weight: 750;
letter-spacing: -0.3px;
}
.sidebar-heading h2 {
font-size: 20px;
}
/* =====================================================
* 导航列表
* ===================================================== */
.preferences-nav {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.preferences-nav-item {
appearance: none;
position: relative;
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 10px;
width: 100%;
height: 38px;
padding: 0 11px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--pref-text-secondary);
cursor: pointer;
font: inherit;
font-size: 13.5px;
font-weight: 580;
overflow: hidden;
text-align: left;
transition:
background-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.preferences-nav-item::before {
content: "";
position: absolute;
top: 8px;
bottom: 8px;
left: 0;
width: 3px;
border-radius: 0 999px 999px 0;
background: var(--pref-accent);
opacity: 0;
transform: scaleY(0.3);
transition:
opacity 150ms ease,
transform 180ms cubic-bezier(0.2, 0.85, 0.2, 1);
}
.preferences-nav-item:hover {
background: rgba(30, 77, 130, 0.06);
color: var(--pref-text-primary);
}
.preferences-nav-item:active {
transform: scale(0.985);
}
.preferences-nav-item.active {
background: var(--pref-accent-light);
color: var(--pref-accent);
font-weight: 650;
}
.preferences-nav-item.active::before {
opacity: 1;
transform: scaleY(1);
}
.preferences-nav-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* =====================================================
* 右侧内容区
* ===================================================== */
.preferences-pane {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
background: var(--pref-bg-pane);
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
.pane-header {
box-sizing: border-box;
overflow: hidden;
padding: 22px 30px 18px;
border-bottom: 1px solid var(--pref-border);
background: var(--pref-bg-pane);
border-top-right-radius: 8px;
}
.pane-header-content {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: center;
gap: 13px;
}
.pane-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 9px;
background: var(--pref-accent-light);
color: var(--pref-accent);
font-size: 19px;
}
h3 {
font-size: 21px;
}
.pane-header span {
display: block;
margin-top: 4px;
color: var(--pref-text-secondary);
font-size: 12.5px;
}
.pane-body {
position: relative;
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 24px 30px 32px;
scrollbar-gutter: stable;
border-bottom-right-radius: 8px;
}
.preference-panel {
display: flex;
max-width: 600px;
flex-direction: column;
gap: 10px;
}
/* =====================================================
* 过渡动画
* ===================================================== */
.preferences-header-enter-active {
transition:
opacity 170ms ease,
transform 190ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.preferences-header-leave-active {
transition:
opacity 100ms ease,
transform 100ms ease;
}
.preferences-header-enter-from {
opacity: 0;
transform: translateY(6px);
}
.preferences-header-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.preferences-panel-enter-active {
transition:
opacity 180ms ease,
transform 210ms cubic-bezier(0.2, 0.8, 0.2, 1),
filter 180ms ease;
}
.preferences-panel-leave-active {
transition:
opacity 95ms ease,
transform 95ms ease,
filter 95ms ease;
}
.preferences-panel-enter-from {
opacity: 0;
filter: blur(1px);
transform: translateY(10px);
}
.preferences-panel-leave-to {
opacity: 0;
filter: blur(1px);
transform: translateY(-6px);
}
.preferences-panel-enter-active .server-overview,
.preferences-panel-enter-active .server-config-form,
.preferences-panel-enter-active .connection-alert,
.preferences-panel-enter-active .connection-diagnostic,
.preferences-panel-enter-active .preference-row,
.preferences-panel-enter-active .shortcut-settings,
.preferences-panel-enter-active .metadata-panel {
animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes preference-card-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* =====================================================
* 卡片通用样式
* ===================================================== */
.server-overview,
.preference-row,
.shortcut-settings,
.metadata-panel {
border: 1px solid var(--pref-border-card);
border-radius: 10px;
background: var(--pref-bg-card);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03);
}
/* =====================================================
* 连接状态卡片
* ===================================================== */
.server-overview {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 14px;
padding: 14px 16px;
}
.server-status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--pref-amber);
flex-shrink: 0;
}
.server-status-dot.connected {
background: var(--pref-green);
box-shadow: 0 0 0 3px rgba(34, 160, 107, 0.18);
}
/* =====================================================
* 服务器配置表单卡片
* ===================================================== */
.server-config-form {
padding: 16px 16px 12px;
border: 1px solid var(--pref-border-card);
border-radius: 10px;
background: var(--pref-bg-card);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03);
}
.connection-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.preference-panel :deep(.el-button) {
transition:
background-color 150ms ease,
border-color 150ms ease,
color 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease,
transform 120ms ease;
}
.preference-panel :deep(.el-button:not(.is-disabled):active) {
transform: scale(0.982);
}
.connection-actions :deep(.el-button) {
min-width: 100px;
font-size: 13px;
}
.connection-alert {
--el-alert-padding: 10px 12px;
border-radius: 8px;
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
opacity 180ms ease,
transform 180ms ease;
}
/* =====================================================
* 连接诊断信息展示
* ===================================================== */
.connection-diagnostic {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 12px 16px;
border: 1px solid var(--pref-border);
border-radius: 8px;
background: #f7f9fc;
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease,
transform 180ms ease;
}
.connection-feedback-enter-active {
transition:
opacity 180ms ease,
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.connection-feedback-leave-active {
transition:
opacity 120ms ease,
transform 120ms ease;
}
.connection-feedback-enter-from,
.connection-feedback-leave-to {
opacity: 0;
transform: translateY(6px);
}
.diagnostic-grid {
display: grid;
grid-template-columns: 66px minmax(0, 1fr);
gap: 7px 12px;
min-width: 0;
}
.diagnostic-grid span {
color: var(--pref-text-muted);
font-size: 11.5px;
}
.diagnostic-grid strong {
min-width: 0;
color: var(--pref-text-primary);
font-size: 11.5px;
font-weight: 600;
overflow-wrap: anywhere;
}
/* =====================================================
* 偏好行(通知、外观、更新等)
* ===================================================== */
.preference-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 15px 16px;
}
.preference-row > div:first-child {
min-width: 0;
}
.row-title {
color: var(--pref-text-primary);
font-size: 13.5px;
font-weight: 640;
}
.row-desc {
margin-top: 3px;
color: var(--pref-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
.row-control {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
/* =====================================================
* 快捷键
* ===================================================== */
.shortcut-settings {
overflow: hidden;
}
.shortcut-settings-head,
.shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 18px;
padding: 14px 16px;
}
.shortcut-settings-head {
border-bottom: 1px solid var(--pref-border);
background: #f8fafc;
}
.shortcut-list {
display: grid;
}
.shortcut-row + .shortcut-row {
border-top: 1px solid var(--pref-border);
}
.shortcut-recorder {
appearance: none;
display: grid;
justify-items: end;
min-width: 126px;
gap: 3px;
padding: 7px 9px;
border: 1px solid var(--pref-border-card);
border-radius: 8px;
background: #f8fafc;
color: var(--pref-text-secondary);
cursor: pointer;
font: inherit;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.shortcut-recorder:hover,
.shortcut-recorder:focus-visible,
.shortcut-recorder.capturing {
border-color: var(--pref-accent);
background: var(--pref-accent-light);
box-shadow: 0 0 0 3px rgba(30, 77, 130, 0.1);
outline: none;
}
.shortcut-recorder kbd {
color: var(--pref-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-weight: 750;
}
.shortcut-recorder span {
color: var(--pref-text-muted);
font-size: 10px;
font-weight: 600;
}
/* =====================================================
* 主题切换器
* ===================================================== */
.theme-segmented {
display: inline-grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
flex: 0 1 336px;
width: min(100%, 336px);
gap: 3px;
padding: 3px;
border: 1px solid var(--pref-border-card);
border-radius: 8px;
background: #eef1f6;
}
.theme-option {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--pref-text-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.theme-option:hover {
color: var(--pref-text-primary);
}
.theme-option.active {
background: #ffffff;
color: var(--pref-accent);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.1);
}
/* =====================================================
* 诊断信息面板
* ===================================================== */
.metadata-panel {
display: flex;
flex-direction: column;
gap: 14px;
align-items: flex-start;
padding: 16px;
}
dl {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
width: 100%;
gap: 8px 12px;
margin: 0;
}
dt {
color: var(--pref-text-muted);
font-size: 12px;
}
dd {
margin: 0;
color: var(--pref-text-primary);
font-size: 12px;
overflow-wrap: anywhere;
}
code {
display: block;
margin-top: 3px;
overflow-wrap: anywhere;
color: var(--pref-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11.5px;
}
/* =====================================================
* 深色主题
* ===================================================== */
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
background: #0f1721;
}
:global([data-ctms-theme="dark"] .desktop-preferences) {
--pref-accent: #7ab3f0;
--pref-accent-light: #172540;
--pref-accent-active: #1e3254;
--pref-bg-sidebar: #131b28;
--pref-bg-pane: #161f2e;
--pref-bg-card: #1c2840;
--pref-sidebar-border: rgba(255, 255, 255, 0.06);
--pref-border: #243144;
--pref-border-card: #243144;
--pref-text-primary: #f1f5f9;
--pref-text-secondary: #94a3b8;
--pref-text-muted: #5a6c82;
--pref-green: #34d399;
--pref-amber: #fbbf24;
background: var(--pref-bg-pane);
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-sidebar) {
border-right-color: var(--pref-sidebar-border);
background: var(--pref-bg-sidebar);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-pane) {
background: var(--pref-bg-pane);
}
:global([data-ctms-theme="dark"] .desktop-preferences .pane-header) {
border-bottom-color: var(--pref-border);
background: var(--pref-bg-pane);
}
:global([data-ctms-theme="dark"] .desktop-preferences .pane-icon) {
background: var(--pref-accent-light);
color: var(--pref-accent);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-nav-item) {
color: var(--pref-text-secondary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-nav-item:hover) {
background: rgba(122, 179, 240, 0.07);
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-nav-item.active) {
background: var(--pref-accent-light);
color: var(--pref-accent);
}
:global([data-ctms-theme="dark"] .desktop-preferences .preferences-nav-item.active::before) {
background: var(--pref-accent);
}
:global([data-ctms-theme="dark"] .desktop-preferences .sidebar-heading h2),
:global([data-ctms-theme="dark"] .desktop-preferences h3),
:global([data-ctms-theme="dark"] .desktop-preferences .row-title),
:global([data-ctms-theme="dark"] .desktop-preferences dd),
:global([data-ctms-theme="dark"] .desktop-preferences code) {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .sidebar-heading p),
:global([data-ctms-theme="dark"] .desktop-preferences .pane-header p),
:global([data-ctms-theme="dark"] .desktop-preferences .pane-header span),
:global([data-ctms-theme="dark"] .desktop-preferences .pane-header h3),
:global([data-ctms-theme="dark"] .desktop-preferences .row-desc),
:global([data-ctms-theme="dark"] .desktop-preferences dt) {
color: var(--pref-text-secondary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .server-overview),
:global([data-ctms-theme="dark"] .desktop-preferences .server-config-form),
:global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic),
:global([data-ctms-theme="dark"] .desktop-preferences .preference-row),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings),
:global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) {
border-color: var(--pref-border-card);
background: var(--pref-bg-card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
:global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic) {
background: #0d1520;
}
:global([data-ctms-theme="dark"] .desktop-preferences .diagnostic-grid span) {
color: var(--pref-text-muted);
}
:global([data-ctms-theme="dark"] .desktop-preferences .diagnostic-grid strong) {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
border-color: var(--pref-border-card);
background: #0d1420;
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-option) {
color: var(--pref-text-muted);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-option:hover) {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-option.active) {
background: var(--pref-accent-light);
color: var(--pref-accent);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings-head),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder) {
background: #101827;
}
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder:hover),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder:focus-visible),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-recorder.capturing) {
background: var(--pref-accent-light);
}
:global([data-ctms-theme="dark"] .desktop-preferences .server-status-dot.connected) {
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.2);
}
/* =====================================================
* 减少动画模式
* ===================================================== */
@media (prefers-reduced-motion: reduce) {
.preferences-nav-item,
.preferences-nav-item::before,
.theme-option,
.preference-panel :deep(.el-button),
.connection-alert,
.connection-diagnostic,
.connection-feedback-enter-active,
.connection-feedback-leave-active,
.preferences-header-enter-active,
.preferences-header-leave-active,
.preferences-panel-enter-active,
.preferences-panel-leave-active {
animation: none;
transition: none;
}
.preferences-header-enter-from,
.preferences-header-leave-to,
.connection-feedback-enter-from,
.connection-feedback-leave-to,
.preferences-panel-enter-from,
.preferences-panel-leave-to {
opacity: 1;
filter: none;
transform: none;
}
}
</style>