mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(webdav): add WebDAV synchronization configuration and functionality
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 新增图片切图功能。
|
||||
+ [新增] 支持webdav同步数据。
|
||||
|
||||
## v0.2.4 - 2026-06-04
|
||||
|
||||
|
||||
@@ -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 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
|
||||
|
||||
@@ -341,7 +341,7 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return <Renderer {...props} />;
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
@@ -382,6 +382,14 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
||||
未知节点
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||
|
||||
@@ -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<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport">>) => void;
|
||||
};
|
||||
|
||||
@@ -112,6 +113,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<AppSyncDomainKey, string> = {
|
||||
canvas: "画布",
|
||||
assets: "我的素材",
|
||||
"image-workbench": "生图工作台",
|
||||
"video-workbench": "视频创作台",
|
||||
};
|
||||
|
||||
function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProgress> {
|
||||
return webdavDomainKeys.reduce(
|
||||
(progress, key) => ({
|
||||
...progress,
|
||||
[key]: { label: webdavDomainLabels[key], stage: "等待同步" },
|
||||
}),
|
||||
{} as Record<AppSyncDomainKey, WebdavDomainProgress>,
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={
|
||||
@@ -218,6 +305,72 @@ export function AppConfigModal() {
|
||||
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cloud className="size-4" />
|
||||
WebDAV 同步
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。</div>
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
|
||||
<Segmented
|
||||
block
|
||||
value={webdav.proxyMode}
|
||||
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
|
||||
options={[
|
||||
{ label: "前端直连", value: "direct" },
|
||||
{ label: "Next.js 转发", value: "nextjs" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="WebDAV 地址" className="mb-4">
|
||||
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4">
|
||||
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名" className="mb-0">
|
||||
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码 / 应用密码" className="mb-0">
|
||||
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
|
||||
{syncingWebdav ? "同步中" : "立即同步"}
|
||||
</Button>
|
||||
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null}
|
||||
</div>
|
||||
{syncingWebdav || webdavSyncStatus ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{webdavDomainKeys.map((key) => {
|
||||
const item = webdavDomainProgress[key];
|
||||
const count = item.total ? `${item.current || 0}/${item.total}` : "";
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
|
||||
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span>
|
||||
<span className="min-w-0 truncate text-right text-stone-500">
|
||||
{item.stage}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Progress percent={getWebdavProgressPercent(item)} size="small" status={getWebdavProgressStatus(item)} showInfo={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> & { 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<T> = {
|
||||
app: "infinite-canvas";
|
||||
version: 1;
|
||||
domain: DomainKey;
|
||||
exportedAt: string;
|
||||
data: T;
|
||||
files: AppSyncFile[];
|
||||
};
|
||||
|
||||
type SyncDomainOptions<T> = {
|
||||
key: DomainKey;
|
||||
label: string;
|
||||
localData: () => Promise<T>;
|
||||
emptyData: T;
|
||||
mergeData: (local: T, remote: T) => T;
|
||||
applyData?: (data: T) => Promise<void>;
|
||||
};
|
||||
|
||||
type SyncDomainResult<T> = {
|
||||
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<AppSyncResult> {
|
||||
emitProgress(onProgress, { stage: "等待本地数据加载" });
|
||||
await Promise.all([waitForHydration(useCanvasStore), waitForHydration(useAssetStore)]);
|
||||
|
||||
const [canvas, assets, imageLogs, videoLogs] = await Promise.all([
|
||||
syncDomain<CanvasDomainData>(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<AssetDomainData>(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<LogDomainData>(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<LogDomainData>(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<T>(config: WebdavSyncConfig, onProgress: AppSyncProgress | undefined, options: SyncDomainOptions<T>): Promise<SyncDomainResult<T>> {
|
||||
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<T> = { 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<T>(config: WebdavSyncConfig, domain: DomainKey, emptyData: T): Promise<DomainManifest<T> | 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<T>;
|
||||
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<T>(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<T>(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<Asset> {
|
||||
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<StoredLog, void>((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<T extends { id?: string }>(local: T[], remote: T[], timeKey: string) {
|
||||
const items = new Map<string, T>();
|
||||
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<string, unknown>, timeKey) >= getTime(current as Record<string, unknown>, timeKey)) items.set(id, item);
|
||||
});
|
||||
return Array.from(items.values()).sort((a, b) => getTime(b as Record<string, unknown>, timeKey) - getTime(a as Record<string, unknown>, timeKey));
|
||||
}
|
||||
|
||||
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
|
||||
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<string, unknown>, key: string) {
|
||||
const value = item[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getTime(item: Record<string, unknown>, 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<T extends { hydrated: boolean }>(store: { getState: () => T; subscribe: (listener: (state: T) => void) => () => void }) {
|
||||
if (store.getState().hydrated) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
if (!state.hydrated) return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>) {
|
||||
const results = new Array<R>(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`;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<never> {
|
||||
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<T>(promise: Promise<T>, message: string) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error(message)), WEBDAV_REQUEST_TIMEOUT_MS);
|
||||
promise.then(resolve, reject).finally(() => window.clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
@@ -28,10 +28,12 @@ type AssetBase<T extends AssetKind> = {
|
||||
};
|
||||
|
||||
type AssetStore = {
|
||||
hydrated: boolean;
|
||||
assets: Asset[];
|
||||
addAsset: (asset: Omit<Asset, "id" | "createdAt" | "updatedAt">) => string;
|
||||
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
|
||||
removeAsset: (id: string) => void;
|
||||
replaceAssets: (assets: Asset[]) => void;
|
||||
cleanupImages: (extra?: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -66,6 +68,7 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
export const useAssetStore = create<AssetStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
hydrated: false,
|
||||
assets: [],
|
||||
addAsset: (asset) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -83,6 +86,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
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<AssetStore>()(
|
||||
name: ASSET_STORE_KEY,
|
||||
storage: assetStorage,
|
||||
partialize: (state) => ({ assets: state.assets }) as StorageValue<AssetStore>["state"],
|
||||
onRehydrateStorage: () => () => {
|
||||
useAssetStore.setState({ hydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
updateWebdavConfig: <K extends keyof WebdavSyncConfig>(key: K, value: WebdavSyncConfig[K]) => void;
|
||||
loadPublicSettings: () => Promise<void>;
|
||||
isAiConfigReady: (config: AiConfig, model: string) => boolean;
|
||||
openConfigDialog: (shouldPromptContinue?: boolean) => void;
|
||||
@@ -168,6 +188,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
config: defaultConfig,
|
||||
webdav: defaultWebdavSyncConfig,
|
||||
publicSettings: null,
|
||||
isPublicSettingsLoading: false,
|
||||
isConfigOpen: false,
|
||||
@@ -179,6 +200,13 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
[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<ConfigStore>()(
|
||||
}),
|
||||
{
|
||||
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<ConfigStore>).config || {}) as Partial<AiConfig>;
|
||||
const persistedState = (persisted || {}) as Partial<ConfigStore>;
|
||||
const persistedConfig = (persistedState.config || {}) as Partial<AiConfig>;
|
||||
const persistedWebdav = (persistedState.webdav || {}) as Partial<WebdavSyncConfig>;
|
||||
const config = { ...defaultConfig, ...persistedConfig };
|
||||
return {
|
||||
...current,
|
||||
webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav },
|
||||
config: {
|
||||
...config,
|
||||
channelMode: config.channelMode || "remote",
|
||||
|
||||
Reference in New Issue
Block a user