feat: complete TASK-WP2-01 project foundations
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
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;
|
||||
user: { creator_name: string };
|
||||
}
|
||||
|
||||
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 {
|
||||
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 }>;
|
||||
pixel_height?: number;
|
||||
pixel_width?: number;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
|
||||
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 href="/app/credits">点数</a>
|
||||
<a href="/app/settings">设置</a>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [ratio, setRatio] = useState<Ratio>("3:4");
|
||||
const [references, setReferences] = 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);
|
||||
}).catch((error) => {
|
||||
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
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"}`}>
|
||||
<section className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"}>
|
||||
{!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>
|
||||
<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} 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.join("、") : "尚未提交,仅保留在当前页面"}</small>
|
||||
<input
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setReferences(Array.from(event.target.files ?? []).map((file) => file.name))}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<div className="generation-submit">
|
||||
<span>预计点数将在模型可用后显示</span>
|
||||
<button disabled type="button">生成一张图片</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="current-task" aria-labelledby="current-task-title">
|
||||
<h2 id="current-task-title">当前任务</h2>
|
||||
<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({ project, selectable, selected, onSelect }: {
|
||||
onSelect?: (selected: boolean) => void;
|
||||
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>
|
||||
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
|
||||
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}>打开</a>
|
||||
</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("");
|
||||
|
||||
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("操作未完成,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
{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}
|
||||
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>
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 [loadingFailed, setLoadingFailed] = useState(false);
|
||||
|
||||
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);
|
||||
setProject(nextProject);
|
||||
setName(nextProject.name);
|
||||
}).catch((error) => {
|
||||
if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [projectId]);
|
||||
|
||||
async function rename(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!session || !project || savingName || !name.trim()) return;
|
||||
setSavingName(true);
|
||||
setNameStatus("");
|
||||
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("项目名已保存");
|
||||
} catch {
|
||||
setNameStatus("项目名保存失败");
|
||||
} finally {
|
||||
setSavingName(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;
|
||||
|
||||
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>
|
||||
<section className="project-identity" aria-labelledby="rename-title">
|
||||
<div><h2 id="rename-title">项目名称</h2><p>state version {project.state_version}</p></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}
|
||||
</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={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>
|
||||
</div>
|
||||
{atHistoryLimit ? <p className="project-blocker">请先删除一张非当前底图的历史图</p> : null}
|
||||
{project.status === "failed_empty" ? <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></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user