Files
tyx_AI_xhs/apps/web/src/project-pages.tsx
T

1023 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
type Ratio = "3:4" | "1:1" | "4:3" | "9:16";
type ProjectStatus = "active" | "failed_empty" | "trashed";
interface SessionPayload {
credits: { available_balance: number; reserved_balance: number };
csrf_token: string;
local_data?: LocalDataPayload;
user: { creator_name: string };
}
interface LocalDataPayload {
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
hard_limit_bytes: number;
managed_content_bytes: number;
}
interface AccountSettingsPayload {
local_data: LocalDataPayload;
}
interface ModelPayload {
config_set_version: number;
configured_default_model_id: string;
models: Array<{
config_version: number;
contract_validation_status: "verified" | "unverified";
credit_cost: number;
enabled: boolean;
is_default: boolean;
model_id: string;
prompt_max_length: number;
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
runtime_availability: { available_for_new_jobs: boolean; checked_at: string; reason: string | null };
supported_ratios: Ratio[];
}>;
recommended_model_id: string | null;
}
interface GenerationTaskPayload {
confirmed_credit_cost: number;
created_at: string;
error_category: GenerationErrorCategory | null;
generation_id: string;
model_config_version: number;
model_id: string;
project_id: string;
prompt: string;
ratio: Ratio;
reference_asset_ids?: string[];
reference_count: number;
reserved_credits: number;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: string;
}
type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled"
| "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid"
| "unknown_retryable" | "unknown_non_retryable";
const generationErrorActions: Record<GenerationErrorCategory, string> = {
gateway_balance_insufficient: "选择未受影响模型或联系管理员",
gateway_contract_invalid: "选择其他模型或联系管理员",
model_disabled: "选择其他模型或等待",
reference_invalid: "更换或移除参考图",
safety_rejected: "修改提示词或参考图",
unknown_non_retryable: "联系管理员",
unknown_retryable: "稍后重试",
upstream_failed: "稍后重试",
upstream_timeout: "使用原输入重试",
};
interface ProjectSummary {
current_image_id: string | null;
deleted_at?: string | null;
name: string;
project_id: string;
purge_at?: string | null;
ratio: Ratio;
state_version: number;
status: ProjectStatus;
successful_image_count: number;
updated_at: string;
}
interface ProjectListPayload {
active_count: number;
active_limit: 20;
projects: ProjectSummary[];
}
interface ProjectDetailPayload extends ProjectSummary {
canvas_state: CanvasState;
created_at: string;
draft_prompt: string;
generations: Array<{
created_at: string;
error_category: string | null;
generation_id: string;
prompt: string;
ratio: Ratio;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
updated_at: string;
}>;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
latest_exports: Array<{
byte_size: number;
created_at: string;
download_url: string;
export_id: string;
format: "jpg" | "png";
pixel_height: number;
pixel_width: number;
sha256: string;
state_version: number;
}>;
pixel_height?: number;
pixel_width?: number;
save_status: "saved";
}
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { credentials: "same-origin", ...init });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
async function readOptionalJson<T>(url: string): Promise<T | undefined> {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (response.status === 404) return undefined;
if (!response.ok) throw new Error("request_failed");
return response.json() as Promise<T>;
}
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);
}
export function ProductHeader({ current }: { current: "workspace" | "projects" | "credits" }) {
return (
<header className="product-header">
<a className="product-brand" href="/app">DADA</a>
<nav aria-label="主导航">
<a aria-current={current === "workspace" ? "page" : undefined} href="/app">创作</a>
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects">项目</a>
<a aria-current={current === "credits" ? "page" : undefined} href="/app/credits">点数</a>
<a href="/app/settings">设置</a>
</nav>
</header>
);
}
export function LocalOnlyFooter() {
return <footer className="local-only-footer">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</footer>;
}
function LoadingPage({ label }: { label: string }) {
return <main className="product-loading" aria-live="polite">{label}</main>;
}
function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectStatus }) {
return (
<div className="project-placeholder" data-ratio={ratio} data-status={status} aria-hidden="true">
<span>D</span><span>A</span><span>D</span><span>A</span>
</div>
);
}
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
const [projects, setProjects] = useState<ProjectListPayload>();
const [models, setModels] = useState<ModelPayload>();
const [selectedModelId, setSelectedModelId] = useState<string>();
const [currentTask, setCurrentTask] = useState<GenerationTaskPayload>();
const [localData, setLocalData] = useState<LocalDataPayload>();
const [generationStateLoaded, setGenerationStateLoaded] = useState(false);
const [loadingFailed, setLoadingFailed] = useState(false);
const [prompt, setPrompt] = useState("");
const [ratio, setRatio] = useState<Ratio>("3:4");
const [references, setReferences] = useState<File[]>([]);
const [submitting, setSubmitting] = useState(false);
const [generationNotice, setGenerationNotice] = useState("");
const [requiresReconfirmation, setRequiresReconfirmation] = useState(false);
const query = useMemo(() => new URLSearchParams(window.location.search), []);
const [targetProjectId, setTargetProjectId] = useState<string | undefined>(() => query.get("retry") ?? query.get("continue") ?? undefined);
const [existingReferenceAssetIds, setExistingReferenceAssetIds] = useState<string[]>([]);
useEffect(() => {
let active = true;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>("/api/v1/projects?status=active"),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
setLocalData(nextSession.local_data);
return Promise.allSettled([
readOptionalJson<ModelPayload>("/api/v1/models"),
readOptionalJson<GenerationTaskPayload>("/api/v1/generations/current"),
readOptionalJson<AccountSettingsPayload>("/api/v1/account/settings"),
]).then(([modelResult, taskResult, settingsResult]) => {
if (!active) return;
if (modelResult.status === "fulfilled") setModels(modelResult.value);
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
setGenerationStateLoaded(true);
});
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, []);
useEffect(() => {
if (!targetProjectId || prompt) return;
readOptionalJson<ProjectDetailPayload>(`/api/v1/projects/${targetProjectId}`).then((project) => {
if (!project) return;
setPrompt(project.draft_prompt);
setRatio(project.ratio);
}).catch(() => setGenerationNotice("暂时无法读取原项目。"));
}, [prompt, targetProjectId]);
useEffect(() => {
if (!currentTask || !["queued", "running"].includes(currentTask.status)) return;
const timer = window.setInterval(() => {
readOptionalJson<GenerationTaskPayload>(`/api/v1/generations/${currentTask.generation_id}`)
.then((task) => { if (task) setCurrentTask(task); })
.catch(() => undefined);
}, 2_000);
return () => window.clearInterval(timer);
}, [currentTask?.generation_id, currentTask?.status]);
const selectedModel = useMemo(() => {
if (!models) return undefined;
const modelId = selectedModelId ?? models.recommended_model_id ?? models.configured_default_model_id;
return models.models.find((model) => model.model_id === modelId && model.enabled
&& model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs);
}, [models, selectedModelId]);
const referenceBytes = references.reduce((sum, file) => sum + file.size, 0);
const referencesValid = selectedModel !== undefined
&& references.length <= selectedModel.reference_limits.max_files
&& referenceBytes <= selectedModel.reference_limits.max_total_bytes
&& references.every((file) => file.size > 0 && file.size <= selectedModel.reference_limits.max_file_bytes);
const capacityBlocksGeneration = localData?.capacity_status === "full" || localData?.capacity_status === "unavailable";
const canSubmit = generationStateLoaded && !currentTask && !submitting && !requiresReconfirmation && selectedModel !== undefined
&& prompt.trim().length > 0 && prompt.trim().length <= selectedModel.prompt_max_length
&& selectedModel.supported_ratios.includes(ratio) && referencesValid
&& session !== undefined && session.credits.available_balance >= selectedModel.credit_cost && !capacityBlocksGeneration;
async function submitGeneration(event: FormEvent) {
event.preventDefault();
if (!session || !selectedModel || !canSubmit) return;
const body = new FormData();
body.append("client_submission_id", crypto.randomUUID());
body.append("confirmed_credit_cost", String(selectedModel.credit_cost));
body.append("creation_mode", targetProjectId ? "existing_project" : "new_project");
body.append("existing_reference_asset_ids", JSON.stringify(existingReferenceAssetIds));
body.append("model_config_version", String(selectedModel.config_version));
body.append("model_id", selectedModel.model_id);
body.append("prompt", prompt.trim());
body.append("ratio", ratio);
if (targetProjectId) body.append("project_id", targetProjectId);
body.append("reference_manifest", JSON.stringify(references.map((file) => ({
file_name: file.name,
mime_type: file.type,
size: file.size,
}))));
for (const file of references) body.append("reference_files", file, file.name);
setSubmitting(true);
setGenerationNotice("");
try {
const response = await fetch("/api/v1/generations", {
body,
credentials: "same-origin",
headers: {
"Idempotency-Key": `generation-${crypto.randomUUID()}`,
"X-CSRF-Token": session.csrf_token,
},
method: "POST",
});
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (response.status === 412) {
setRequiresReconfirmation(true);
setGenerationNotice("模型配置已更新,请确认最新配置后重新提交。");
return;
}
if (!response.ok) {
setGenerationNotice(response.status === 507 ? "本机存储空间不足,当前不能创建新任务。" : "任务未提交,请检查当前状态后重试。");
return;
}
const result = await response.json() as { created: boolean; task: GenerationTaskPayload };
setCurrentTask(result.task);
setSession((current) => current ? {
...current,
credits: {
available_balance: current.credits.available_balance - (result.created ? result.task.reserved_credits : 0),
reserved_balance: current.credits.reserved_balance + (result.created ? result.task.reserved_credits : 0),
},
} : current);
setGenerationNotice(result.created ? "任务已提交。" : "已返回当前进行中的任务。");
} catch {
setGenerationNotice("任务未提交,请检查本机服务后重试。");
} finally {
setSubmitting(false);
}
}
function handleTerminalAction(task: GenerationTaskPayload) {
const category = task.error_category;
if (!category) return;
if (category === "unknown_non_retryable") {
setGenerationNotice("请联系超级管理员处理此任务。");
return;
}
setPrompt(task.prompt);
setRatio(task.ratio);
setTargetProjectId(task.project_id);
setExistingReferenceAssetIds(task.reference_asset_ids ?? []);
setCurrentTask(undefined);
if (["model_disabled", "gateway_balance_insufficient", "gateway_contract_invalid"].includes(category)) {
setSelectedModelId(undefined);
setGenerationNotice("请选择当前可用模型后重新提交。");
} else if (category === "safety_rejected" || category === "reference_invalid") {
setGenerationNotice("请修改输入后重新提交。");
} else {
setGenerationNotice("已恢复原任务输入,可以重新提交。");
}
}
async function confirmLatestModelConfiguration() {
try {
const next = await readJson<ModelPayload>("/api/v1/models");
setModels(next);
setRequiresReconfirmation(false);
setGenerationNotice("已确认最新配置,请重新检查点数和参考图后提交。");
} catch {
setGenerationNotice("暂时无法读取最新模型配置。");
}
}
if (loadingFailed) {
return (
<main className="product-loading">
<p role="alert">创作工作台暂时无法读取。</p>
<button onClick={() => window.location.reload()} type="button">重新读取</button>
</main>
);
}
if (!session || !projects) return <LoadingPage label="正在读取创作工作台" />;
const empty = projects.projects.length === 0;
return (
<div className="product-page">
<ProductHeader current="workspace" />
{empty ? (
<section className="workspace-art" aria-labelledby="workspace-title">
<div>
<strong>DADA</strong>
<h1 id="workspace-title">开始一张新作品</h1>
</div>
<div className="workspace-art-system" aria-hidden="true">
<i /><b>NEW PROJECT</b><b>LOCAL ONLY</b><b>NO CLOUD SYNC</b>
</div>
</section>
) : null}
<main className={`workspace-main ${empty ? "is-empty" : "has-projects"}`}>
<form className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"} onSubmit={submitGeneration}>
{!empty ? (
<div className="workspace-compact-heading">
<div><p>NEW PROJECT</p><h1 id="workspace-compact-title">新建创作</h1></div>
<span>当前可用 {session.credits.available_balance} </span>
</div>
) : null}
<label className="prompt-label" htmlFor={promptId}>描述你想生成的画面</label>
<textarea
id={promptId}
maxLength={4_000}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入提示词…"
value={prompt}
/>
<div className="generation-options">
<div className="model-status" aria-live="polite">
<span>模型</span>
{models?.models.some((model) => model.enabled && model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs) ? (
<select aria-label="模型" onChange={(event) => setSelectedModelId(event.target.value || undefined)} value={selectedModel?.model_id ?? ""}>
{models.models.filter((model) => model.enabled && model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs)
.map((model) => <option key={model.model_id} value={model.model_id}>{model.model_id}</option>)}
</select>
) : <strong>当前没有可用于新任务的模型</strong>}
</div>
<fieldset className="ratio-control">
<legend>画面比例</legend>
<div>
{(["3:4", "1:1", "4:3", "9:16"] as const).map((value) => (
<label key={value}>
<input
checked={ratio === value}
disabled={selectedModel !== undefined && !selectedModel.supported_ratios.includes(value)}
name="ratio"
onChange={() => setRatio(value)}
type="radio"
value={value}
/>
<span>{value}</span>
</label>
))}
</div>
</fieldset>
</div>
<label className="reference-input">
<span>添加参考图</span>
<small>{references.length > 0 ? references.map((file) => file.name).join("、") : "尚未提交,仅保留在当前页面"}</small>
<input
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setReferences(Array.from(event.target.files ?? []))}
type="file"
/>
</label>
{localData?.capacity_status === "critical" ? (
<div className="capacity-critical" role="status">
<strong>存储空间已超过 90%</strong>
<span>请尽快清理本机内容,达到上限后将无法提交新任务。</span>
</div>
) : null}
{generationNotice ? (
<div className="generation-notice" role="status">
<span>{generationNotice}</span>
{requiresReconfirmation ? (
<button onClick={confirmLatestModelConfiguration} type="button">确认最新配置</button>
) : null}
</div>
) : null}
<div className="generation-submit">
<span>{selectedModel ? `本次预计冻结 ${selectedModel.credit_cost} 点` : "预计点数将在模型可用后显示"}</span>
<button disabled={!canSubmit} type="submit">{submitting ? "正在提交" : "生成一张图片"}</button>
</div>
</form>
<aside className="current-task" aria-labelledby="current-task-title">
<h2 id="current-task-title">当前任务</h2>
{currentTask ? (
<div className="current-task-active">
<span className="task-state">{{
failed: "生成失败", queued: "排队中", rejected: "请求未通过", running: "处理中", succeeded: "生成成功",
}[currentTask.status]}</span>
<strong>{currentTask.prompt}</strong>
<dl>
<div><dt>画面比例</dt><dd>{currentTask.ratio}</dd></div>
<div><dt>参考图</dt><dd>{currentTask.reference_count} </dd></div>
<div><dt>点数</dt><dd>{currentTask.status === "succeeded" ? `已扣除 ${currentTask.confirmed_credit_cost} 点` : ["failed", "rejected"].includes(currentTask.status) ? "已释放冻结点" : `已冻结 ${currentTask.reserved_credits} 点`}</dd></div>
</dl>
{currentTask.status === "succeeded" ? <a href={`/app/projects/${currentTask.project_id}`}>进入编辑</a> : null}
{["queued", "running"].includes(currentTask.status) ? <a href={`/app/projects/${currentTask.project_id}`}>返回当前项目</a> : null}
{["failed", "rejected"].includes(currentTask.status) && currentTask.error_category ? (
<button onClick={() => handleTerminalAction(currentTask)} type="button">{generationErrorActions[currentTask.error_category]}</button>
) : null}
</div>
) : (
<div className="current-task-empty"><i /><strong>没有进行中的任务</strong><span>任务状态会持续显示在这里</span></div>
)}
</aside>
{!empty ? (
<section className="recent-projects" aria-labelledby="recent-title">
<header><h2 id="recent-title">最近项目</h2><a href="/app/projects">查看全部项目</a></header>
<div className="project-grid compact">
{projects.projects.slice(0, 6).map((project) => <ProjectCard key={project.project_id} project={project} />)}
</div>
</section>
) : null}
</main>
<LocalOnlyFooter />
</div>
);
}
function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, onTrash, project, selectable, selected }: {
activeLimitReached?: boolean;
busy?: boolean;
onPurge?: (() => void) | undefined;
onRestore?: (() => void) | undefined;
onSelect?: (selected: boolean) => void;
onTrash?: (() => void) | undefined;
project: ProjectSummary;
selectable?: boolean;
selected?: boolean;
}) {
return (
<article className="project-card" data-status={project.status}>
{selectable ? (
<label className="project-select">
<input
aria-label={`选择失败草稿:${project.name}`}
checked={selected}
onChange={(event) => onSelect?.(event.target.checked)}
type="checkbox"
/>
</label>
) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
{project.status === "trashed" && project.purge_at ? <p className="trash-expiry">将在 {formatUpdatedAt(project.purge_at)} 永久删除</p> : null}
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
<div className="project-card-actions">
{project.status !== "trashed" ? <a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}>打开</a> : null}
{onTrash ? <button aria-label={`移入回收站:${project.name}`} disabled={busy} onClick={onTrash} type="button">移入回收站</button> : null}
{onRestore ? <button aria-label={`恢复项目:${project.name}`} disabled={busy || activeLimitReached} onClick={onRestore} type="button">恢复</button> : null}
{onPurge ? <button aria-label={`永久删除:${project.name}`} disabled={busy} onClick={onPurge} type="button">永久删除</button> : null}
</div>
</div>
</article>
);
}
export function ProjectsPage() {
const [session, setSession] = useState<SessionPayload>();
const [payload, setPayload] = useState<ProjectListPayload>();
const [status, setStatus] = useState<"active" | "trashed">("active");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<"updated" | "name">("updated");
const [selected, setSelected] = useState<string[]>([]);
const [loadingFailed, setLoadingFailed] = useState(false);
const [notice, setNotice] = useState("");
const [busyProjectId, setBusyProjectId] = useState<string>();
const [pendingPurge, setPendingPurge] = useState<ProjectSummary>();
useEffect(() => {
let active = true;
setLoadingFailed(false);
Promise.all([
session ? Promise.resolve(session) : readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectListPayload>(`/api/v1/projects?status=${status}`),
]).then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setPayload(nextProjects);
setSelected([]);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
return () => { active = false; };
}, [status]);
const visible = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase("zh-CN");
const items = (payload?.projects ?? []).filter((project) => project.name.toLocaleLowerCase("zh-CN").includes(normalized));
return items.toSorted((left, right) => sort === "name"
? left.name.localeCompare(right.name, "zh-CN")
: Date.parse(right.updated_at) - Date.parse(left.updated_at));
}, [payload, query, sort]);
async function batchTrash() {
if (!session || selected.length === 0) return;
try {
const result = await readJson<{ ignored_project_ids: string[]; trashed_project_ids: string[] }>("/api/v1/projects/failed-empty/trash", {
body: JSON.stringify({ project_ids: selected }),
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
setPayload((current) => current ? {
...current,
active_count: Math.max(0, current.active_count - result.trashed_project_ids.length),
projects: current.projects.filter((project) => !result.trashed_project_ids.includes(project.project_id)),
} : current);
setSelected([]);
setNotice("失败草稿已移入回收站");
} catch {
setNotice("操作未完成,请重试");
}
}
async function runLifecycle(project: ProjectSummary, action: "trash" | "restore" | "purge") {
if (!session || busyProjectId) return;
setBusyProjectId(project.project_id);
try {
await readJson(`/api/v1/projects/${project.project_id}/${action}`, {
headers: { "X-CSRF-Token": session.csrf_token },
method: "POST",
});
setPayload((current) => current ? {
...current,
active_count: action === "trash"
? Math.max(0, current.active_count - 1)
: action === "restore"
? Math.min(current.active_limit, current.active_count + 1)
: current.active_count,
projects: current.projects.filter((item) => item.project_id !== project.project_id),
} : current);
setNotice(action === "trash"
? "项目已移入回收站"
: action === "restore"
? "项目已恢复"
: "项目已永久删除,物理清理将在后台完成");
if (action === "purge") setPendingPurge(undefined);
} catch {
setNotice("操作未完成,请重试");
} finally {
setBusyProjectId(undefined);
}
}
if (!payload && !loadingFailed) return <LoadingPage label="正在读取项目" />;
return (
<div className="product-page">
<ProductHeader current="projects" />
<main className="projects-page">
<header className="projects-title">
<div><p>LOCAL PROJECTS</p><h1>项目</h1></div>
<strong>{payload?.active_count ?? 0} / 20 active</strong>
</header>
<div className="projects-tabs" role="tablist" aria-label="项目状态">
<button aria-selected={status === "active"} onClick={() => setStatus("active")} role="tab" type="button">项目</button>
<button aria-selected={status === "trashed"} onClick={() => setStatus("trashed")} role="tab" type="button">回收站</button>
</div>
<div className="projects-controls">
<input aria-label="搜索项目" onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目" type="search" value={query} />
<select aria-label="状态筛选" value={status} onChange={(event) => setStatus(event.target.value as "active" | "trashed")}>
<option value="active">active</option><option value="trashed">trashed</option>
</select>
<select aria-label="项目排序" value={sort} onChange={(event) => setSort(event.target.value as "updated" | "name")}>
<option value="updated">最近更新</option><option value="name">名称</option>
</select>
</div>
{loadingFailed ? <p className="projects-stale" role="alert">项目列表读取失败。{payload ? "当前显示上次读取的数据。" : ""}</p> : null}
{notice ? <p className="projects-notice" role="status">{notice}</p> : null}
{status === "trashed" && payload && payload.active_count === payload.active_limit ? (
<p className="projects-stale">活动项目已达 20 个,请先释放名额</p>
) : null}
{visible.length === 0 ? (
<section className="projects-empty">
<h2>{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}</h2>
{query ? <button onClick={() => setQuery("")} type="button">清除筛选</button> : status === "active" ? <a href="/app">前往创作</a> : null}
</section>
) : (
<div className="project-grid">
{visible.map((project) => (
<ProjectCard
key={project.project_id}
onSelect={(checked) => setSelected((current) => checked
? [...current, project.project_id]
: current.filter((projectId) => projectId !== project.project_id))}
project={project}
activeLimitReached={payload?.active_count === payload?.active_limit}
busy={busyProjectId === project.project_id}
onPurge={status === "trashed" ? () => setPendingPurge(project) : undefined}
onRestore={status === "trashed" ? () => runLifecycle(project, "restore") : undefined}
onTrash={status === "active" ? () => runLifecycle(project, "trash") : undefined}
selectable={status === "active" && project.status === "failed_empty"}
selected={selected.includes(project.project_id)}
/>
))}
</div>
)}
{status === "active" && payload?.projects.some((project) => project.status === "failed_empty") ? (
<section className="failed-draft-bar" aria-label="失败草稿快捷清理">
<span>已选 {selected.length} 个无成功图草稿</span>
<button disabled={selected.length === 0} onClick={batchTrash} type="button">批量移入回收站</button>
</section>
) : null}
</main>
{pendingPurge ? (
<div className="project-purge-overlay">
<section aria-labelledby="project-purge-title" aria-modal="true" className="project-purge-dialog" role="dialog">
<p>IRREVERSIBLE ACTION</p>
<h2 id="project-purge-title">永久删除项目</h2>
<p>{pendingPurge.name}”将立即无法恢复。关联数据和文件会进入后台物理清理队列。</p>
<div>
<button disabled={busyProjectId === pendingPurge.project_id} onClick={() => runLifecycle(pendingPurge, "purge")} type="button">确认永久删除</button>
<button disabled={Boolean(busyProjectId)} onClick={() => setPendingPurge(undefined)} type="button">取消</button>
</div>
</section>
</div>
) : null}
<LocalOnlyFooter />
</div>
);
}
export function ProjectDetailPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<SessionPayload>();
const [project, setProject] = useState<ProjectDetailPayload>();
const [name, setName] = 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;
Promise.all([
readJson<SessionPayload>("/api/v1/auth/session"),
readJson<ProjectDetailPayload>(`/api/v1/projects/${projectId}`),
]).then(([nextSession, nextProject]) => {
if (!active) return;
setSession(nextSession);
const normalizedProject = {
...nextProject,
canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject),
latest_exports: nextProject.latest_exports ?? [],
save_status: nextProject.save_status ?? "saved",
};
setProject(normalizedProject);
setName(nextProject.name);
}).catch((error) => {
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
});
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 (!name.trim() || saveStatus === "conflicted") return;
await queueRef.current?.saveNow();
}
async function exportConflictVersion() {
if (!project || conflictExportBusy) return;
setConflictExportBusy(true);
setConflictNotice("");
try {
await conflictExportGuard.current.run(() => downloadConflictPng(project.canvas_state, name));
setConflictNotice("仅下载本页版本,未写入项目");
} catch {
setConflictNotice("本页版本导出失败,未写入项目");
} finally {
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">
<ProductHeader current="projects" />
<main className="project-detail-page">
<header className="project-detail-header">
<a href="/app/projects" aria-label="返回项目列表"></a>
<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><strong className={`save-status ${saveStatus}`} aria-live={saveStatus === "failed" || conflicted ? "assertive" : "polite"}>{saveLabel}</strong></div>
<form onSubmit={rename}>
<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">
<section className="project-current" aria-labelledby="current-image-title">
<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={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
{conflicted || !project.current_image_id ? <button disabled type="button">下载原始图</button> : <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}>下载原始图</a>}
</div>
{atHistoryLimit ? <p className="project-blocker">请先删除一张非当前底图的历史图</p> : 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>
{project.images.length === 0 ? (
<div className="history-empty"><strong>还没有成功图片</strong><p>{project.draft_prompt}</p></div>
) : (
<ol>
{project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" />
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
</li>
))}
</ol>
)}
</section>
</div>
<section aria-labelledby="latest-exports-title" className="project-latest-exports">
<header><div><p>LOCAL LATEST</p><h2 id="latest-exports-title">最新成品</h2></div><span>同一电脑可重新下载</span></header>
{project.latest_exports.length === 0 ? (
<div className="latest-exports-empty">
<strong>暂无导出成品</strong>
<div>
<a href={`/app/projects/${project.project_id}/editor`}>进入编辑并导出</a>
{project.current_image_id ? <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}>下载原始生成图</a> : null}
</div>
</div>
) : (
<ul>
{project.latest_exports.map((item) => (
<li key={item.export_id}>
<div><strong>{item.format.toUpperCase()}</strong><span>{item.pixel_width} × {item.pixel_height}</span></div>
<time dateTime={item.created_at}>{formatUpdatedAt(item.created_at)}</time>
<a href={item.download_url}>重新下载</a>
</li>
))}
</ul>
)}
</section>
</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>
);
}