From 39a1e859b19503f790998f851fe6662f4fa87955 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 8 Jun 2026 14:59:30 +0800 Subject: [PATCH] feat(webdav): add WebDAV synchronization configuration and functionality --- CHANGELOG.md | 1 + docs/content/docs/progress/pending-test.mdx | 1 + .../(user)/canvas/components/canvas-node.tsx | 10 +- .../(user)/canvas/stores/use-canvas-store.ts | 2 + web/src/app/webdav-proxy/route.ts | 59 +++ .../components/layout/app-config-modal.tsx | 185 +++++++- web/src/services/app-sync.ts | 394 ++++++++++++++++++ web/src/services/webdav-sync.ts | 155 +++++++ web/src/stores/use-asset-store.ts | 7 + web/src/stores/use-config-store.ts | 35 +- 10 files changed, 845 insertions(+), 4 deletions(-) create mode 100644 web/src/app/webdav-proxy/route.ts create mode 100644 web/src/services/app-sync.ts create mode 100644 web/src/services/webdav-sync.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e72bd1..dc95362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased + [新增] 新增图片切图功能。 ++ [新增] 支持webdav同步数据。 ## v0.2.4 - 2026-06-04 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index c450c6e..f1a3adb 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。 - 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。 - 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。 - 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 diff --git a/web/src/app/(user)/canvas/components/canvas-node.tsx b/web/src/app/(user)/canvas/components/canvas-node.tsx index 8c09741..722af7c 100644 --- a/web/src/app/(user)/canvas/components/canvas-node.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node.tsx @@ -341,7 +341,7 @@ function NodeContent(props: NodeContentRendererProps) { if (props.node.metadata?.status === "error") return ; const Renderer = nodeContentRenderers[props.node.type]; - return ; + return Renderer ? : ; } const nodeContentRenderers = { @@ -382,6 +382,14 @@ function ErrorContent({ node, theme, onRetry }: Pick) { + return ( +
+ 未知节点 +
+ ); +} + function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) { return (
diff --git a/web/src/app/(user)/canvas/stores/use-canvas-store.ts b/web/src/app/(user)/canvas/stores/use-canvas-store.ts index 67e0e6a..1a17590 100644 --- a/web/src/app/(user)/canvas/stores/use-canvas-store.ts +++ b/web/src/app/(user)/canvas/stores/use-canvas-store.ts @@ -28,6 +28,7 @@ type CanvasStore = { openProject: (id: string) => CanvasProject | null; renameProject: (id: string, title: string) => void; deleteProjects: (ids: string[]) => void; + replaceProjects: (projects: CanvasProject[]) => void; updateProject: (id: string, patch: Partial>) => void; }; @@ -112,6 +113,7 @@ export const useCanvasStore = create()( const projects = state.projects.filter((project) => !ids.includes(project.id)); return { projects }; }), + replaceProjects: (projects) => set({ projects }), updateProject: (id, patch) => set((state) => ({ projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)), diff --git a/web/src/app/webdav-proxy/route.ts b/web/src/app/webdav-proxy/route.ts new file mode 100644 index 0000000..16fb961 --- /dev/null +++ b/web/src/app/webdav-proxy/route.ts @@ -0,0 +1,59 @@ +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const WEBDAV_PROXY_TIMEOUT_MS = 120000; + +export async function POST(request: NextRequest) { + const target = request.headers.get("x-webdav-target") || ""; + const method = (request.headers.get("x-webdav-method") || "GET").toUpperCase(); + if (!target) return new Response("Missing x-webdav-target", { status: 400 }); + + let url: URL; + try { + url = new URL(target); + } catch { + return new Response("Invalid x-webdav-target", { status: 400 }); + } + if (url.protocol !== "http:" && url.protocol !== "https:") return new Response("Unsupported WebDAV target", { status: 400 }); + + const headers = new Headers(); + copyHeader(request, headers, "x-webdav-authorization", "Authorization"); + copyHeader(request, headers, "x-webdav-depth", "Depth"); + copyHeader(request, headers, "x-webdav-destination", "Destination"); + copyHeader(request, headers, "x-webdav-overwrite", "Overwrite"); + copyHeader(request, headers, "x-webdav-content-type", "Content-Type"); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), WEBDAV_PROXY_TIMEOUT_MS); + try { + const body = method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer(); + console.log(`[webdav-proxy] ${method} ${url.href} ${body?.byteLength || 0}B`); + const response = await fetch(url, { method, headers, body: body?.byteLength ? body : undefined, signal: controller.signal }); + console.log(`[webdav-proxy] ${method} ${url.href} -> ${response.status}`); + return new Response(method === "HEAD" ? null : response.body, { + status: response.status, + headers: responseHeaders(response.headers), + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") return new Response("WebDAV proxy timeout", { status: 504 }); + return new Response(error instanceof Error ? error.message : "WebDAV proxy error", { status: 502 }); + } finally { + clearTimeout(timer); + } +} + +function copyHeader(request: NextRequest, headers: Headers, from: string, to: string) { + const value = request.headers.get(from); + if (value) headers.set(to, value); +} + +function responseHeaders(headers: Headers) { + const result = new Headers(); + ["content-type", "etag", "last-modified", "dav"].forEach((key) => { + const value = headers.get(key); + if (value) result.set(key, value); + }); + return result; +} diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index e8468d7..728a75e 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -1,10 +1,13 @@ "use client"; -import { App, Button, Form, Input, Modal, Segmented, Select } from "antd"; +import { App, Button, Form, Input, Modal, Progress, Segmented, Select } from "antd"; +import { Cloud, RefreshCw, Wifi } from "lucide-react"; import { useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { fetchImageModels } from "@/services/api/image"; +import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; +import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; @@ -16,6 +19,14 @@ type ModelGroup = { optionsLabel: string; }; +type WebdavDomainProgress = { + label: string; + stage: string; + current?: number; + total?: number; + status?: "active" | "success" | "exception"; +}; + const modelGroups: ModelGroup[] = [ { capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" }, { capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" }, @@ -23,11 +34,35 @@ const modelGroups: ModelGroup[] = [ { capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" }, ]; +const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"]; +const webdavDomainLabels: Record = { + canvas: "画布", + assets: "我的素材", + "image-workbench": "生图工作台", + "video-workbench": "视频创作台", +}; + +function createWebdavDomainProgress(): Record { + return webdavDomainKeys.reduce( + (progress, key) => ({ + ...progress, + [key]: { label: webdavDomainLabels[key], stage: "等待同步" }, + }), + {} as Record, + ); +} + export function AppConfigModal() { const { message } = App.useApp(); const [loadingModels, setLoadingModels] = useState(false); + const [testingWebdav, setTestingWebdav] = useState(false); + const [syncingWebdav, setSyncingWebdav] = useState(false); + const [webdavSyncStatus, setWebdavSyncStatus] = useState(""); + const [webdavDomainProgress, setWebdavDomainProgress] = useState(createWebdavDomainProgress); const config = useConfigStore((state) => state.config); + const webdav = useConfigStore((state) => state.webdav); const updateConfig = useConfigStore((state) => state.updateConfig); + const updateWebdavConfig = useConfigStore((state) => state.updateWebdavConfig); const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); @@ -39,6 +74,7 @@ export function AppConfigModal() { const effectiveMode = allowCustomChannel ? config.channelMode : "remote"; const modelConfig = effectiveMode === "remote" ? effectiveConfig : config; const modelOptions = config.models.map((model) => ({ label: model, value: model })); + const webdavReady = Boolean(webdav.url.trim()); const finishConfig = () => { setConfigDialogOpen(false); @@ -89,6 +125,57 @@ export function AppConfigModal() { if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || ""); }; + const testWebdav = async () => { + if (!webdavReady) { + message.error("请先填写 WebDAV 地址"); + return; + } + setTestingWebdav(true); + try { + await testWebdavConnection(webdav); + message.success("WebDAV 连接可用"); + } catch (error) { + message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败"); + } finally { + setTestingWebdav(false); + } + }; + + const updateWebdavProgress = (event: AppSyncProgressEvent) => { + setWebdavSyncStatus(event.stage); + if (!event.domain) return; + setWebdavDomainProgress((current) => ({ + ...current, + [event.domain as AppSyncDomainKey]: { + label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey], + stage: event.stage, + current: event.current, + total: event.total, + status: event.status, + }, + })); + }; + + const syncWebdav = async () => { + if (!webdavReady) { + message.error("请先填写 WebDAV 地址"); + return; + } + setSyncingWebdav(true); + setWebdavDomainProgress(createWebdavDomainProgress()); + setWebdavSyncStatus("准备同步"); + try { + const result = await syncAppDataToWebdav(webdav, updateWebdavProgress); + updateWebdavConfig("lastSyncedAt", result.syncedAt); + message.success(`同步完成:${result.projects} 个画布,${result.assets} 个素材,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`); + } catch (error) { + setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败"); + message.error(error instanceof Error ? error.message : "WebDAV 同步失败"); + } finally { + setSyncingWebdav(false); + } + }; + return ( updateConfig("systemPrompt", event.target.value)} /> ) : null} +
+
+
+
+ + WebDAV 同步 +
+
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。
+
+
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
+
+
+ + updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)} + options={[ + { label: "前端直连", value: "direct" }, + { label: "Next.js 转发", value: "nextjs" }, + ]} + /> + + + updateWebdavConfig("url", event.target.value)} /> + + + updateWebdavConfig("directory", event.target.value)} /> + + + updateWebdavConfig("username", event.target.value)} /> + + + updateWebdavConfig("password", event.target.value)} /> + +
+
+ + + {webdavSyncStatus ? {webdavSyncStatus} : null} +
+ {syncingWebdav || webdavSyncStatus ? ( +
+ {webdavDomainKeys.map((key) => { + const item = webdavDomainProgress[key]; + const count = item.total ? `${item.current || 0}/${item.total}` : ""; + return ( +
+
+ {item.label} + + {item.stage} + {count ? ` · ${count}` : ""} + +
+ +
+ ); + })} +
+ ) : null} +
@@ -237,3 +390,33 @@ function resolveNextCapabilityModels(current: string[], suggested: string[], all function uniqueModels(models: string[]) { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))); } + +function formatWebdavTime(value: string) { + return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); +} + +function getWebdavProgressPercent(item: WebdavDomainProgress) { + if (item.status === "success") return 100; + if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100)); + if (item.status === "exception") return 100; + if (item.stage === "等待同步") return 0; + if (item.stage === "读取远端清单") return 12; + if (item.stage === "读取本地数据") return 24; + if (item.stage === "下载缺失媒体") return 36; + if (item.stage === "写入本地合并结果") return 58; + if (item.stage === "上传新增媒体") return 66; + if (item.stage === "媒体已齐全" || item.stage === "媒体无需上传") return 74; + if (item.stage.startsWith("上传清单")) return 90; + return item.status === "active" ? 30 : 0; +} + +function getWebdavProgressStatus(item: WebdavDomainProgress): "normal" | "active" | "success" | "exception" { + if (item.status === "success" || item.status === "exception") return item.status; + return item.status === "active" ? "active" : "normal"; +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} diff --git a/web/src/services/app-sync.ts b/web/src/services/app-sync.ts new file mode 100644 index 0000000..1f4a976 --- /dev/null +++ b/web/src/services/app-sync.ts @@ -0,0 +1,394 @@ +"use client"; + +import localforage from "localforage"; + +import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage"; +import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage"; +import { downloadWebdavFile, uploadWebdavFile, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; +import type { Asset } from "@/stores/use-asset-store"; +import { useAssetStore } from "@/stores/use-asset-store"; +import type { WebdavSyncConfig } from "@/stores/use-config-store"; +import type { CanvasProject } from "@/app/(user)/canvas/stores/use-canvas-store"; +import { useCanvasStore } from "@/app/(user)/canvas/stores/use-canvas-store"; + +type StoredLog = Record & { id?: string }; +export type AppSyncDomainKey = "canvas" | "assets" | "image-workbench" | "video-workbench"; +type DomainKey = AppSyncDomainKey; +type CanvasDomainData = { projects: CanvasProject[] }; +type AssetDomainData = { assets: Asset[] }; +type LogDomainData = { logs: StoredLog[] }; + +type AppSyncFile = { + storageKey: string; + path: string; + mimeType: string; + bytes: number; +}; + +type DomainManifest = { + app: "infinite-canvas"; + version: 1; + domain: DomainKey; + exportedAt: string; + data: T; + files: AppSyncFile[]; +}; + +type SyncDomainOptions = { + key: DomainKey; + label: string; + localData: () => Promise; + emptyData: T; + mergeData: (local: T, remote: T) => T; + applyData?: (data: T) => Promise; +}; + +type SyncDomainResult = { + data: T; + mergedRemote: boolean; + files: number; + manifestBytes: number; + uploadedFiles: number; + uploadedBytes: number; +}; + +export type AppSyncResult = { + syncedAt: string; + mergedRemote: boolean; + projects: number; + assets: number; + imageLogs: number; + videoLogs: number; + files: number; + manifestBytes: number; + uploadedFiles: number; + uploadedBytes: number; +}; + +export type AppSyncProgressEvent = { + domain?: AppSyncDomainKey; + label?: string; + stage: string; + current?: number; + total?: number; + status?: "active" | "success" | "exception"; +}; + +export type AppSyncProgress = (event: AppSyncProgressEvent) => void; + +const FILE_CONCURRENCY = 3; +const imageLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" }); +const videoLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" }); +type LogStore = typeof imageLogStore; +const storageKeyPattern = /^(image|video|audio|file|video-reference|audio-reference):/; + +export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?: AppSyncProgress): Promise { + emitProgress(onProgress, { stage: "等待本地数据加载" }); + await Promise.all([waitForHydration(useCanvasStore), waitForHydration(useAssetStore)]); + + const [canvas, assets, imageLogs, videoLogs] = await Promise.all([ + syncDomain(config, onProgress, { + key: "canvas", + label: "画布", + emptyData: { projects: [] }, + localData: async () => ({ projects: useCanvasStore.getState().projects }), + mergeData: (local, remote) => ({ projects: mergeById(local.projects, remote.projects, "updatedAt") }), + applyData: async (data) => useCanvasStore.getState().replaceProjects(data.projects), + }), + syncDomain(config, onProgress, { + key: "assets", + label: "我的素材", + emptyData: { assets: [] }, + localData: async () => ({ assets: useAssetStore.getState().assets }), + mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }), + applyData: async (data) => useAssetStore.getState().replaceAssets(await Promise.all(data.assets.map(hydrateAsset))), + }), + syncDomain(config, onProgress, { + key: "image-workbench", + label: "生图工作台", + emptyData: { logs: [] }, + localData: async () => ({ logs: await readStoredLogs(imageLogStore) }), + mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }), + applyData: async (data) => replaceStoredLogs(imageLogStore, data.logs), + }), + syncDomain(config, onProgress, { + key: "video-workbench", + label: "视频创作台", + emptyData: { logs: [] }, + localData: async () => ({ logs: await readStoredLogs(videoLogStore) }), + mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }), + applyData: async (data) => replaceStoredLogs(videoLogStore, data.logs), + }), + ]); + + const result = { + syncedAt: new Date().toISOString(), + mergedRemote: [canvas, assets, imageLogs, videoLogs].some((item) => item.mergedRemote), + projects: canvas.data.projects.length, + assets: assets.data.assets.length, + imageLogs: imageLogs.data.logs.length, + videoLogs: videoLogs.data.logs.length, + files: canvas.files + assets.files + imageLogs.files + videoLogs.files, + manifestBytes: canvas.manifestBytes + assets.manifestBytes + imageLogs.manifestBytes + videoLogs.manifestBytes, + uploadedFiles: canvas.uploadedFiles + assets.uploadedFiles + imageLogs.uploadedFiles + videoLogs.uploadedFiles, + uploadedBytes: canvas.uploadedBytes + assets.uploadedBytes + imageLogs.uploadedBytes + videoLogs.uploadedBytes, + }; + emitProgress(onProgress, { stage: "同步完成", status: "success" }); + return result; +} + +async function syncDomain(config: WebdavSyncConfig, onProgress: AppSyncProgress | undefined, options: SyncDomainOptions): Promise> { + try { + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取远端清单", status: "active" }); + const remoteManifest = await readDomainManifest(config, options.key, options.emptyData); + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取本地数据", status: "active" }); + const localData = await options.localData(); + const mergedData = remoteManifest ? options.mergeData(localData, remoteManifest.data) : localData; + + if (remoteManifest) { + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "下载缺失媒体", status: "active" }); + await downloadMissingFiles(config, options.key, mergedData, remoteManifest.files, onProgress); + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "写入本地合并结果", status: "active" }); + await options.applyData?.(mergedData); + } + + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "上传新增媒体", status: "active" }); + const uploaded = await uploadChangedFiles(config, options.key, mergedData, remoteManifest?.files || [], onProgress); + const manifest: DomainManifest = { app: "infinite-canvas", version: 1, domain: options.key, exportedAt: new Date().toISOString(), data: mergedData, files: uploaded.files }; + const manifestFile = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" }); + emitProgress(onProgress, { domain: options.key, label: options.label, stage: `上传清单 ${formatBytes(manifestFile.size)}`, status: "active" }); + await uploadWebdavFile(config, domainPath(options.key, WEBDAV_MANIFEST_FILE_NAME), manifestFile, "application/json"); + emitProgress(onProgress, { domain: options.key, label: options.label, stage: "完成", current: 1, total: 1, status: "success" }); + + return { + data: mergedData, + mergedRemote: Boolean(remoteManifest), + files: uploaded.files.length, + manifestBytes: manifestFile.size, + uploadedFiles: uploaded.uploadedFiles, + uploadedBytes: uploaded.uploadedBytes, + }; + } catch (error) { + emitProgress(onProgress, { domain: options.key, label: options.label, stage: error instanceof Error ? error.message : "同步失败", status: "exception" }); + throw error; + } +} + +async function readDomainManifest(config: WebdavSyncConfig, domain: DomainKey, emptyData: T): Promise | null> { + const file = await downloadWebdavFile(config, domainPath(domain, WEBDAV_MANIFEST_FILE_NAME)); + if (!file) return null; + const data = JSON.parse(await file.text()) as DomainManifest; + if (data.app !== "infinite-canvas" || data.domain !== domain) throw new Error(`${domain} 同步清单不是当前应用的数据`); + return { + app: "infinite-canvas", + version: 1, + domain, + exportedAt: data.exportedAt || new Date().toISOString(), + data: data.data || emptyData, + files: Array.isArray(data.files) ? data.files : [], + }; +} + +async function downloadMissingFiles(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) { + const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item])); + const tasks: AppSyncFile[] = []; + const storageKeys = collectStorageKeys(data); + let scanned = 0; + for (const storageKey of storageKeys) { + const localBlob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); + scanned += 1; + if (localBlob) { + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" }); + continue; + } + const remoteFile = remoteFileMap.get(storageKey); + if (remoteFile) tasks.push(remoteFile); + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" }); + } + if (!tasks.length) { + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体已齐全", current: 1, total: 1, status: "active" }); + return; + } + let downloaded = 0; + await runWithConcurrency(tasks, FILE_CONCURRENCY, async (remoteFile) => { + const blob = await downloadWebdavFile(config, remoteFile.path); + if (!blob) return; + const typedBlob = blob.type ? blob : blob.slice(0, blob.size, remoteFile.mimeType); + await (remoteFile.storageKey.startsWith("image:") ? setImageBlob(remoteFile.storageKey, typedBlob) : setMediaBlob(remoteFile.storageKey, typedBlob)); + downloaded += 1; + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "下载媒体", current: downloaded, total: tasks.length, status: "active" }); + }); +} + +async function uploadChangedFiles(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) { + const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item])); + const files: AppSyncFile[] = []; + const tasks: Array<{ item: AppSyncFile; blob: Blob }> = []; + let uploadedFiles = 0; + let uploadedBytes = 0; + + const storageKeys = collectStorageKeys(data); + let scanned = 0; + for (const storageKey of storageKeys) { + const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); + const remoteFile = remoteFileMap.get(storageKey); + if (!blob) { + if (remoteFile) files.push(remoteFile); + scanned += 1; + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" }); + continue; + } + const item: AppSyncFile = { + storageKey, + path: remoteFile?.path || domainPath(domain, `files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`), + mimeType: blob.type || remoteFile?.mimeType || "application/octet-stream", + bytes: blob.size, + }; + files.push(item); + if (!remoteFile || remoteFile.bytes !== blob.size) tasks.push({ item, blob }); + scanned += 1; + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" }); + } + + if (!tasks.length) { + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体无需上传", current: 1, total: 1, status: "active" }); + return { files, uploadedFiles, uploadedBytes }; + } + + await runWithConcurrency(tasks, FILE_CONCURRENCY, async ({ item, blob }) => { + await uploadWebdavFile(config, item.path, blob, item.mimeType); + uploadedFiles += 1; + uploadedBytes += blob.size; + emitProgress(onProgress, { domain, label: domainLabel(domain), stage: `上传媒体 ${formatBytes(blob.size)}`, current: uploadedFiles, total: tasks.length, status: "active" }); + }); + + return { files, uploadedFiles, uploadedBytes }; +} + +async function hydrateAsset(asset: Asset): Promise { + if (asset.kind === "image" && asset.data.storageKey) { + const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl); + return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } }; + } + if (asset.kind === "video" && asset.data.storageKey) { + const url = await resolveMediaUrl(asset.data.storageKey, asset.data.url); + return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, url } }; + } + return asset; +} + +async function readStoredLogs(store: LogStore) { + const logs: StoredLog[] = []; + await store.iterate((value) => { + if (value && typeof value === "object") logs.push(value); + }); + return logs; +} + +async function replaceStoredLogs(store: LogStore, logs: StoredLog[]) { + await store.clear(); + await runWithConcurrency(logs, FILE_CONCURRENCY, async (log) => { + const id = getStringField(log, "id"); + if (id) await store.setItem(id, log); + }); +} + +function mergeById(local: T[], remote: T[], timeKey: string) { + const items = new Map(); + remote.forEach((item) => { + const id = item.id || ""; + if (id) items.set(id, item); + }); + local.forEach((item) => { + const id = item.id || ""; + if (!id) return; + const current = items.get(id); + if (!current || getTime(item as Record, timeKey) >= getTime(current as Record, timeKey)) items.set(id, item); + }); + return Array.from(items.values()).sort((a, b) => getTime(b as Record, timeKey) - getTime(a as Record, timeKey)); +} + +function collectStorageKeys(value: unknown, keys = new Set()) { + if (typeof value === "string") { + if (storageKeyPattern.test(value)) keys.add(value); + return [...keys]; + } + if (!value || typeof value !== "object") return [...keys]; + if ("storageKey" in value && typeof value.storageKey === "string" && storageKeyPattern.test(value.storageKey)) keys.add(value.storageKey); + Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys))); + return [...keys]; +} + +function domainPath(domain: DomainKey, path: string) { + return `${domain}/${path}`; +} + +function domainLabel(domain: DomainKey) { + if (domain === "canvas") return "画布"; + if (domain === "assets") return "我的素材"; + if (domain === "image-workbench") return "生图工作台"; + return "视频创作台"; +} + +function emitProgress(onProgress: AppSyncProgress | undefined, event: AppSyncProgressEvent) { + onProgress?.(event); +} + +function getStringField(item: Record, key: string) { + const value = item[key]; + return typeof value === "string" ? value : ""; +} + +function getTime(item: Record, key: string) { + const value = item[key]; + if (typeof value === "number") return value; + if (typeof value === "string") return Date.parse(value) || 0; + return 0; +} + +function safeFileName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, "_"); +} + +function fileExtension(mimeType: string, storageKey: string) { + if (mimeType.includes("png")) return "png"; + if (mimeType.includes("jpeg")) return "jpg"; + if (mimeType.includes("webp")) return "webp"; + if (mimeType.includes("gif")) return "gif"; + if (mimeType.includes("mp4")) return "mp4"; + if (mimeType.includes("webm")) return "webm"; + if (mimeType.includes("wav")) return "wav"; + if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3"; + return storageKey.startsWith("image:") ? "png" : "bin"; +} + +function waitForHydration(store: { getState: () => T; subscribe: (listener: (state: T) => void) => () => void }) { + if (store.getState().hydrated) return Promise.resolve(); + return new Promise((resolve) => { + const unsubscribe = store.subscribe((state) => { + if (!state.hydrated) return; + unsubscribe(); + resolve(); + }); + }); +} + +async function runWithConcurrency(items: T[], limit: number, worker: (item: T, index: number) => Promise) { + const results = new Array(items.length); + let nextIndex = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await worker(items[index], index); + } + }), + ); + return results; +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} diff --git a/web/src/services/webdav-sync.ts b/web/src/services/webdav-sync.ts new file mode 100644 index 0000000..4ca495f --- /dev/null +++ b/web/src/services/webdav-sync.ts @@ -0,0 +1,155 @@ +"use client"; + +import type { WebdavSyncConfig } from "@/stores/use-config-store"; + +export const WEBDAV_MANIFEST_FILE_NAME = "manifest.json"; +const WEBDAV_REQUEST_TIMEOUT_MS = 120000; +const ensuredDirectories = new Set(); + +export async function testWebdavConnection(config: WebdavSyncConfig) { + await ensureWebdavDirectory(config); + const response = await webdavFetch(config, "", { method: "PROPFIND", headers: { Depth: "0" } }); + if (response.ok || response.status === 207) return; + await throwWebdavError(response, "WebDAV 连接测试失败"); +} + +export async function downloadWebdavSyncFile(config: WebdavSyncConfig) { + return downloadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME); +} + +export async function downloadWebdavFile(config: WebdavSyncConfig, path: string) { + await ensureWebdavDirectory(config); + const response = await webdavFetch(config, path, { method: "GET" }); + if (response.status === 404) return null; + if (!response.ok) await throwWebdavError(response, "读取 WebDAV 同步文件失败"); + const file = await withTimeout(response.blob(), "读取 WebDAV 同步文件超时"); + return file.size ? file : null; +} + +export async function uploadWebdavSyncFile(config: WebdavSyncConfig, file: Blob) { + return uploadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME, file, "application/json"); +} + +export async function uploadWebdavFile(config: WebdavSyncConfig, path: string, file: Blob, contentType = "application/octet-stream") { + if (!file.size) throw new Error("上传文件为空,已取消上传"); + await ensureWebdavDirectory(config); + await ensureWebdavSubdirectory(config, path); + const response = await webdavFetch(config, path, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: file, + }); + if (!response.ok) await throwWebdavError(response, "上传 WebDAV 同步文件失败"); +} + +async function ensureWebdavDirectory(config: WebdavSyncConfig) { + assertWebdavConfig(config); + await ensureWebdavDirectoryPath(config, config.directory); +} + +async function ensureWebdavSubdirectory(config: WebdavSyncConfig, path: string) { + const directory = normalizePath(path).split("/").slice(0, -1).join("/"); + if (!directory) return; + await ensureWebdavDirectoryPath(config, [config.directory, directory].filter(Boolean).join("/")); +} + +async function ensureWebdavDirectoryPath(config: WebdavSyncConfig, directory: string) { + const parts = normalizePath(directory).split("/").filter(Boolean); + const cacheKey = `${config.proxyMode}:${config.url}:${parts.join("/")}`; + if (ensuredDirectories.has(cacheKey)) return; + let path = ""; + for (const part of parts) { + path = path ? `${path}/${part}` : part; + const response = await webdavFetch({ ...config, directory: "" }, path, { method: "MKCOL" }); + if (response.ok || ((response.status === 405 || response.status === 423) && (await webdavDirectoryExists(config, path)))) continue; + await throwWebdavError(response, "创建 WebDAV 远程目录失败"); + } + ensuredDirectories.add(cacheKey); +} + +async function webdavDirectoryExists(config: WebdavSyncConfig, path: string) { + const response = await webdavFetch({ ...config, directory: "" }, path, { method: "PROPFIND", headers: { Depth: "0" } }); + return response.ok || response.status === 207; +} + +async function webdavFetch(config: WebdavSyncConfig, path: string, init: RequestInit) { + const headers = new Headers(init.headers); + if (config.username || config.password) headers.set("Authorization", `Basic ${encodeBasicAuth(`${config.username}:${config.password}`)}`); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), WEBDAV_REQUEST_TIMEOUT_MS); + try { + const url = buildWebdavUrl(config, path); + if (config.proxyMode === "nextjs") return await fetch("/webdav-proxy", { method: "POST", headers: proxyHeaders(url, init.method || "GET", headers), body: proxyBody(init), signal: controller.signal }); + return await fetch(url, { ...init, headers, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw new Error("WebDAV 请求超时,请检查网络、代理或远端服务状态"); + if (error instanceof TypeError) throw new Error("无法连接 WebDAV,请检查地址、HTTPS 证书、CORS 或网络状态"); + throw error; + } finally { + window.clearTimeout(timer); + } +} + +function proxyHeaders(target: string, method: string, headers: Headers) { + const proxyHeaders = new Headers({ + "x-webdav-target": target, + "x-webdav-method": method, + }); + copyProxyHeader(headers, proxyHeaders, "Authorization", "x-webdav-authorization"); + copyProxyHeader(headers, proxyHeaders, "Depth", "x-webdav-depth"); + copyProxyHeader(headers, proxyHeaders, "Destination", "x-webdav-destination"); + copyProxyHeader(headers, proxyHeaders, "Overwrite", "x-webdav-overwrite"); + copyProxyHeader(headers, proxyHeaders, "Content-Type", "x-webdav-content-type"); + const contentType = headers.get("Content-Type"); + if (contentType) proxyHeaders.set("Content-Type", contentType); + return proxyHeaders; +} + +function copyProxyHeader(from: Headers, to: Headers, source: string, target: string) { + const value = from.get(source); + if (value) to.set(target, value); +} + +function proxyBody(init: RequestInit) { + const method = (init.method || "GET").toUpperCase(); + if (method === "GET" || method === "HEAD") return undefined; + return init.body || undefined; +} + +function buildWebdavUrl(config: WebdavSyncConfig, path: string) { + const baseUrl = config.url.trim().replace(/\/+$/, ""); + const remotePath = [normalizePath(config.directory), normalizePath(path)].filter(Boolean).join("/"); + if (!remotePath) return baseUrl; + return `${baseUrl}/${remotePath.split("/").map(encodeURIComponent).join("/")}`; +} + +function normalizePath(path: string) { + return path.trim().replace(/^\/+|\/+$/g, ""); +} + +function assertWebdavConfig(config: WebdavSyncConfig) { + if (!config.url.trim()) throw new Error("请先填写 WebDAV 地址"); +} + +async function throwWebdavError(response: Response, fallback: string): Promise { + const detail = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) throw new Error("WebDAV 认证失败,请检查用户名、密码或应用密码"); + if (response.status === 404) throw new Error("WebDAV 路径不存在,请检查地址和远程目录"); + throw new Error(`${fallback}:${response.status}${detail ? ` ${detail.slice(0, 120)}` : ""}`); +} + +function encodeBasicAuth(value: string) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +function withTimeout(promise: Promise, message: string) { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error(message)), WEBDAV_REQUEST_TIMEOUT_MS); + promise.then(resolve, reject).finally(() => window.clearTimeout(timer)); + }); +} diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 2e8ceae..e643637 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -28,10 +28,12 @@ type AssetBase = { }; type AssetStore = { + hydrated: boolean; assets: Asset[]; addAsset: (asset: Omit) => string; updateAsset: (id: string, patch: Partial>) => void; removeAsset: (id: string) => void; + replaceAssets: (assets: Asset[]) => void; cleanupImages: (extra?: unknown) => void; }; @@ -66,6 +68,7 @@ const assetStorage: PersistStorage = { export const useAssetStore = create()( persist( (set, get) => ({ + hydrated: false, assets: [], addAsset: (asset) => { const now = new Date().toISOString(); @@ -83,6 +86,7 @@ export const useAssetStore = create()( get().cleanupImages({ assets }); return { assets }; }), + replaceAssets: (assets) => set({ assets }), cleanupImages: (extra) => { window.setTimeout(async () => { const { useCanvasStore } = await import("@/app/(user)/canvas/stores/use-canvas-store"); @@ -95,6 +99,9 @@ export const useAssetStore = create()( name: ASSET_STORE_KEY, storage: assetStorage, partialize: (state) => ({ assets: state.assets }) as StorageValue["state"], + onRehydrateStorage: () => () => { + useAssetStore.setState({ hydrated: true }); + }, }, ), ); diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 059f76d..c3ba817 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -36,6 +36,15 @@ export type AiConfig = { canvasImageCount: string; }; +export type WebdavSyncConfig = { + proxyMode: "direct" | "nextjs"; + url: string; + username: string; + password: string; + directory: string; + lastSyncedAt: string; +}; + export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; export type ModelCapability = "image" | "video" | "text" | "audio"; @@ -68,13 +77,24 @@ export const defaultConfig: AiConfig = { canvasImageCount: "3", }; +export const defaultWebdavSyncConfig: WebdavSyncConfig = { + proxyMode: "direct", + url: "", + username: "", + password: "", + directory: "infinite-canvas", + lastSyncedAt: "", +}; + type ConfigStore = { config: AiConfig; + webdav: WebdavSyncConfig; publicSettings: AdminPublicSettings | null; isPublicSettingsLoading: boolean; isConfigOpen: boolean; shouldPromptContinue: boolean; updateConfig: (key: K, value: AiConfig[K]) => void; + updateWebdavConfig: (key: K, value: WebdavSyncConfig[K]) => void; loadPublicSettings: () => Promise; isAiConfigReady: (config: AiConfig, model: string) => boolean; openConfigDialog: (shouldPromptContinue?: boolean) => void; @@ -168,6 +188,7 @@ export const useConfigStore = create()( persist( (set, get) => ({ config: defaultConfig, + webdav: defaultWebdavSyncConfig, publicSettings: null, isPublicSettingsLoading: false, isConfigOpen: false, @@ -179,6 +200,13 @@ export const useConfigStore = create()( [key]: value, }, })), + updateWebdavConfig: (key, value) => + set((state) => ({ + webdav: { + ...state.webdav, + [key]: value, + }, + })), loadPublicSettings: async () => { if (get().isPublicSettingsLoading) return; set({ isPublicSettingsLoading: true }); @@ -195,12 +223,15 @@ export const useConfigStore = create()( }), { name: CONFIG_STORE_KEY, - partialize: (state) => ({ config: state.config }), + partialize: (state) => ({ config: state.config, webdav: state.webdav }), merge: (persisted, current) => { - const persistedConfig = ((persisted as Partial).config || {}) as Partial; + const persistedState = (persisted || {}) as Partial; + const persistedConfig = (persistedState.config || {}) as Partial; + const persistedWebdav = (persistedState.webdav || {}) as Partial; const config = { ...defaultConfig, ...persistedConfig }; return { ...current, + webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav }, config: { ...config, channelMode: config.channelMode || "remote",