feat: add product archive frontend client
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import type { CanvasDocument, StickerAsset } from '../types';
|
||||
import { serializeDocument } from './svgExport';
|
||||
|
||||
const PNG_TYPE = 'image/png';
|
||||
|
||||
/**
|
||||
* Render the full visible canvas document to a PNG Blob. WebKit returns the
|
||||
* Blob directly from toBlob; the standard-track callback form and the
|
||||
* data-URL fallback keep other browsers working.
|
||||
*/
|
||||
export async function createDesignPreviewBlob(
|
||||
document: CanvasDocument,
|
||||
stickers: Map<string, StickerAsset>,
|
||||
): Promise<Blob> {
|
||||
const width = Math.max(1, Math.floor(document.width || 0));
|
||||
const height = Math.max(1, Math.floor(document.height || 0));
|
||||
const svgMarkup = await serializeDocument(document, stickers, { includeBackground: true });
|
||||
const svgBlob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const objectUrl = URL.createObjectURL(svgBlob);
|
||||
try {
|
||||
const image: HTMLImageElement =
|
||||
typeof Image === 'function' ? new Image() : globalThis.document.createElement('img');
|
||||
image.src = objectUrl;
|
||||
await image.decode();
|
||||
|
||||
const canvas = globalThis.document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('设计预览画布上下文不可用');
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const pngBlob = await canvasToPngBlob(canvas);
|
||||
if (pngBlob.type !== PNG_TYPE || pngBlob.size === 0) {
|
||||
throw new Error('设计预览未生成有效的 PNG 输出');
|
||||
}
|
||||
return pngBlob;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
if (typeof canvas.toBlob === 'function') {
|
||||
try {
|
||||
const direct = (canvas.toBlob as unknown as (type: string) => Blob | undefined)('image/png');
|
||||
if (direct) return direct;
|
||||
} catch {
|
||||
// Non-WebKit browsers wait for the callback form below.
|
||||
}
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('设计预览画布未生成 PNG Blob'));
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
if (typeof canvas.toDataURL === 'function') {
|
||||
const res = await fetch(canvas.toDataURL('image/png'));
|
||||
if (!res.ok) throw new Error('设计预览 PNG 输出读取失败');
|
||||
return res.blob();
|
||||
}
|
||||
throw new Error('当前浏览器不支持设计预览 PNG 输出');
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CanvasDocument, ProductArchiveResult, ProductDetail, ProductSummary } from '../types';
|
||||
import { apiUrl, ensureOk } from './api';
|
||||
|
||||
async function requestJson<T>(token: string, path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
const res = await fetch(apiUrl(path), { ...init, headers });
|
||||
await ensureOk(res, '产品档案接口请求失败');
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listProducts(token: string, query = ''): Promise<ProductSummary[]> {
|
||||
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
|
||||
return requestJson(token, `/api/products${suffix}`);
|
||||
}
|
||||
|
||||
export async function getProduct(token: string, productId: string): Promise<ProductDetail> {
|
||||
return requestJson(token, `/api/products/${encodeURIComponent(productId)}`);
|
||||
}
|
||||
|
||||
export async function createManualProduct(
|
||||
token: string,
|
||||
input: { name: string; sku?: string; specification?: string },
|
||||
): Promise<ProductSummary> {
|
||||
return requestJson(token, '/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: 'manual',
|
||||
external_product_id: null,
|
||||
name: input.name.trim(),
|
||||
sku: input.sku?.trim() || '',
|
||||
specification: input.specification?.trim() || '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveProductVersion(
|
||||
token: string,
|
||||
productId: string,
|
||||
document: CanvasDocument,
|
||||
preview: Blob,
|
||||
): Promise<ProductArchiveResult> {
|
||||
const form = new FormData();
|
||||
form.append('document_json', JSON.stringify(document));
|
||||
form.append('preview', new File([preview], 'design-preview.png', { type: 'image/png' }));
|
||||
return requestJson(token, `/api/products/${encodeURIComponent(productId)}/versions`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreProduct(token: string, productId: string): Promise<ProductSummary> {
|
||||
return requestJson(token, `/api/products/${encodeURIComponent(productId)}/restore`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProduct(token: string, productId: string): Promise<ProductSummary> {
|
||||
return requestJson(token, `/api/products/${encodeURIComponent(productId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -261,3 +261,61 @@ export interface WordcloudStickerPayload {
|
||||
/** 存在时表示替换画布上已有词云,而不是新增贴纸 */
|
||||
replaceTarget?: WordcloudReplaceTarget;
|
||||
}
|
||||
|
||||
// 产品档案与词云归档(与 backend/service/schemas.py 对齐)
|
||||
export type ProductSource = 'manual' | 'external';
|
||||
export type ProductStatus = 'active' | 'pending_cleanup' | 'failed_cleanup' | 'purged';
|
||||
|
||||
export interface ProductSummary {
|
||||
product_id: string;
|
||||
source: ProductSource;
|
||||
external_product_id: string | null;
|
||||
name: string;
|
||||
sku: string;
|
||||
specification: string;
|
||||
status: ProductStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
purge_after: string | null;
|
||||
cover_image_id: string | null;
|
||||
}
|
||||
|
||||
export interface ProductImage {
|
||||
image_id: string;
|
||||
product_id: string;
|
||||
version_id: string;
|
||||
image_path: string;
|
||||
image_type: string;
|
||||
is_cover: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProductWordcloudArchive {
|
||||
archive_id: string;
|
||||
product_id: string;
|
||||
version_id: string;
|
||||
archive_path: string;
|
||||
source_job_id: string;
|
||||
source_asset_id: string;
|
||||
db_checksum: string;
|
||||
db_path: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProductVersion {
|
||||
version_id: string;
|
||||
product_id: string;
|
||||
version: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
design_preview_path: string;
|
||||
wordcloud_count: number;
|
||||
wordcloud_archives: ProductWordcloudArchive[];
|
||||
}
|
||||
|
||||
export type ProductArchiveResult = ProductVersion;
|
||||
|
||||
export interface ProductDetail extends ProductSummary {
|
||||
images: ProductImage[];
|
||||
versions: ProductVersion[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user