feat: complete TASK-WP2-03 project lifecycle

This commit is contained in:
suyx
2026-08-02 20:00:18 +08:00
parent da6fa25e60
commit 9b4860cfc2
17 changed files with 1477 additions and 10 deletions
+66 -2
View File
@@ -262,8 +262,13 @@ export function WorkspacePage() {
);
}
function ProjectCard({ project, selectable, selected, onSelect }: {
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;
@@ -284,8 +289,14 @@ function ProjectCard({ project, selectable, selected, onSelect }: {
<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>
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}></a>
<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>
);
@@ -300,6 +311,8 @@ export function ProjectsPage() {
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;
@@ -346,6 +359,36 @@ export function ProjectsPage() {
}
}
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 (
@@ -371,6 +414,9 @@ export function ProjectsPage() {
</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>
@@ -385,6 +431,11 @@ export function ProjectsPage() {
? [...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)}
/>
@@ -398,6 +449,19 @@ export function ProjectsPage() {
</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>
);