228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import { App, Button, Popover } from "antd";
|
|
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw, UploadCloud } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
|
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
|
import { overwriteRemoteCanvasProject, retryRemoteUserDataSync } from "@/services/user-data-sync";
|
|
import "./canvas-sync-status.css";
|
|
|
|
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
|
|
const { message } = App.useApp();
|
|
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
|
|
const [busy, setBusy] = useState(false);
|
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
const phase = progress?.phase;
|
|
const conflict = phase === "conflict";
|
|
const cloudError = phase === "error";
|
|
const localPhase = progress?.localPhase;
|
|
const localError = localPhase === "error";
|
|
const localPending = localPhase === "pending";
|
|
const retryScheduled = cloudError && progress?.message?.includes("自动重试");
|
|
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
|
|
const reconciling = phase === "reconciling";
|
|
const tone = conflict || cloudError || localError ? (conflict ? "conflict" : "error") : saving || reconciling || localPending ? "pending" : phase === "done" ? "done" : localPhase === "saved" ? "done" : "idle";
|
|
const label = conflict
|
|
? "需要处理冲突"
|
|
: cloudError
|
|
? "云端保存失败"
|
|
: localError
|
|
? "本地保存失败"
|
|
: reconciling
|
|
? "正在自动合并"
|
|
: saving
|
|
? "正在同步云端"
|
|
: localPending
|
|
? "正在保存本地"
|
|
: phase === "done"
|
|
? "已保存到云端"
|
|
: localPhase === "saved"
|
|
? "已保存在本地"
|
|
: "尚未同步";
|
|
const description = conflict
|
|
? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。"
|
|
: cloudError
|
|
? progress?.message || "本地内容仍然保留,云端暂未确认。"
|
|
: localError
|
|
? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。"
|
|
: reconciling
|
|
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
|
|
: saving
|
|
? progress?.message || "本地内容已保留,正在同步到云端。"
|
|
: localPending
|
|
? "正在把最近编辑写入浏览器本地存储。"
|
|
: phase === "done"
|
|
? progress?.message || "当前画布与云端版本一致。"
|
|
: localPhase === "saved"
|
|
? "当前编辑已保存到浏览器本地,登录后会继续同步到云端。"
|
|
: "本地内容会先保存到浏览器,再尝试同步到云端。";
|
|
const localStatus = localError ? progress?.localError || "保存失败" : localPending ? "正在保存" : localPhase === "saved" ? `已保存${progress?.localSavedAt ? ` · ${new Date(progress.localSavedAt).toLocaleTimeString()}` : ""}` : "尚未确认";
|
|
const cloudStatus = conflict
|
|
? "存在冲突"
|
|
: cloudError
|
|
? progress?.message || "保存失败"
|
|
: saving || reconciling
|
|
? "正在同步"
|
|
: phase === "done"
|
|
? `已保存${progress?.cloudRevision !== undefined ? ` · 版本 ${progress.cloudRevision}` : ""}`
|
|
: "尚未同步";
|
|
|
|
const run = async (operation: () => Promise<unknown>) => {
|
|
setBusy(true);
|
|
try {
|
|
await operation();
|
|
} catch (cause) {
|
|
message.error(cause instanceof Error ? cause.message : "操作失败,请重试");
|
|
throw cause;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const loadLatest = () => {
|
|
void run(async () => {
|
|
await onLoadLatest();
|
|
setStatusOpen(false);
|
|
message.success("云端版本已加载,本地内容已保留为草稿");
|
|
}).catch(() => undefined);
|
|
};
|
|
|
|
const overwriteCloud = () => {
|
|
void run(async () => {
|
|
await overwriteRemoteCanvasProject(projectId);
|
|
setStatusOpen(false);
|
|
message.success("本地版本已覆盖云端");
|
|
}).catch(() => undefined);
|
|
};
|
|
|
|
return (
|
|
<Popover
|
|
trigger="click"
|
|
placement="bottom"
|
|
open={statusOpen}
|
|
onOpenChange={setStatusOpen}
|
|
arrow={false}
|
|
classNames={{ root: "canvas-sync-popover", container: "canvas-sync-popover-surface", content: "canvas-sync-popover-content" }}
|
|
content={
|
|
<div className={`canvas-sync-panel canvas-sync-panel--${tone}`} data-canvas-no-zoom>
|
|
<div className="canvas-sync-panel__header">
|
|
<span className="canvas-sync-panel__status-icon" aria-hidden="true">
|
|
{saving || localPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff /> : <CloudCheck />}
|
|
</span>
|
|
<div className="canvas-sync-panel__heading">
|
|
<span className="canvas-sync-panel__eyebrow">云端同步</span>
|
|
<strong>{label}</strong>
|
|
</div>
|
|
</div>
|
|
<p className="canvas-sync-panel__description" role="status">
|
|
{description}
|
|
</p>
|
|
<div className="canvas-sync-panel__details" aria-label="画布保存分层状态">
|
|
<div>
|
|
<span>本地浏览器</span>
|
|
<strong>{localStatus}</strong>
|
|
</div>
|
|
<div>
|
|
<span>云端画布</span>
|
|
<strong>{cloudStatus}</strong>
|
|
</div>
|
|
{progress?.pendingWrites ? <small>待写入 {progress.pendingWrites} 项</small> : null}
|
|
</div>
|
|
{progress?.draftError ? (
|
|
<div className="canvas-sync-panel__callout">
|
|
<strong>本地草稿保存未完成,已暂停版本替换</strong>
|
|
<span>{progress.draftError}</span>
|
|
</div>
|
|
) : conflict ? (
|
|
<div className="canvas-sync-panel__callout">
|
|
<strong>请选择保留哪一版</strong>
|
|
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "本地内容仍在编辑器中。"} 加载云端版会替换当前编辑内容;用本地版覆盖会替换云端内容。</span>
|
|
</div>
|
|
) : cloudError ? (
|
|
<div className="canvas-sync-panel__callout">
|
|
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
|
|
<span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span>
|
|
</div>
|
|
) : localError ? (
|
|
<div className="canvas-sync-panel__callout">
|
|
<strong>本地保存没有完成</strong>
|
|
<span>{progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"}</span>
|
|
</div>
|
|
) : null}
|
|
<div className="canvas-sync-panel__actions">
|
|
{cloudError ? (
|
|
<Button
|
|
type="primary"
|
|
block
|
|
icon={<RefreshCw className="size-3.5" />}
|
|
loading={busy}
|
|
onClick={() =>
|
|
void run(() => retryRemoteUserDataSync(projectId))
|
|
.then(() => setStatusOpen(false))
|
|
.catch(() => undefined)
|
|
}
|
|
>
|
|
立即重试云端同步
|
|
</Button>
|
|
) : localError ? (
|
|
<Button
|
|
type="primary"
|
|
block
|
|
icon={<RefreshCw className="size-3.5" />}
|
|
loading={busy}
|
|
onClick={() =>
|
|
void run(() => flushCanvasStorePersistence())
|
|
.then(() => setStatusOpen(false))
|
|
.catch(() => undefined)
|
|
}
|
|
>
|
|
立即重试本地保存
|
|
</Button>
|
|
) : null}
|
|
{conflict ? (
|
|
<div className="canvas-sync-panel__conflict-actions">
|
|
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
|
加载云端版
|
|
</Button>
|
|
<Button type="primary" block icon={<UploadCloud className="size-3.5" />} danger disabled={busy || saving || localPending || localError || Boolean(progress?.draftError)} onClick={overwriteCloud}>
|
|
用本地版覆盖云端
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
|
加载云端版
|
|
</Button>
|
|
)}
|
|
<div className="canvas-sync-panel__secondary-actions">
|
|
{onOpenVersions ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
disabled={busy}
|
|
icon={<FileClock className="size-3.5" />}
|
|
onClick={() => {
|
|
setStatusOpen(false);
|
|
onOpenVersions();
|
|
}}
|
|
>
|
|
版本记录{progress?.draftCount ? ` · ${progress.draftCount} 份草稿` : ""}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
|
|
aria-label={`画布保存状态:${label}`}
|
|
aria-expanded={statusOpen}
|
|
icon={saving || localPending ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
|
|
>
|
|
<span className="canvas-sync-status-label text-xs">{label}</span>
|
|
</Button>
|
|
</Popover>
|
|
);
|
|
}
|