完成webdav同步功能
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user