1023 lines
47 KiB
TypeScript
1023 lines
47 KiB
TypeScript
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>
|
||
);
|
||
}
|