feat: complete TASK-WP2-03 project lifecycle
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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