Files
yurou/src/components/SettingsModal.vue
2026-07-20 18:10:08 +08:00

1757 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";
import { computed, onBeforeUnmount, ref, watch } from "vue";
type SettingsPage = "about" | "editor" | "appearance" | "sync";
type DocumentMode = "preview" | "edit" | "mixed";
type AppTheme = "system" | "warm" | "white" | "black" | "gruvbox" | "gray";
type SyncProvider = "none" | "webdav" | "s3";
interface SyncConfig {
provider: SyncProvider;
webdav: {
endpoint: string;
username: string;
password: string;
remoteDir: string;
};
s3: {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
remotePrefix: string;
pathStyle: boolean;
useHttps: boolean;
};
autoSyncOnStartup: boolean;
autoSyncAfterSave: boolean;
lastSyncAt: string | null;
}
interface SyncResult {
message: string;
lastSyncAt: string;
stats: {
uploaded: number;
downloaded: number;
deletedLocal: number;
deletedRemote: number;
skipped: number;
};
files: SyncFileDetail[];
}
interface SyncConnectionTestResult {
message: string;
details: string[];
}
interface SyncConfigSaveResult {
configPath: string;
}
type SyncFileAction = "upload" | "download" | "deleteLocal" | "deleteRemote" | "skip";
interface SyncFileDetail {
path: string;
action: SyncFileAction;
size: number;
}
interface SyncFeedback {
kind: "progress" | "save" | "test" | "sync" | "error";
title: string;
summary: string;
details?: string[];
configPath?: string;
result?: SyncResult;
}
const props = withDefaults(
defineProps<{
show: boolean;
initialPage?: SettingsPage;
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
renderSingleLineBreaks?: boolean;
paragraphIndent?: boolean;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
renderSingleLineBreaks: false,
paragraphIndent: false,
},
);
const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
updateRenderSingleLineBreaks: [value: boolean];
updateParagraphIndent: [value: boolean];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
const APP_NAME = "yurou";
const FALLBACK_VERSION = "0.1.0";
const AUTO_UPDATE_KEY = "yurou:auto-update";
const LANGUAGE_KEY = "yurou:language";
const activePage = ref<SettingsPage>(props.initialPage);
const appVersion = ref(FALLBACK_VERSION);
const autoUpdate = ref(loadBooleanPref(AUTO_UPDATE_KEY, true));
const language = ref(loadStringPref(LANGUAGE_KEY, "zh-Hans"));
const updateStatus = ref("");
const syncConfig = ref<SyncConfig>(createDefaultSyncConfig());
const syncFeedback = ref<SyncFeedback | null>(null);
const syncSaving = ref(false);
const syncTesting = ref(false);
const syncRunning = ref(false);
let updateStatusTimer: ReturnType<typeof setTimeout> | null = null;
const pageTitle = computed(() => {
if (activePage.value === "about") return "关于";
if (activePage.value === "editor") return "编辑器";
if (activePage.value === "sync") return "同步";
return "外观";
});
const isSyncBusy = computed(() => syncSaving.value || syncTesting.value || syncRunning.value);
const syncProviderLabel = computed(() => {
if (syncConfig.value.provider === "webdav") return "WebDAV";
if (syncConfig.value.provider === "s3") return "S3 兼容";
return "关闭同步";
});
const syncTargetDescription = computed(() => {
if (syncConfig.value.provider === "webdav") {
const endpoint = syncConfig.value.webdav.endpoint.trim() || "未填写服务器地址";
const remoteDir = syncConfig.value.webdav.remoteDir.trim() || "/";
return `远端目标:${endpoint} · ${remoteDir}`;
}
if (syncConfig.value.provider === "s3") {
const bucket = syncConfig.value.s3.bucket.trim() || "未填写 Bucket";
const prefix = syncConfig.value.s3.remotePrefix.trim() || "/";
return `远端目标Bucket ${bucket} · ${prefix}`;
}
return "同步已关闭";
});
const syncFileDetails = computed(() => {
const files = syncFeedback.value?.result?.files || [];
return files
.filter((file) => file.action !== "skip")
.sort((left, right) => left.path.localeCompare(right.path));
});
const menuItems: Array<{
id: SettingsPage;
label: string;
icon: string;
}> = [
{ id: "about", label: "关于", icon: "ri-information-line" },
{ id: "editor", label: "编辑器", icon: "ri-edit-2-line" },
{ id: "appearance", label: "外观", icon: "ri-palette-line" },
{ id: "sync", label: "同步", icon: "ri-loop-left-line" },
];
const documentModeOptions: Array<{
value: DocumentMode;
label: string;
}> = [
{ value: "preview", label: "预览" },
{ value: "edit", label: "编辑" },
{ value: "mixed", label: "混合" },
];
const themeOptions: Array<{
value: AppTheme;
label: string;
description: string;
swatches: [string, string, string];
}> = [
{
value: "system",
label: "跟随系统",
description: "随操作系统在浅色与深色之间切换",
swatches: ["#ffffff", "#101113", "#7c9cff"],
},
{
value: "warm",
label: "柔和暖色",
description: "暖白纸张、陶土强调色",
swatches: ["#faf9f6", "#bf6a3b", "#7a55d9"],
},
{
value: "white",
label: "明亮白色",
description: "纯白背景、清晰蓝色强调",
swatches: ["#ffffff", "#2563eb", "#8250df"],
},
{
value: "gray",
label: "中性灰色",
description: "低饱和界面、沉静蓝灰强调",
swatches: ["#f3f4f6", "#4f6f93", "#7c5c99"],
},
{
value: "black",
label: "深色",
description: "近黑背景、冷调高对比代码色",
swatches: ["#101113", "#8ab4ff", "#c58af9"],
},
{
value: "gruvbox",
label: "Gruvbox",
description: "复古深色、暖橙与青绿色代码色",
swatches: ["#282828", "#fe8019", "#8ec07c"],
},
];
function loadBooleanPref(key: string, fallback: boolean) {
try {
const stored = localStorage.getItem(key);
if (stored === "true") return true;
if (stored === "false") return false;
} catch {
// localStorage may be unavailable in restricted environments.
}
return fallback;
}
function loadStringPref(key: string, fallback: string) {
try {
return localStorage.getItem(key) || fallback;
} catch {
return fallback;
}
}
function savePref(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
// noop
}
}
function createDefaultSyncConfig(): SyncConfig {
return {
provider: "none",
webdav: {
endpoint: "",
username: "",
password: "",
remoteDir: "/yurou",
},
s3: {
endpoint: "",
region: "us-east-1",
bucket: "",
accessKeyId: "",
secretAccessKey: "",
remotePrefix: "yurou",
pathStyle: true,
useHttps: true,
},
autoSyncOnStartup: false,
autoSyncAfterSave: false,
lastSyncAt: null,
};
}
function mergeSyncConfig(config: Partial<SyncConfig> | null | undefined): SyncConfig {
const defaults = createDefaultSyncConfig();
if (!config) return defaults;
return {
...defaults,
...config,
webdav: {
...defaults.webdav,
...(config.webdav || {}),
},
s3: {
...defaults.s3,
...(config.s3 || {}),
},
};
}
function close() {
emit("update:show", false);
}
function onOverlayKeydown(event: KeyboardEvent) {
if (event.key === "Escape") close();
}
async function loadAppVersion() {
try {
appVersion.value = await getVersion();
} catch {
appVersion.value = FALLBACK_VERSION;
}
}
function checkForUpdates() {
updateStatus.value = "当前已是最新版本";
if (updateStatusTimer) clearTimeout(updateStatusTimer);
updateStatusTimer = setTimeout(() => {
updateStatus.value = "";
}, 2400);
}
function openReleaseNotes() {
window.open("https://gitea.cirry.cn/cirry/yurou/releases", "_blank", "noopener,noreferrer");
}
function openHelp() {
window.open("https://gitea.cirry.cn/cirry/yurou", "_blank", "noopener,noreferrer");
}
function onDefaultDocumentModeChange(event: Event) {
const mode = (event.target as HTMLSelectElement).value as DocumentMode;
emit("updateDefaultDocumentMode", mode);
}
function setSyncError(message: string, title = "操作失败") {
syncFeedback.value = {
kind: "error",
title,
summary: message,
};
}
function syncActionLabel(action: SyncFileAction) {
if (action === "upload") return "上传";
if (action === "download") return "下载";
if (action === "deleteLocal") return "删除本地";
if (action === "deleteRemote") return "删除远端";
return "未变更";
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async function loadSyncConfig() {
try {
const config = await invoke<SyncConfig>("load_sync_config");
syncConfig.value = mergeSyncConfig(config);
} catch (error) {
setSyncError(`读取同步配置失败: ${error}`, "无法读取同步配置");
}
}
async function saveSyncConfig() {
syncSaving.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在保存配置",
summary: `正在保存 ${syncProviderLabel.value} 配置。`,
};
try {
const result = await invoke<SyncConfigSaveResult>("save_sync_config", { config: syncConfig.value });
syncFeedback.value = {
kind: "save",
title: "同步配置已保存",
summary: `已保存 ${syncProviderLabel.value} 配置。`,
configPath: result.configPath,
details: [
syncTargetDescription.value,
`启动时自动同步:${syncConfig.value.autoSyncOnStartup ? "开启" : "关闭"}`,
`保存后自动同步:${syncConfig.value.autoSyncAfterSave ? "开启" : "关闭"}`,
],
};
} catch (error) {
setSyncError(String(error), "保存同步配置失败");
} finally {
syncSaving.value = false;
}
}
async function testSyncConnection() {
syncTesting.value = true;
syncFeedback.value = {
kind: "progress",
title: "正在测试连接",
summary: `正在检查 ${syncProviderLabel.value} 服务的读取、写入和删除权限。`,
};
try {
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
syncFeedback.value = {
kind: "test",
title: "连接测试通过",
summary: result.message,
details: result.details,
};
} catch (error) {
setSyncError(String(error), "连接测试失败");
} finally {
syncTesting.value = false;
}
}
async function runSyncNow() {
if (!props.workspacePath) {
setSyncError("请先打开一个工作目录", "无法开始同步");
return;
}
syncRunning.value = true;
try {
syncFeedback.value = {
kind: "progress",
title: "正在同步",
summary: "正在扫描本地和远端文件,远端列表请求最长等待 120 秒。",
};
await invoke("save_sync_config", { config: syncConfig.value });
const prepared = await new Promise<boolean>((resolve) => {
emit("prepareWorkspaceSync", resolve);
});
if (!prepared) {
setSyncError("当前文档尚未保存,同步已取消。", "同步未开始");
return;
}
const result = await invoke<SyncResult>("sync_now", {
workspace: props.workspacePath,
config: syncConfig.value,
});
syncConfig.value.lastSyncAt = result.lastSyncAt;
const changedCount = result.stats.uploaded + result.stats.downloaded + result.stats.deletedLocal + result.stats.deletedRemote;
syncFeedback.value = {
kind: "sync",
title: "同步完成",
summary: changedCount > 0 ? `本次同步处理了 ${changedCount} 个变更文件。` : "本地与远端内容已一致,没有文件需要变更。",
result,
};
emit("workspaceSynced");
} catch (error) {
setSyncError(String(error), "同步失败");
} finally {
syncRunning.value = false;
}
}
watch(
() => props.show,
(show) => {
if (!show) return;
activePage.value = props.initialPage;
updateStatus.value = "";
syncFeedback.value = null;
void loadAppVersion();
void loadSyncConfig();
},
);
watch(
() => props.initialPage,
(page) => {
if (props.show) activePage.value = page;
},
);
watch(autoUpdate, (value) => {
savePref(AUTO_UPDATE_KEY, String(value));
});
watch(language, (value) => {
savePref(LANGUAGE_KEY, value);
});
onBeforeUnmount(() => {
if (updateStatusTimer) clearTimeout(updateStatusTimer);
});
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="settings-overlay"
role="presentation"
@keydown="onOverlayKeydown"
>
<section
class="settings-shell"
role="dialog"
aria-modal="true"
:aria-label="`设置 - ${pageTitle}`"
>
<aside class="settings-menu" aria-label="设置菜单">
<div class="settings-menu-title">设置</div>
<button
v-for="item in menuItems"
:key="item.id"
class="settings-menu-item"
:class="{ 'settings-menu-item-active': activePage === item.id }"
type="button"
@click="activePage = item.id"
>
<i :class="[item.icon, 'text-base']"></i>
<span>{{ item.label }}</span>
</button>
</aside>
<div class="settings-main">
<header class="settings-header">
<h2>{{ pageTitle }}</h2>
<button class="settings-close-btn" type="button" title="关闭设置" @click="close">
<i class="ri-close-line text-lg"></i>
</button>
</header>
<div v-if="activePage === 'about'" class="settings-content">
<section class="settings-about-card" aria-label="关于 yurou">
<div class="settings-about-logo">
<img src="/yurou-logo.png" alt="yurou logo" class="settings-logo-img" />
</div>
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>版本 {{ appVersion }}</h3>
<p>安装程序版本: {{ appVersion }}</p>
<button class="settings-link" type="button" @click="openReleaseNotes">
阅读更新日志
</button>
</div>
<div class="settings-action-stack">
<button class="settings-primary-btn" type="button" @click="checkForUpdates">
检查更新
</button>
<span v-if="updateStatus" class="settings-status">{{ updateStatus }}</span>
</div>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>自动更新</h3>
<p>关闭后 {{ APP_NAME }} 将不会自动更新</p>
</div>
<label class="settings-switch" title="自动更新">
<input v-model="autoUpdate" type="checkbox" />
<span></span>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>语言</h3>
<p>更改界面语言</p>
<button class="settings-link" type="button">
显示如何给 {{ APP_NAME }} 添加你的母语
</button>
</div>
<select v-model="language" class="settings-select" aria-label="界面语言">
<option value="zh-Hans">简体中文</option>
<option value="zh-Hant">繁体中文</option>
<option value="en">English</option>
</select>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>获取帮助</h3>
<p>获取关于使用 {{ APP_NAME }} 的帮助</p>
</div>
<button class="settings-secondary-btn" type="button" @click="openHelp">
打开
</button>
</div>
</section>
</div>
<div v-else-if="activePage === 'editor'" class="settings-content">
<section class="settings-about-card" aria-label="编辑器设置">
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>默认编辑模式</h3>
<p>在编辑视图下新标签页默认采用的编辑模式</p>
</div>
<select
class="settings-select"
aria-label="默认编辑模式"
:value="defaultDocumentMode"
@change="onDefaultDocumentModeChange"
>
<option
v-for="option in documentModeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>单换行显示为换行</h3>
<p>开启后预览和混合模式会保留普通回车产生的换行不修改 Markdown 原文</p>
</div>
<label class="settings-switch" title="单换行显示为换行">
<input
:checked="renderSingleLineBreaks"
type="checkbox"
@change="emit('updateRenderSingleLineBreaks', ($event.target as HTMLInputElement).checked)"
/>
<span></span>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>段落首行缩进</h3>
<p>开启后预览和混合模式下段落首行会缩进两个字符更符合中文排版习惯</p>
</div>
<label class="settings-switch" title="段落首行缩进">
<input
:checked="paragraphIndent"
type="checkbox"
@change="emit('updateParagraphIndent', ($event.target as HTMLInputElement).checked)"
/>
<span></span>
</label>
</div>
</section>
</div>
<div v-else-if="activePage === 'sync'" class="settings-content settings-sync-content">
<section class="settings-about-card" aria-label="同步设置">
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>同步方式</h3>
<p>同步当前工作目录中的所有文件包括隐藏文件</p>
</div>
<select v-model="syncConfig.provider" class="settings-select" aria-label="同步方式">
<option value="none">关闭同步</option>
<option value="webdav">WebDAV</option>
<option value="s3">S3 兼容</option>
</select>
</div>
<template v-if="syncConfig.provider === 'webdav'">
<div class="settings-form-row">
<label class="settings-field">
<span>服务器地址</span>
<input
v-model="syncConfig.webdav.endpoint"
class="settings-input"
type="url"
placeholder="https://example.com/dav"
/>
</label>
<label class="settings-field">
<span>远端目录</span>
<input
v-model="syncConfig.webdav.remoteDir"
class="settings-input"
type="text"
placeholder="/yurou"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>用户名</span>
<input
v-model="syncConfig.webdav.username"
class="settings-input"
type="text"
autocomplete="username"
/>
</label>
<label class="settings-field">
<span>密码或应用密码</span>
<input
v-model="syncConfig.webdav.password"
class="settings-input"
type="password"
autocomplete="current-password"
/>
</label>
</div>
</template>
<template v-else-if="syncConfig.provider === 's3'">
<div class="settings-form-row">
<label class="settings-field">
<span>Endpoint</span>
<input
v-model="syncConfig.s3.endpoint"
class="settings-input"
type="text"
placeholder="https://s3.example.com"
/>
</label>
<label class="settings-field">
<span>Region</span>
<input
v-model="syncConfig.s3.region"
class="settings-input"
type="text"
placeholder="us-east-1"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>Bucket</span>
<input
v-model="syncConfig.s3.bucket"
class="settings-input"
type="text"
placeholder="notes"
/>
</label>
<label class="settings-field">
<span>远端前缀</span>
<input
v-model="syncConfig.s3.remotePrefix"
class="settings-input"
type="text"
placeholder="yurou"
/>
</label>
</div>
<div class="settings-form-row">
<label class="settings-field">
<span>Access Key ID</span>
<input
v-model="syncConfig.s3.accessKeyId"
class="settings-input"
type="text"
autocomplete="username"
/>
</label>
<label class="settings-field">
<span>Secret Access Key</span>
<input
v-model="syncConfig.s3.secretAccessKey"
class="settings-input"
type="password"
autocomplete="current-password"
/>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>Path style</h3>
<p>MinIO部分私有云和兼容 S3 服务通常需要开启</p>
</div>
<label class="settings-switch" title="Path style">
<input v-model="syncConfig.s3.pathStyle" type="checkbox" />
<span></span>
</label>
</div>
<div class="settings-row">
<div class="settings-copy">
<h3>使用 HTTPS</h3>
<p> Endpoint 未填写协议时用这个选项决定 http https</p>
</div>
<label class="settings-switch" title="使用 HTTPS">
<input v-model="syncConfig.s3.useHttps" type="checkbox" />
<span></span>
</label>
</div>
</template>
<div class="settings-row">
<div class="settings-copy">
<h3>同步策略</h3>
<p>启动时和保存后可自动同步冲突时以最新修改为准</p>
</div>
<div class="settings-switch-group">
<label class="settings-switch-line">
<span>启动时</span>
<span class="settings-switch">
<input v-model="syncConfig.autoSyncOnStartup" type="checkbox" />
<span></span>
</span>
</label>
<label class="settings-switch-line">
<span>保存后</span>
<span class="settings-switch">
<input v-model="syncConfig.autoSyncAfterSave" type="checkbox" />
<span></span>
</span>
</label>
</div>
</div>
<div class="settings-row settings-sync-actions">
<div class="settings-copy">
<h3>立即同步</h3>
<p>
{{ syncConfig.lastSyncAt ? `上次同步:${syncConfig.lastSyncAt}` : '尚未同步' }}
</p>
</div>
<div class="settings-action-stack">
<div class="settings-config-actions" aria-label="同步配置操作">
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy"
@click="saveSyncConfig"
>
<i :class="syncSaving ? 'ri-loader-4-line settings-spin' : 'ri-save-3-line'"></i>
{{ syncSaving ? '保存中' : '保存配置' }}
</button>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none'"
@click="testSyncConnection"
>
<i :class="syncTesting ? 'ri-loader-4-line settings-spin' : 'ri-pulse-line'"></i>
{{ syncTesting ? '测试中' : '测试连接' }}
</button>
</div>
<button
class="settings-primary-btn settings-sync-now-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
@click="runSyncNow"
>
<i :class="syncRunning ? 'ri-loader-4-line settings-spin' : 'ri-loop-left-line'"></i>
{{ syncRunning ? '同步中' : '立即同步' }}
</button>
</div>
<section
v-if="syncFeedback"
class="settings-sync-feedback"
:class="{
'settings-sync-feedback-error': syncFeedback.kind === 'error',
'settings-sync-feedback-progress': syncFeedback.kind === 'progress',
}"
role="status"
aria-live="polite"
>
<div class="settings-sync-feedback-header">
<i
:class="
syncFeedback.kind === 'error'
? 'ri-error-warning-line'
: syncFeedback.kind === 'progress'
? 'ri-loader-4-line settings-spin'
: 'ri-checkbox-circle-line'
"
></i>
<div>
<strong>{{ syncFeedback.title }}</strong>
<p>{{ syncFeedback.summary }}</p>
</div>
</div>
<div v-if="syncFeedback.configPath" class="settings-config-path">
<span>配置保存位置</span>
<code :title="syncFeedback.configPath">{{ syncFeedback.configPath }}</code>
</div>
<ul v-if="syncFeedback.details?.length" class="settings-feedback-details">
<li v-for="detail in syncFeedback.details" :key="detail">
<i class="ri-check-line"></i>
<span>{{ detail }}</span>
</li>
</ul>
<template v-if="syncFeedback.result">
<dl class="settings-sync-stats" aria-label="同步数量汇总">
<div>
<dt>上传</dt>
<dd>{{ syncFeedback.result.stats.uploaded }}</dd>
</div>
<div>
<dt>下载</dt>
<dd>{{ syncFeedback.result.stats.downloaded }}</dd>
</div>
<div>
<dt>删除本地</dt>
<dd>{{ syncFeedback.result.stats.deletedLocal }}</dd>
</div>
<div>
<dt>删除远端</dt>
<dd>{{ syncFeedback.result.stats.deletedRemote }}</dd>
</div>
<div>
<dt>未变更</dt>
<dd>{{ syncFeedback.result.stats.skipped }}</dd>
</div>
</dl>
<div class="settings-sync-file-section">
<div class="settings-sync-file-heading">
<strong>变更文件</strong>
<span>{{ syncFileDetails.length }} 个文件</span>
</div>
<div v-if="syncFileDetails.length" class="settings-sync-file-list">
<div v-for="file in syncFileDetails" :key="`${file.action}:${file.path}`" class="settings-sync-file">
<span class="settings-sync-file-action" :data-action="file.action">
{{ syncActionLabel(file.action) }}
</span>
<code :title="file.path">{{ file.path }}</code>
<span class="settings-sync-file-size">{{ formatFileSize(file.size) }}</span>
</div>
</div>
<p v-else class="settings-sync-file-empty">本次同步没有文件变更</p>
</div>
</template>
</section>
</div>
</section>
</div>
<div v-else class="settings-content">
<section class="settings-about-card" aria-label="外观设置">
<div class="settings-row settings-row-top">
<div class="settings-copy">
<h3>外观主题</h3>
<p>主题同时应用于界面Markdown 预览和代码编辑</p>
</div>
</div>
<div class="theme-option-grid" role="radiogroup" aria-label="外观主题">
<button
v-for="option in themeOptions"
:key="option.value"
class="theme-option"
:class="{ 'theme-option-active': appTheme === option.value }"
type="button"
role="radio"
:aria-checked="appTheme === option.value"
@click="emit('updateAppTheme', option.value)"
>
<span class="theme-option-preview" aria-hidden="true">
<span
v-for="(swatch, index) in option.swatches"
:key="swatch"
:style="{ background: swatch, flex: index === 0 ? 2 : 1 }"
></span>
</span>
<span class="theme-option-copy">
<strong>{{ option.label }}</strong>
<small>{{ option.description }}</small>
</span>
<i v-if="appTheme === option.value" class="ri-check-line"></i>
</button>
</div>
</section>
</div>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.settings-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: var(--app-overlay);
backdrop-filter: blur(8px);
}
.settings-shell {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
width: min(820px, calc(100vw - 48px));
height: min(680px, calc(100vh - 48px));
min-height: 430px;
max-height: min(680px, calc(100vh - 48px));
overflow: hidden;
border: 1px solid var(--app-border-strong);
border-radius: 12px;
background: var(--app-surface);
box-shadow:
0 24px 70px var(--app-shadow),
0 2px 10px color-mix(in srgb, var(--app-shadow) 46%, transparent);
}
.settings-menu {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 10px;
border-right: 1px solid var(--app-border);
background: var(--app-sidebar-bg);
}
.settings-menu-title {
padding: 0 10px 8px;
color: var(--app-muted);
font-size: 12px;
font-weight: 600;
}
.settings-menu-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 34px;
padding: 7px 10px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--app-text-soft);
font-size: 13px;
line-height: 1;
text-align: left;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.settings-menu-item:hover {
background: var(--app-hover);
color: var(--app-text);
}
.settings-menu-item-active {
background: var(--app-active-bg);
color: var(--app-accent);
font-weight: 600;
}
.settings-main {
display: flex;
min-height: 0;
min-width: 0;
flex-direction: column;
background: var(--app-panel-bg);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 54px;
padding: 0 20px;
border-bottom: 1px solid var(--app-border);
background: color-mix(in srgb, var(--app-surface) 72%, transparent);
}
.settings-header h2 {
margin: 0;
color: var(--app-text);
font-size: 15px;
font-weight: 700;
}
.settings-close-btn {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--app-muted);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.settings-close-btn:hover {
background: var(--app-hover);
color: var(--app-text);
}
.settings-content {
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 22px;
}
.settings-sync-content {
scrollbar-gutter: stable;
}
.settings-about-card {
overflow: hidden;
border-radius: 10px;
background: var(--app-surface);
box-shadow: inset 0 0 0 1px var(--app-border);
}
.settings-about-logo {
display: flex;
justify-content: center;
align-items: center;
padding: 28px 20px 8px;
}
.settings-logo-img {
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 16px;
}
.settings-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 20px;
align-items: center;
min-height: 78px;
padding: 16px 20px;
border-top: 1px solid var(--app-border-strong);
}
.settings-row-top {
border-top: none;
}
.settings-copy {
min-width: 0;
}
.settings-copy h3,
.settings-empty-page h3 {
margin: 0 0 6px;
color: var(--app-text-strong);
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
.settings-copy p,
.settings-empty-page p {
margin: 0;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1.5;
}
.settings-link {
display: inline;
margin: 2px 0 0;
padding: 0;
border: none;
background: transparent;
color: var(--app-link);
font-size: 12px;
line-height: 1.4;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.settings-link:hover {
color: var(--app-link-hover);
}
.settings-action-stack {
display: flex;
width: 244px;
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.settings-config-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.settings-primary-btn,
.settings-secondary-btn {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
line-height: 1;
gap: 6px;
cursor: pointer;
transition:
background-color 0.15s ease,
box-shadow 0.15s ease,
color 0.15s ease;
}
.settings-primary-btn {
min-width: 78px;
border: none;
background: var(--app-control);
color: var(--app-control-contrast);
box-shadow: 0 2px 7px color-mix(in srgb, var(--app-control) 32%, transparent);
}
.settings-primary-btn:hover {
background: var(--app-control-hover);
}
.settings-secondary-btn {
min-width: 0;
border: 1px solid var(--app-border-strong);
background: var(--app-surface);
color: var(--app-text);
box-shadow: 0 1px 4px color-mix(in srgb, var(--app-shadow) 58%, transparent);
}
.settings-secondary-btn:hover {
background: var(--app-surface-muted);
}
.settings-primary-btn:disabled,
.settings-secondary-btn:disabled {
cursor: default;
opacity: 0.56;
box-shadow: none;
}
.settings-primary-btn:disabled:hover {
background: var(--app-control);
}
.settings-secondary-btn:disabled:hover {
background: var(--app-surface);
}
.settings-status {
color: var(--app-muted);
font-size: 11px;
white-space: nowrap;
}
.settings-switch {
position: relative;
display: inline-flex;
width: 40px;
height: 22px;
cursor: pointer;
}
.settings-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.settings-switch span {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--app-border-strong);
transition: background-color 0.16s ease;
}
.settings-switch span::after {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--app-surface);
box-shadow: 0 1px 3px var(--app-shadow);
content: "";
transition: transform 0.16s ease;
}
.settings-switch input:checked + span {
background: var(--app-control);
}
.settings-switch input:checked + span::after {
transform: translateX(18px);
}
.settings-switch input:focus-visible + span {
outline: 2px solid var(--app-accent-muted);
outline-offset: 2px;
}
.settings-select {
width: 158px;
height: 30px;
border: 1px solid var(--app-border-strong);
border-radius: 6px;
background: var(--app-surface);
color: var(--app-text);
font-size: 13px;
line-height: 1;
padding: 0 10px;
box-shadow: 0 1px 4px color-mix(in srgb, var(--app-shadow) 58%, transparent);
outline: none;
}
.settings-select:focus {
border-color: var(--app-accent-muted);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
}
.settings-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 16px 20px;
border-top: 1px solid var(--app-border-strong);
}
.settings-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.settings-field > span {
color: var(--app-text-soft);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.settings-input {
width: 100%;
min-width: 0;
height: 32px;
border: 1px solid var(--app-border-strong);
border-radius: 6px;
background: var(--app-surface);
color: var(--app-text);
font-size: 13px;
line-height: 1;
padding: 0 10px;
box-shadow: 0 1px 4px color-mix(in srgb, var(--app-shadow) 48%, transparent);
outline: none;
}
.settings-input::placeholder {
color: var(--app-subtle);
}
.settings-input:focus {
border-color: var(--app-accent-muted);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 12%, transparent);
}
.settings-switch-group {
display: flex;
min-width: 164px;
flex-direction: column;
gap: 10px;
}
.settings-switch-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1;
}
.settings-sync-actions {
align-items: flex-start;
}
.settings-sync-now-btn {
width: 100%;
}
.settings-sync-feedback {
display: flex;
min-width: 0;
grid-column: 1 / -1;
flex-direction: column;
gap: 12px;
border: 1px solid color-mix(in srgb, var(--app-accent) 26%, var(--app-border));
border-radius: 6px;
background: color-mix(in srgb, var(--app-accent) 7%, var(--app-surface));
padding: 12px;
}
.settings-sync-feedback-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
}
.settings-sync-feedback-progress {
border-color: var(--app-border-strong);
background: var(--app-surface-muted);
}
.settings-sync-feedback-header {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
align-items: flex-start;
}
.settings-sync-feedback-header > i {
margin-top: 1px;
color: var(--app-accent);
font-size: 16px;
}
.settings-sync-feedback-error .settings-sync-feedback-header > i,
.settings-sync-feedback-error .settings-sync-feedback-header strong {
color: #dc2626;
}
.settings-sync-feedback-header strong,
.settings-sync-file-heading strong {
display: block;
color: var(--app-text-strong);
font-size: 13px;
line-height: 1.35;
}
.settings-sync-feedback-header p,
.settings-sync-file-empty {
margin: 3px 0 0;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-config-path {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
border-top: 1px solid var(--app-border-strong);
padding-top: 10px;
}
.settings-config-path span,
.settings-sync-file-heading span,
.settings-sync-file-size {
color: var(--app-muted);
font-size: 11px;
line-height: 1.35;
}
.settings-config-path code {
min-width: 0;
color: var(--app-text);
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 11px;
overflow-wrap: anywhere;
}
.settings-feedback-details {
display: flex;
margin: 0;
flex-direction: column;
gap: 6px;
padding: 0;
list-style: none;
}
.settings-feedback-details li {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 5px;
color: var(--app-text-soft);
font-size: 12px;
line-height: 1.45;
}
.settings-feedback-details i {
color: var(--app-accent);
}
.settings-sync-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--app-border-strong);
border-bottom: 1px solid var(--app-border-strong);
padding: 9px 0;
}
.settings-sync-stats > div {
min-width: 0;
padding: 0 9px;
border-left: 1px solid var(--app-border-strong);
}
.settings-sync-stats > div:first-child {
border-left: none;
padding-left: 0;
}
.settings-sync-stats dt {
color: var(--app-muted);
font-size: 10px;
line-height: 1.3;
}
.settings-sync-stats dd {
margin: 3px 0 0;
color: var(--app-text-strong);
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.settings-sync-file-section {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.settings-sync-file-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-sync-file-list {
display: flex;
max-height: 220px;
overflow: auto;
flex-direction: column;
border: 1px solid var(--app-border-strong);
border-radius: 5px;
background: color-mix(in srgb, var(--app-surface) 78%, transparent);
}
.settings-sync-file {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 32px;
padding: 6px 9px;
border-top: 1px solid var(--app-border);
}
.settings-sync-file:first-child {
border-top: none;
}
.settings-sync-file-action {
color: var(--app-accent);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.settings-sync-file-action[data-action="deleteLocal"],
.settings-sync-file-action[data-action="deleteRemote"] {
color: #dc2626;
}
.settings-sync-file-action[data-action="skip"] {
color: var(--app-muted);
}
.settings-sync-file code {
min-width: 0;
overflow: hidden;
color: var(--app-text);
font-family: var(--app-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-spin {
animation: settings-spin 0.85s linear infinite;
}
@keyframes settings-spin {
to {
transform: rotate(360deg);
}
}
.theme-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 4px 20px 20px;
}
.theme-option {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 18px;
gap: 11px;
align-items: center;
min-height: 64px;
padding: 10px;
border: 1px solid var(--app-border-strong);
border-radius: 7px;
background: var(--app-surface);
color: var(--app-text);
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.theme-option:hover {
background: var(--app-surface-muted);
}
.theme-option-active {
border-color: var(--app-accent);
background: var(--app-active-bg);
}
.theme-option-preview {
display: flex;
width: 48px;
height: 38px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--app-text) 18%, transparent);
border-radius: 5px;
box-shadow: 0 2px 5px color-mix(in srgb, var(--app-shadow) 55%, transparent);
}
.theme-option-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.theme-option-copy strong {
color: var(--app-text-strong);
font-size: 13px;
line-height: 1.2;
}
.theme-option-copy small {
color: var(--app-muted);
font-size: 11px;
line-height: 1.35;
}
.theme-option > i {
color: var(--app-accent);
font-size: 16px;
}
.settings-empty-page {
display: flex;
align-items: center;
justify-content: center;
color: var(--app-muted);
text-align: center;
}
.settings-empty-page > div {
max-width: 320px;
}
@media (max-width: 680px) {
.settings-overlay {
align-items: stretch;
padding: 14px;
}
.settings-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
height: calc(100vh - 28px);
max-height: calc(100vh - 28px);
min-height: 0;
}
.settings-menu {
flex-direction: row;
align-items: center;
border-right: none;
border-bottom: 1px solid var(--app-border);
}
.settings-menu-title {
padding: 0 8px;
}
.settings-menu-item {
width: auto;
}
.settings-row {
grid-template-columns: 1fr;
gap: 12px;
}
.settings-form-row {
grid-template-columns: 1fr;
}
.settings-action-stack {
width: 100%;
align-items: flex-start;
}
.settings-config-actions,
.settings-sync-now-btn {
width: 100%;
}
.settings-config-path {
grid-template-columns: 1fr;
gap: 4px;
}
.settings-sync-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
row-gap: 10px;
}
.settings-sync-stats > div:nth-child(4) {
border-left: none;
}
.settings-sync-file {
grid-template-columns: 58px minmax(0, 1fr);
}
.settings-sync-file-size {
display: none;
}
.settings-switch-group {
min-width: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.settings-spin {
animation-duration: 1.8s;
}
}
</style>