1383 lines
39 KiB
Vue
1383 lines
39 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 === '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 size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
|
</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, Link, Monitor, Moon, Sunny } from "@element-plus/icons-vue";
|
|
import {
|
|
getDesktopNotificationSubscription,
|
|
setDesktopNotificationSubscription,
|
|
} from "../api/desktopNotifications";
|
|
import {
|
|
clientRuntime,
|
|
getAppMetadata,
|
|
getDesktopServerUrl,
|
|
getNotificationPermission,
|
|
isDesktopUpdaterAvailable,
|
|
normalizeDesktopServerUrl,
|
|
readDesktopThemePreference,
|
|
requestNotificationPermission,
|
|
setDesktopServerUrl,
|
|
setDesktopThemePreference,
|
|
type DesktopThemePreference,
|
|
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" | "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: "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 desktopUpdateChecking = ref(false);
|
|
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
|
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
|
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
|
|
let updateStatusUnlisten: (() => void) | undefined;
|
|
let connectionPendingTimer: number | undefined;
|
|
const themeOptions = [
|
|
{ value: "light" as const, label: "明亮", icon: Sunny },
|
|
{ value: "dark" as const, label: "暗黑", icon: Moon },
|
|
];
|
|
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 === "granted") return "已授权";
|
|
if (notificationPermission.value === "denied") return "已拒绝";
|
|
if (notificationPermission.value === "prompt") return "待授权";
|
|
return "不可用";
|
|
});
|
|
|
|
const notificationPermissionTagType = computed(() => {
|
|
if (notificationPermission.value === "granted") return "success";
|
|
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 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 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(() => {
|
|
updateStatusUnlisten = listenDesktopUpdateStatus((status) => {
|
|
desktopUpdateStatus.value = status;
|
|
});
|
|
void loadNotificationState();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
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 让组件有确定高度,配合 dialog body 的 flex:1 撑满容器
|
|
* 若 dialog 比 700px 高,组件会通过 flex:1 继续撑大 */
|
|
height: min(700px, calc(100vh - 96px));
|
|
overflow: hidden;
|
|
background: var(--pref-bg-pane);
|
|
border-radius: inherit;
|
|
}
|
|
|
|
/* =====================================================
|
|
* 侧边栏
|
|
* ===================================================== */
|
|
.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);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.pane-header {
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
padding: 22px 30px 18px;
|
|
border-bottom: 1px solid var(--pref-border);
|
|
background: var(--pref-bg-pane);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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 .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,
|
|
.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;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--pref-border);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.row-control {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* =====================================================
|
|
* 主题切换器
|
|
* ===================================================== */
|
|
.theme-segmented {
|
|
display: inline-grid;
|
|
grid-template-columns: repeat(2, minmax(72px, 1fr));
|
|
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: 72px;
|
|
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;
|
|
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 .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 .connection-actions) {
|
|
border-top-color: var(--pref-border);
|
|
}
|
|
|
|
: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 .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>
|