Files
ctms/frontend/src/views/DesktopPreferences.vue
T
2026-07-02 10:43:30 +08:00

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>