完成webdav同步功能

This commit is contained in:
2026-07-10 23:57:23 +08:00
parent de5bd2fa01
commit 6a66c7867a
6 changed files with 2866 additions and 25 deletions

View File

@@ -1,20 +1,64 @@
<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";
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;
};
}
interface SyncConnectionTestResult {
message: string;
details: string[];
}
const props = withDefaults(
defineProps<{
show: boolean;
initialPage?: SettingsPage;
workspacePath?: string;
defaultDocumentMode?: DocumentMode;
appTheme?: AppTheme;
}>(),
{
initialPage: "about",
workspacePath: "",
defaultDocumentMode: "edit",
appTheme: "warm",
},
@@ -24,6 +68,8 @@ const emit = defineEmits<{
"update:show": [value: boolean];
updateDefaultDocumentMode: [mode: DocumentMode];
updateAppTheme: [theme: AppTheme];
prepareWorkspaceSync: [done: (saved: boolean) => void];
workspaceSynced: [];
}>();
const APP_NAME = "yurou";
@@ -36,14 +82,23 @@ 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 syncStatus = ref("");
const syncError = ref("");
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 menuItems: Array<{
id: SettingsPage;
label: string;
@@ -52,6 +107,7 @@ const menuItems: Array<{
{ 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<{
@@ -103,6 +159,49 @@ function savePref(key: string, value: string) {
}
}
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);
}
@@ -149,6 +248,87 @@ function resetTheme() {
emit("updateAppTheme", "warm");
}
function setSyncStatus(message: string, isError = false) {
if (isError) {
syncError.value = message;
syncStatus.value = "";
} else {
syncStatus.value = message;
syncError.value = "";
}
}
async function loadSyncConfig() {
try {
const config = await invoke<SyncConfig>("load_sync_config");
syncConfig.value = mergeSyncConfig(config);
syncError.value = "";
} catch (error) {
setSyncStatus(`读取同步配置失败: ${error}`, true);
}
}
async function saveSyncConfig() {
syncSaving.value = true;
try {
await invoke("save_sync_config", { config: syncConfig.value });
setSyncStatus("同步配置已保存");
} catch (error) {
setSyncStatus(`保存失败: ${error}`, true);
} finally {
syncSaving.value = false;
}
}
async function testSyncConnection() {
syncTesting.value = true;
try {
const result = await invoke<SyncConnectionTestResult>("test_sync_connection", { config: syncConfig.value });
setSyncStatus([result.message, ...result.details.map((item) => `- ${item}`)].join("\n"));
} catch (error) {
setSyncStatus(String(error), true);
} finally {
syncTesting.value = false;
}
}
function formatSyncResult(result: SyncResult) {
const { stats } = result;
return `同步完成:上传 ${stats.uploaded},下载 ${stats.downloaded},删除本地 ${stats.deletedLocal},删除远端 ${stats.deletedRemote},跳过 ${stats.skipped}`;
}
async function runSyncNow() {
if (!props.workspacePath) {
setSyncStatus("请先打开一个工作目录", true);
return;
}
syncRunning.value = true;
try {
setSyncStatus("正在同步:扫描本地和远端文件,远端列表请求最长等待 120 秒。");
await invoke("save_sync_config", { config: syncConfig.value });
const prepared = await new Promise<boolean>((resolve) => {
emit("prepareWorkspaceSync", resolve);
});
if (!prepared) {
setSyncStatus("同步已取消,当前文档尚未保存", true);
return;
}
const result = await invoke<SyncResult>("sync_now", {
workspace: props.workspacePath,
config: syncConfig.value,
});
syncConfig.value.lastSyncAt = result.lastSyncAt;
setSyncStatus(formatSyncResult(result));
emit("workspaceSynced");
} catch (error) {
setSyncStatus(`同步失败: ${error}`, true);
} finally {
syncRunning.value = false;
}
}
watch(
() => props.show,
(show) => {
@@ -156,6 +336,7 @@ watch(
activePage.value = props.initialPage;
updateStatus.value = "";
void loadAppVersion();
void loadSyncConfig();
},
);
@@ -185,7 +366,6 @@ onBeforeUnmount(() => {
v-if="show"
class="settings-overlay"
role="presentation"
@click.self="close"
@keydown="onOverlayKeydown"
>
<section
@@ -298,6 +478,214 @@ onBeforeUnmount(() => {
</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
v-if="syncStatus || syncError"
class="settings-sync-log"
:class="{ 'settings-sync-log-error': Boolean(syncError) }"
role="status"
>
{{ syncError || syncStatus }}
</div>
</div>
<div class="settings-action-stack">
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy"
@click="saveSyncConfig"
>
{{ syncSaving ? '保存中' : '保存配置' }}
</button>
<button
class="settings-secondary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none'"
@click="testSyncConnection"
>
{{ syncTesting ? '测试中' : '测试连接' }}
</button>
<button
class="settings-primary-btn"
type="button"
:disabled="isSyncBusy || syncConfig.provider === 'none' || !workspacePath"
@click="runSyncNow"
>
{{ syncRunning ? '同步中' : '立即同步' }}
</button>
</div>
</div>
</section>
</div>
<div v-else class="settings-content">
<section class="settings-about-card" aria-label="外观设置">
<div class="settings-row settings-row-top">
@@ -372,7 +760,9 @@ onBeforeUnmount(() => {
.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;
@@ -433,6 +823,7 @@ onBeforeUnmount(() => {
.settings-main {
display: flex;
min-height: 0;
min-width: 0;
flex-direction: column;
background: var(--app-panel-bg);
@@ -480,9 +871,14 @@ onBeforeUnmount(() => {
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;
@@ -592,6 +988,21 @@ onBeforeUnmount(() => {
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;
@@ -665,6 +1076,95 @@ onBeforeUnmount(() => {
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-log {
width: min(100%, 420px);
max-height: 138px;
margin-top: 10px;
overflow: auto;
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));
color: var(--app-accent);
font-size: 12px;
line-height: 1.5;
padding: 9px 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-sync-log-error {
border-color: color-mix(in srgb, #dc2626 32%, var(--app-border));
background: color-mix(in srgb, #dc2626 7%, var(--app-surface));
color: #dc2626;
}
.theme-swatch-group {
display: inline-flex;
align-items: center;
@@ -727,8 +1227,11 @@ onBeforeUnmount(() => {
.settings-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
max-height: none;
height: calc(100vh - 28px);
max-height: calc(100vh - 28px);
min-height: 0;
}
.settings-menu {
@@ -751,8 +1254,16 @@ onBeforeUnmount(() => {
gap: 12px;
}
.settings-form-row {
grid-template-columns: 1fr;
}
.settings-action-stack {
align-items: flex-start;
}
.settings-switch-group {
min-width: 0;
}
}
</style>