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
+22 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -166,6 +166,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectPurgeResponse>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -175,6 +182,13 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO
return response.json() as Promise<ProjectRenameResponse>;
}
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectRestoreResponse>;
}
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -227,6 +241,13 @@ export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, op
return response.json() as Promise<FailedEmptyTrashResponse>;
}
export async function trashProject(options: ClientOptions = {}): Promise<ProjectTrashResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/trash`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectTrashResponse>;
}
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+19
View File
@@ -357,6 +357,11 @@ export type ProjectParams = {
"projectId": ProjectId;
};
export type ProjectPurgeResponse = {
"project_id": ProjectId;
"status": "purged";
};
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
export type ProjectRenameRequest = {
@@ -369,6 +374,13 @@ export type ProjectRenameResponse = {
"status": "renamed";
};
export type ProjectRestoreResponse = {
"deleted_at": null;
"project_id": ProjectId;
"purge_at": null;
"status": "active";
};
export type ProjectStateConflictResponse = {
"latest_state_version": number;
"save_status": "conflicted";
@@ -398,6 +410,13 @@ export type ProjectSummary = {
"updated_at": string;
};
export type ProjectTrashResponse = {
"deleted_at": string;
"project_id": ProjectId;
"purge_at": string;
"status": "trashed";
};
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type RegistrationCompleteHeaders = {
+95 -2
View File
@@ -522,8 +522,8 @@
.project-card-body {
display: grid;
min-height: 170px;
grid-template-rows: minmax(48px, auto) auto auto 1fr;
min-height: 198px;
grid-template-rows: minmax(48px, auto) auto auto auto 1fr;
gap: 8px;
padding: 16px;
}
@@ -568,6 +568,46 @@
font-weight: 800;
}
.project-card-body .trash-expiry {
color: #8f1d14;
font-weight: 700;
}
.project-card-body .project-card-actions {
display: flex;
align-self: end;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.project-card-actions a,
.project-card-actions button {
display: inline-grid;
min-height: 38px;
place-items: center;
padding: 7px 10px;
border: 1px solid #111111;
border-radius: 0;
color: #111111;
background: #ffffff;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.project-card-actions button:disabled {
border-color: #b2b2ac;
color: #777770;
background: #dfdfda;
cursor: not-allowed;
}
.project-card[data-status="trashed"] .project-card-actions button:last-child:not(:disabled) {
border-color: #a52e24;
color: #8f1d14;
}
.projects-stale,
.projects-notice {
padding: 12px 14px;
@@ -912,6 +952,55 @@
background: rgb(17 17 17 / 58%);
}
.project-purge-overlay {
position: fixed;
z-index: 40;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgb(17 17 17 / 62%);
}
.project-purge-dialog {
width: min(520px, 100%);
padding: 28px;
border: 2px solid #111111;
background: #ffffff;
box-shadow: 10px 10px 0 #d14a3b;
}
.project-purge-dialog > p:first-child {
margin: 0 0 8px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.project-purge-dialog h2 {
margin: 0 0 10px;
}
.project-purge-dialog > div {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 22px;
}
.project-purge-dialog button {
min-height: 44px;
border: 1px solid #111111;
border-radius: 0;
background: #ffffff;
font-weight: 800;
}
.project-purge-dialog button:first-child {
color: #ffffff;
background: #a52e24;
}
.project-leave-dialog {
width: min(560px, 100%);
padding: 28px;
@@ -1120,6 +1209,10 @@
grid-template-columns: 1fr;
}
.project-purge-dialog > div {
grid-template-columns: 1fr;
}
.project-actions {
grid-template-columns: 1fr;
}
+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>
);