feat: complete TASK-WP2-07 latest exports

This commit is contained in:
suyx
2026-08-03 01:43:09 +08:00
parent 2bb4f86d04
commit 1b0a05bbcf
20 changed files with 1791 additions and 18 deletions
+32
View File
@@ -0,0 +1,32 @@
export type ExportFlowStatus = "composition_failed" | "download_failed" | "downloaded_not_saved" | "downloaded_and_saved";
export const exportResultCopy: Record<ExportFlowStatus, string> = {
composition_failed: "合成失败,请返回编辑器检查",
download_failed: "浏览器下载失败,可重新下载同一次合成结果",
downloaded_not_saved: "文件已下载,但未保存为最新成品",
downloaded_and_saved: "已下载并保存为最新成品",
};
export async function runExportFlow(input: {
compose: () => Promise<Blob>;
download: (blob: Blob) => Promise<void>;
persist: (blob: Blob) => Promise<void>;
}): Promise<{ blob: Blob | null; status: ExportFlowStatus }> {
let blob: Blob;
try {
blob = await input.compose();
} catch {
return { blob: null, status: "composition_failed" };
}
try {
await input.download(blob);
} catch {
return { blob, status: "download_failed" };
}
try {
await input.persist(blob);
return { blob, status: "downloaded_and_saved" };
} catch {
return { blob, status: "downloaded_not_saved" };
}
}
+26 -3
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, LatestExportSaveResponse, 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; }
@@ -90,13 +90,28 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
return response.json() as Promise<RegistrationCompleteResponse>;
}
export async function createGeneration(options: ClientOptions = {}): Promise<GenerationCreateResponse> {
export async function createGeneration(body: FormData, options: ClientOptions = {}): Promise<GenerationCreateResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { method: "POST", headers: options.headers ?? {} });
const headers = new Headers(options.headers);
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { body: body, method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<GenerationCreateResponse>;
}
export async function downloadLatestExport(options: ClientOptions = {}): Promise<Blob> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob() as Promise<Blob>;
}
export async function downloadOriginalGeneration(options: ClientOptions = {}): Promise<Blob> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/private-assets/projects/{projectId}/images/{imageId}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob() as Promise<Blob>;
}
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
@@ -240,6 +255,14 @@ export async function restoreProject(options: ClientOptions = {}): Promise<Proje
return response.json() as Promise<ProjectRestoreResponse>;
}
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { body: body, method: "PUT", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<LatestExportSaveResponse>;
}
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+49
View File
@@ -311,6 +311,8 @@ export type ErrorEnvelope = {
};
};
export type ExportFormat = "jpg" | "png";
export type FailedEmptyTrashRequest = {
"project_ids": Array<ProjectId>;
};
@@ -379,6 +381,47 @@ export type GenerationTaskResponse = {
export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export type LatestExportItem = {
"byte_size": number;
"created_at": string;
"download_url": string;
"export_id": string;
"format": ExportFormat;
"pixel_height": number;
"pixel_width": number;
"sha256": string;
"state_version": number;
};
export type LatestExportMultipartBody = {
"byte_size": string;
"export_file": string;
"export_id": string;
"format": ExportFormat;
"pixel_height": string;
"pixel_width": string;
"sha256": string;
"state_version": string;
};
export type LatestExportParams = {
"format": ExportFormat;
"projectId": ProjectId;
};
export type LatestExportSaveResponse = {
"byte_size": number;
"created_at": string;
"download_url": string;
"export_id": string;
"format": ExportFormat;
"pixel_height": number;
"pixel_width": number;
"sha256": string;
"state_version": number;
"status": "saved";
};
export type LoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
@@ -429,6 +472,7 @@ export type ProjectDetailResponse = {
"draft_prompt": string;
"generations": Array<GenerationProjectItem>;
"images": Array<ProjectImageItem>;
"latest_exports": Array<LatestExportItem>;
"name": string;
"pixel_height": number;
"pixel_width": number;
@@ -455,6 +499,11 @@ export type ProjectImageItem = {
"image_id": ProjectId;
};
export type ProjectImageParams = {
"imageId": ProjectId;
"projectId": ProjectId;
};
export type ProjectListQuery = {
"status"?: "active" | "trashed";
};
+109
View File
@@ -1049,6 +1049,106 @@
color: #65655f;
}
.project-latest-exports {
margin-top: 22px;
border: 1px solid #111111;
background: #ffffff;
}
.project-latest-exports > header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid #111111;
}
.project-latest-exports h2,
.project-latest-exports p {
margin: 0;
}
.project-latest-exports p {
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.project-latest-exports > header > span {
color: #65655f;
font-size: 12px;
}
.latest-exports-empty {
display: grid;
min-height: 150px;
place-items: center;
gap: 18px;
padding: 28px;
text-align: center;
}
.latest-exports-empty > strong {
font-size: 18px;
}
.latest-exports-empty > div {
display: flex;
gap: 8px;
}
.latest-exports-empty a,
.project-latest-exports li > a {
display: inline-grid;
min-height: 40px;
place-items: center;
padding: 8px 14px;
border: 1px solid #111111;
color: #111111;
background: #ffffff;
font-weight: 800;
text-decoration: none;
}
.latest-exports-empty a:first-child {
background: #f2f500;
}
.project-latest-exports ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
padding: 18px;
list-style: none;
}
.project-latest-exports li {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px 16px;
padding: 16px;
border: 1px solid #b9b9b1;
}
.project-latest-exports li > div {
display: flex;
align-items: baseline;
gap: 10px;
}
.project-latest-exports li > time {
color: #65655f;
font-size: 12px;
}
.project-latest-exports li > a {
grid-column: 2;
grid-row: 1 / span 2;
}
.project-leave-overlay {
position: fixed;
z-index: 30;
@@ -1330,6 +1430,15 @@
grid-template-columns: 1fr;
}
.project-latest-exports ul {
grid-template-columns: 1fr;
}
.latest-exports-empty > div {
width: 100%;
flex-direction: column;
}
.project-current > .project-placeholder {
min-height: 360px;
}
+37 -3
View File
@@ -114,6 +114,17 @@ interface ProjectDetailPayload extends ProjectSummary {
updated_at: string;
}>;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
latest_exports: Array<{
byte_size: number;
created_at: string;
download_url: string;
export_id: string;
format: "jpg" | "png";
pixel_height: number;
pixel_width: number;
sha256: string;
state_version: number;
}>;
pixel_height?: number;
pixel_width?: number;
save_status: "saved";
@@ -767,6 +778,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
const normalizedProject = {
...nextProject,
canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject),
latest_exports: nextProject.latest_exports ?? [],
save_status: nextProject.save_status ?? "saved",
};
setProject(normalizedProject);
@@ -944,8 +956,8 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<div className="project-actions">
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
<button disabled={conflicted || !project.current_image_id} type="button"></button>
{conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/app/projects/${project.project_id}/editor`}></a>}
{conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}></a>}
</div>
{atHistoryLimit ? <p className="project-blocker"></p> : null}
{project.status === "failed_empty" && !conflicted ? <a className="project-retry" href={`/app?retry=${project.project_id}`}></a> : null}
@@ -959,13 +971,35 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
{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>
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}></a></div>
</li>
))}
</ol>
)}
</section>
</div>
<section aria-labelledby="latest-exports-title" className="project-latest-exports">
<header><div><p>LOCAL LATEST</p><h2 id="latest-exports-title"></h2></div><span></span></header>
{project.latest_exports.length === 0 ? (
<div className="latest-exports-empty">
<strong></strong>
<div>
<a href={`/app/projects/${project.project_id}/editor`}></a>
{project.current_image_id ? <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}></a> : null}
</div>
</div>
) : (
<ul>
{project.latest_exports.map((item) => (
<li key={item.export_id}>
<div><strong>{item.format.toUpperCase()}</strong><span>{item.pixel_width} × {item.pixel_height}</span></div>
<time dateTime={item.created_at}>{formatUpdatedAt(item.created_at)}</time>
<a href={item.download_url}></a>
</li>
))}
</ul>
)}
</section>
</main>
{pendingNavigation ? (
<div className="project-leave-overlay" role="presentation">