feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+227 -23
View File
@@ -1,4 +1,12 @@
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import {
ConflictExportGuard,
ProjectAutoSaveQueue,
ProjectStateConflict,
type ProjectSaveStatus,
} from "./project-autosave.js";
import "./project-pages.css";
@@ -31,6 +39,7 @@ interface ProjectListPayload {
}
interface ProjectDetailPayload extends ProjectSummary {
canvas_state: CanvasState;
created_at: string;
draft_prompt: string;
generations: Array<{
@@ -45,6 +54,7 @@ interface ProjectDetailPayload extends ProjectSummary {
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
pixel_height?: number;
pixel_width?: number;
save_status: "saved";
}
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
@@ -61,6 +71,52 @@ function formatUpdatedAt(value: string) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function initialCanvasState(project: Pick<ProjectDetailPayload, "current_image_id" | "pixel_height" | "pixel_width" | "ratio">): CanvasState {
return {
background: {
adjustments: {
brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill",
saturation: 0, sharpness: 0, temperature: 0,
},
asset_id: project.current_image_id,
},
elements: [],
pixel_height: project.pixel_height ?? (project.ratio === "9:16" ? 1920 : project.ratio === "3:4" ? 1440 : 1080),
pixel_width: project.pixel_width ?? (project.ratio === "4:3" ? 1440 : 1080),
ratio: project.ratio,
schema_version: 1,
};
}
async function downloadConflictPng(canvasState: CanvasState, projectName: string) {
const canvas = document.createElement("canvas");
canvas.width = canvasState.pixel_width;
canvas.height = canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) throw new Error("canvas_unavailable");
context.fillStyle = "#f6f6f4";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#d4d4cf";
const stripe = canvas.width / 4;
for (let index = 0; index < 4; index += 1) {
if (index % 2 === 1) context.fillRect(index * stripe, 0, stripe, canvas.height);
}
context.fillStyle = "#111111";
context.font = `900 ${Math.max(64, Math.floor(canvas.width / 7))}px Arial`;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("DADA", canvas.width / 2, canvas.height / 2);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((value) => value ? resolve(value) : reject(new Error("canvas_export_failed")), "image/png");
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.download = `${projectName.trim().replace(/[\\/:*?"<>|]+/g, "-") || "Dada"}-本页版本.png`;
anchor.href = url;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
return (
<header className="product-header">
@@ -351,9 +407,18 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<SessionPayload>();
const [project, setProject] = useState<ProjectDetailPayload>();
const [name, setName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameStatus, setNameStatus] = useState("");
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
const [conflictVersions, setConflictVersions] = useState<{ latest: number; page: number }>();
const [conflictExportBusy, setConflictExportBusy] = useState(false);
const [conflictExportUsed, setConflictExportUsed] = useState(false);
const [conflictNotice, setConflictNotice] = useState("");
const [pendingNavigation, setPendingNavigation] = useState<string>();
const [leaving, setLeaving] = useState(false);
const [leaveStatus, setLeaveStatus] = useState("");
const [loadingFailed, setLoadingFailed] = useState(false);
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const saveStatusRef = useRef<ProjectSaveStatus>("saved");
const conflictExportGuard = useRef(new ConflictExportGuard());
useEffect(() => {
let active = true;
@@ -363,7 +428,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
]).then(([nextSession, nextProject]) => {
if (!active) return;
setSession(nextSession);
setProject(nextProject);
const normalizedProject = {
...nextProject,
canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject),
save_status: nextProject.save_status ?? "saved",
};
setProject(normalizedProject);
setName(nextProject.name);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
@@ -371,30 +441,134 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
return () => { active = false; };
}, [projectId]);
useEffect(() => {
if (!project || !session) return;
const queue = new ProjectAutoSaveQueue({
initialState: { canvas_state: project.canvas_state, name: project.name },
initialVersion: project.state_version,
onConflict: (latestVersion) => setConflictVersions({ latest: latestVersion, page: queue.stateVersion }),
onSaved: (snapshot, stateVersion) => {
setProject((current) => current ? {
...current, canvas_state: snapshot.canvas_state, name: snapshot.name,
save_status: "saved", state_version: stateVersion,
} : current);
setName(snapshot.name);
},
onStatus: (status) => {
saveStatusRef.current = status;
setSaveStatus(status);
},
save: async (snapshot, stateVersion, operationId) => {
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
body: JSON.stringify(snapshot),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": operationId,
"If-Match": String(stateVersion),
"X-CSRF-Token": session.csrf_token,
},
method: "PUT",
});
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (response.status === 412) {
const conflict = await response.json() as { latest_state_version: number };
throw new ProjectStateConflict(conflict.latest_state_version);
}
if (!response.ok) throw new Error("project_save_failed");
const saved = await response.json() as { state_version: number };
return { stateVersion: saved.state_version };
},
});
queueRef.current = queue;
saveStatusRef.current = "saved";
setSaveStatus("saved");
return () => {
queue.dispose();
if (queueRef.current === queue) queueRef.current = undefined;
};
}, [project?.created_at, projectId, session?.csrf_token]);
useEffect(() => {
const guardActive = () => ["dirty", "saving", "failed"].includes(saveStatusRef.current);
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!guardActive()) return;
event.preventDefault();
event.returnValue = "";
void queueRef.current?.saveNow();
};
const interceptNavigation = (event: MouseEvent) => {
if (!guardActive() || event.defaultPrevented || event.button !== 0) return;
const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(target instanceof HTMLAnchorElement) || target.target || target.download) return;
const destination = new URL(target.href, window.location.href);
if (destination.origin !== window.location.origin) return;
event.preventDefault();
setLeaveStatus("");
setPendingNavigation(destination.href);
};
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", interceptNavigation, true);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", interceptNavigation, true);
};
}, []);
function updateName(value: string) {
if (!project || saveStatus === "conflicted") return;
setName(value);
const snapshot: ProjectEditableState = { canvas_state: project.canvas_state, name: value };
queueRef.current?.commit(snapshot);
}
async function rename(event: FormEvent) {
event.preventDefault();
if (!session || !project || savingName || !name.trim()) return;
setSavingName(true);
setNameStatus("");
if (!name.trim() || saveStatus === "conflicted") return;
await queueRef.current?.saveNow();
}
async function exportConflictVersion() {
if (!project || conflictExportBusy) return;
setConflictExportBusy(true);
setConflictNotice("");
try {
const result = await readJson<{ name: string; state_version: number }>(`/api/v1/projects/${projectId}`, {
body: JSON.stringify({ name }), headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token }, method: "PATCH",
});
setProject({ ...project, name: result.name, state_version: result.state_version });
setName(result.name);
setNameStatus("项目名已保存");
await conflictExportGuard.current.run(() => downloadConflictPng(project.canvas_state, name));
setConflictNotice("仅下载本页版本,未写入项目");
} catch {
setNameStatus("项目名保存失败");
setConflictNotice("本页版本导出失败,未写入项目");
} finally {
setSavingName(false);
setConflictExportUsed(conflictExportGuard.current.used);
setConflictExportBusy(false);
}
}
async function saveAndLeave() {
if (!pendingNavigation) return;
setLeaving(true);
setLeaveStatus("");
const saved = await queueRef.current?.saveNow();
if (saved) window.location.assign(pendingNavigation);
else setLeaveStatus("保存未完成,仍停留在当前页面");
setLeaving(false);
}
if (!project && !loadingFailed) return <LoadingPage label="正在读取项目详情" />;
if (!project) {
return <main className="product-loading"><p role="alert"></p><a href="/app/projects"></a></main>;
}
const atHistoryLimit = project.successful_image_count >= 10;
const conflicted = saveStatus === "conflicted";
const saveLabel = {
conflicted: "版本冲突",
dirty: "有未保存修改",
failed: "未保存",
saved: "已保存",
saving: "正在保存",
}[saveStatus];
return (
<div className="product-page">
@@ -405,12 +579,27 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<div><p>{project.status === "failed_empty" ? "FAILED EMPTY" : project.status.toUpperCase()}</p><h1>{project.name}</h1></div>
<span> {project.ratio}</span>
</header>
{conflicted && conflictVersions ? (
<section className="project-conflict" aria-live="assertive">
<div>
<p>STATE VERSION CONFLICT</p>
<h2></h2>
<span> {conflictVersions.page}</span>
<span> {conflictVersions.latest}</span>
</div>
<p></p>
<div className="project-conflict-actions">
<button disabled={conflictExportBusy || conflictExportUsed} onClick={exportConflictVersion} type="button"></button>
<button onClick={() => window.location.reload()} type="button"></button>
</div>
{conflictNotice ? <strong role="status">{conflictNotice}</strong> : null}
</section>
) : null}
<section className="project-identity" aria-labelledby="rename-title">
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p></div>
<div><h2 id="rename-title"></h2><p>state version {project.state_version}</p><strong className={`save-status ${saveStatus}`} aria-live={saveStatus === "failed" || conflicted ? "assertive" : "polite"}>{saveLabel}</strong></div>
<form onSubmit={rename}>
<input aria-label="项目名称" maxLength={80} onChange={(event) => setName(event.target.value)} value={name} />
<button disabled={savingName || !name.trim() || name.trim() === project.name} type="submit">{savingName ? "保存中" : "保存名称"}</button>
{nameStatus ? <span role="status">{nameStatus}</span> : null}
<input aria-label="项目名称" disabled={conflicted} maxLength={80} onChange={(event) => updateName(event.target.value)} value={name} />
<button disabled={conflicted || saveStatus === "saving" || !name.trim() || (saveStatus === "saved" && name.trim() === project.name)} type="submit"></button>
</form>
</section>
<div className="project-detail-grid">
@@ -418,12 +607,12 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-actions">
<button disabled={atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={!project.current_image_id} type="button"></button>
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
</div>
{atHistoryLimit ? <p className="project-blocker"></p> : null}
{project.status === "failed_empty" ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
{project.status === "failed_empty" && !conflicted ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
</section>
<section className="project-history" aria-labelledby="history-title">
<header><h2 id="history-title"></h2><strong>{project.successful_image_count} / 10 </strong></header>
@@ -442,6 +631,21 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
</section>
</div>
</main>
{pendingNavigation ? (
<div className="project-leave-overlay" role="presentation">
<section aria-labelledby="leave-project-title" aria-modal="true" className="project-leave-dialog" role="dialog">
<p>UNSAVED PROJECT</p>
<h2 id="leave-project-title"></h2>
<span></span>
<div>
<button disabled={leaving} onClick={saveAndLeave} type="button"></button>
<button disabled={leaving} onClick={() => window.location.assign(pendingNavigation)} type="button"></button>
<button disabled={leaving} onClick={() => setPendingNavigation(undefined)} type="button"></button>
</div>
{leaveStatus ? <strong role="alert">{leaveStatus}</strong> : null}
</section>
</div>
) : null}
<LocalOnlyFooter />
</div>
);