ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
1618 lines
47 KiB
Vue
1618 lines
47 KiB
Vue
<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>
|