feat: complete TASK-WP2-07 latest exports
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user