feat: complete TASK-WP2-03 project lifecycle
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user