Compare commits

..

7 Commits

6 changed files with 406 additions and 52 deletions

View File

@ -5,6 +5,7 @@ import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/clien
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
import equal from "fast-deep-equal";
import {
AlertCircle,
AlertTriangle,
@ -171,6 +172,7 @@ import {
type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers";
import {
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state";
@ -5120,7 +5122,31 @@ export default function CanvasEditorLegacyPage({
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false);
const canvasLeaveApprovedRef = useRef(false);
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
const [canvasLeaveError, setCanvasLeaveError] = useState("");
canvasPersistenceScope.current = id;
const hasUnsavedCanvasChanges = useCallback(() => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
const content = (value: CanvasState) => [value.nodes.map(node => {
const { updatedAt: _updatedAt, ...nodeData } = node;
return { ...nodeData, connections: node.connections || [] };
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
return !equal(content(saved), content(current));
}, [buildPersistedCanvasData, id]);
const requestCanvasLeave = useCallback((url: string) => {
if (hasUnsavedCanvasChanges()) {
setCanvasLeaveError("");
setCanvasLeaveUrl(url);
return;
}
canvasLeaveApprovedRef.current = true;
window.location.assign(url);
}, [hasUnsavedCanvasChanges]);
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
@ -5147,13 +5173,13 @@ export default function CanvasEditorLegacyPage({
}, [buildPersistedCanvasData, id]);
const persistCanvasStateNow = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
if (externalCanvasConflictRef.current) return false;
setIsSaving(true);
try {
let canvasData = buildPersistedCanvasData();
let snapshot = JSON.stringify({ canvasData });
if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
clearCanvasDraft(id);
return true;
}
@ -5201,8 +5227,8 @@ export default function CanvasEditorLegacyPage({
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
});
canvasSyncErrorRef.current = result.status === 409
? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
console.error(`[Canvas] ${reason} save failed:`, result.error);
} else {
canvasSyncErrorRef.current = "";
@ -5232,6 +5258,34 @@ export default function CanvasEditorLegacyPage({
[persistCanvasStateNow]
);
const finishCanvasLeave = useCallback(async (save: boolean) => {
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
setCanvasLeaveError("");
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
return;
}
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
if (save) {
setIsSavingBeforeLeave(true);
try {
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
} catch (error) {
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
setIsSavingBeforeLeave(false);
return;
}
}
canvasLeaveApprovedRef.current = true;
clearCanvasDraft(id);
window.location.assign(canvasLeaveUrl);
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
return canvasSaveQueue.current!.enqueue(async () => {
setIsSaving(true);
@ -5274,7 +5328,7 @@ export default function CanvasEditorLegacyPage({
return canvasSaveQueue.current!.enqueueOperation(
async () => {
const saved = await persistCanvasStateNow("autosave");
if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
return true;
},
async () => {
@ -5296,20 +5350,18 @@ export default function CanvasEditorLegacyPage({
const persistCanvasBeforeAgent = useCallback(async () => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
if (savedCanvasSnapshotRef.current?.id === id) {
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
const current = buildPersistedCanvasData();
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
if (!hasUnsavedCanvasChanges()) return true;
}
// Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
return true;
}, [persistCanvasState, buildPersistedCanvasData, id]);
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
const canvasRemoteOperationContext = useMemo(() => ({
run: runCanvasRemoteOperation,
@ -5320,6 +5372,10 @@ export default function CanvasEditorLegacyPage({
if (isVideoMode) return;
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
return;
}
if (useCanvasStore.getState().isGenerating) {
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
return;
@ -5355,7 +5411,32 @@ export default function CanvasEditorLegacyPage({
clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
};
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
useEffect(() => {
if (isLoading || isAgentMode) return;
const retrySync = () => {
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
};
const onVisibilityChange = () => {
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
}
};
const retryTimer = window.setInterval(() => {
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
}, 20_000);
window.addEventListener("online", retrySync);
window.addEventListener("focus", retrySync);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.clearInterval(retryTimer);
window.removeEventListener("online", retrySync);
window.removeEventListener("focus", retrySync);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
useEffect(() => {
if (isLoading || isAgentOperationRefreshActive()) return;
@ -5458,18 +5539,25 @@ export default function CanvasEditorLegacyPage({
}
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
useEffect(() => {
if (isLoading || isAgentMode) return;
const handleBeforeUnload = () => {
const retainDraft = () => {
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
const canvasData = buildPersistedCanvasData();
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
return canvasData;
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!retainDraft()) return;
event.preventDefault();
event.returnValue = "true";
};
const handlePageHide = () => {
try {
if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return;
const canvasData = buildPersistedCanvasData();
writeCanvasDraft(id, {
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
snapshot: JSON.stringify({ canvasData }),
});
const canvasData = retainDraft();
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, {
method: "PUT",
@ -5488,18 +5576,20 @@ export default function CanvasEditorLegacyPage({
};
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handleBeforeUnload);
window.addEventListener("pagehide", handlePageHide);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload);
window.removeEventListener("pagehide", handlePageHide);
};
}, [
activeCanvasWorkspaceType,
buildPersistedCanvasData,
hasUnsavedCanvasChanges,
id,
isAgentOperationRefreshActive,
isAgentMode,
isLoading,
isSaving,
]);
// 智能命名与缩略图逻辑
@ -10509,6 +10599,20 @@ export default function CanvasEditorLegacyPage({
}
>
<CanvasAgentUiListener projectId={id} workspace="canvas" />
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>离开前保存画布?</DialogTitle>
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
</DialogHeader>
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
<DialogFooter className="gap-2">
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{canvasSyncConflicts.length > 0 ? (
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
<div className="min-w-0 flex-1 basis-64">
@ -11228,14 +11332,12 @@ export default function CanvasEditorLegacyPage({
{isVideoMode ? brandEntry : (
<CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode}
onHome={goHome}
onHome={() => requestCanvasLeave("/")}
onOpenFiles={() => {
setIsPanelOpen(true);
setPanelMode("fileList");
}}
onSave={() => void handleSave()}
projectTitle={projectTitle}
rightInset={panelOcclusionRight}
/>
@ -11340,6 +11442,7 @@ export default function CanvasEditorLegacyPage({
createUrl={createNewCanvasUrl}
currentId={id}
onClose={() => setPanelMode("chat")}
onNavigate={requestCanvasLeave}
workspaceLabel={workspaceLabel}
workspaceType={activeCanvasWorkspaceType}
/>

View File

@ -10,31 +10,39 @@ export function AppUpdateNotice() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
let disposed = false;
let controller: AbortController | undefined;
let checking = false;
const check = async () => {
if (checking || document.visibilityState !== "visible") return;
if (disposed || checking || document.visibilityState !== "visible") return;
checking = true;
controller = new AbortController();
const current = controller;
const timeout = window.setTimeout(() => current.abort(), 8000);
try {
const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) });
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
if (!response.ok) return;
const data = await response.json();
if (!controller.signal.aborted && typeof data.version === "string" && data.version)
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
} catch {
// A failed version read leaves the current workspace available.
} finally {
window.clearTimeout(timeout);
checking = false;
}
};
void check();
const interval = window.setInterval(check, 60_000);
window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
return () => {
controller.abort();
disposed = true;
controller?.abort();
clearInterval(interval);
window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check);
};
}, []);
@ -60,6 +68,7 @@ export function AppUpdateNotice() {
</AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
</AlertDialogFooter>
</AlertDialogContent>

View File

@ -8972,6 +8972,10 @@ export function CanvasChatPanel({
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (composerUploadProgress) {
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
return;
}
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
return;
@ -8981,9 +8985,17 @@ export function CanvasChatPanel({
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
const beforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "true";
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
return () => {
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
window.removeEventListener("beforeunload", beforeUnload);
};
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => {

View File

@ -19,6 +19,7 @@ const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListPanelProps {
currentId: string;
onClose: () => void;
onNavigate?: (url: string) => void;
workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string;
createUrl?: string;
@ -28,6 +29,7 @@ interface CanvasFileListPanelProps {
export function CanvasFileListPanel({
currentId,
onClose,
onNavigate,
workspaceType = "canvas",
workspaceLabel = "画布",
createUrl = "/canvas?new=1",
@ -88,6 +90,10 @@ export function CanvasFileListPanel({
// 创建新画布
const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl);
};
@ -100,6 +106,10 @@ export function CanvasFileListPanel({
}
const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板
if (onNavigate) {
onNavigate(targetUrl);
return;
}
// 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => {
window.location.assign(targetUrl);

View File

@ -2,14 +2,10 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void;
onHome: () => void;
onOpenFiles: () => void;
onSave: () => void;
projectTitle: string;
rightInset: number;
};
@ -18,11 +14,9 @@ const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({
isSaving,
onAddImage,
onHome,
onOpenFiles,
onSave,
projectTitle,
rightInset,
}: CanvasSurfaceHeaderProps) {
@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
<ReferenceIcon name="plus" size={16} />
</button>
</div>
<button
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
disabled={isSaving}
onClick={onSave}
type="button"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</div>
);
}

View File

@ -13,9 +13,13 @@ async function setup(page: Page, failRead = false) {
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
nativeResultNext: false,
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
};
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
: value)), updatedAt: revision() });
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
@ -24,6 +28,10 @@ async function setup(page: Page, failRead = false) {
const path = new URL(request.url()).pathname;
let data: unknown = {};
let status = 200;
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
state.uploadStarted++;
await new Promise<void>(resolve => { state.finishUpload = resolve; });
}
if (path === `/api/canvas/${projectId}`) {
if (request.method() === "GET") {
status = state.failRead ? 500 : 200;
@ -106,6 +114,140 @@ async function moveImage(page: Page) {
await page.mouse.up();
}
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
const event = new Event("beforeunload", { cancelable: true });
Object.defineProperty(event, "returnValue", { value: "", writable: true });
window.dispatchEvent(event);
return event.defaultPrevented && Boolean(event.returnValue);
});
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
const { state, errors } = await setup(page);
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
state.failSave = true;
await moveImage(page);
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
expect(state.canvasData.nodes[0].position.x).toBe(160);
state.failSave = false;
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
const { state, errors } = await setup(page);
await page.locator('[data-canvas-node-id="source"]').click();
state.failSave = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("分析这张已经保存的图片");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
expect(errors).toEqual([]);
});
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
const { state, errors } = await setup(page);
expect(await unloadWouldBeBlocked(page)).toBe(false);
await page.locator('[data-canvas-node-id="source"]').click();
expect(await unloadWouldBeBlocked(page)).toBe(false);
state.failSave = true;
await moveImage(page);
const writes = state.writes.length;
expect(await unloadWouldBeBlocked(page)).toBe(true);
expect(state.writes).toHaveLength(writes);
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
const { state, errors } = await setup(page);
state.failSave = true;
await moveImage(page);
const writes = state.writes.length;
await page.evaluate(() => {
window.dispatchEvent(new Event("pagehide"));
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
});
await expect.poll(() => state.writes.length).toBe(writes + 1);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await expect(dialog).toBeVisible();
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
await expect(dialog).toHaveCount(0);
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
state.failSave = true;
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
expect(state.submissions).toHaveLength(0);
state.failSave = false;
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(errors).toEqual([]);
});
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
state.failSave = true;
await moveImage(page);
expect(await unloadWouldBeBlocked(page)).toBe(true);
const writes = state.writes.length;
await page.getByRole("button", { name: "返回首页", exact: true }).click();
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.writes).toHaveLength(writes);
expect(state.canvasData.nodes[0].position.x).toBe(160);
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
expect(errors).toEqual([]);
});
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
const { state, errors } = await setup(page);
state.failSave = true;
await moveImage(page);
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
const { state, errors } = await setup(page);
await page.locator('[data-canvas-node-id="source"]').click();
@ -138,6 +280,7 @@ test("toolbar save failures show the cause and recover without a paid submission
state.failSave = true;
await page.locator('[data-tool-key="enhance"]').click();
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
expect(state.capabilitySubmissions).toHaveLength(0);
state.failSave = false;
await page.locator('[data-tool-key="enhance"]').click();
@ -309,6 +452,92 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
return () => navigations;
}
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.holdUpload = true;
await page.mouse.click(100, 90);
await page.evaluate(() => {
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
});
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
expect(await unloadWouldBeBlocked(page)).toBe(true);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
expect(refreshes()).toBe(0);
state.holdUpload = false;
state.finishUpload?.();
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
expect(state.canvasData.nodes).toHaveLength(2);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.holdUpload = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("上传后保留这条输入");
await editor.evaluate(element => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
});
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
expect(await unloadWouldBeBlocked(page)).toBe(true);
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
expect(refreshes()).toBe(0);
state.holdUpload = false;
state.finishUpload?.();
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
await expect(editor).toContainText("上传后保留这条输入");
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
expect(state.canvasData.nodes).toHaveLength(2);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
await page.context().addInitScript(() => {
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
});
const { state, errors } = await setup(page);
await showUpdate(page, state);
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
await page.clock.install();
const { state, errors } = await setup(page);
state.appVersion = "20261106T120000Z-nextrelease1";
await page.clock.fastForward(60_001);
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
@ -361,9 +590,19 @@ test("an update keeps the current page when automatic synchronization fails", as
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
expect(refreshes()).toBe(0);
expect(state.submissions).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "返回处理", exact: true }).click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
state.failSave = false;
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);