feat: add product archive frontend client

This commit is contained in:
2026-09-12 15:48:19 +08:00
parent cb5a46631b
commit dd3003609b
4 changed files with 203 additions and 0 deletions
+64
View File
@@ -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 输出');
}
+64
View File
@@ -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',
});
}
+58
View File
@@ -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[];
}
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('design preview serializes the complete visible document before PNG upload', async () => {
const source = await readFile(new URL('../src/lib/designPreview.ts', import.meta.url), 'utf8');
assert.match(source, /serializeDocument\(document, stickers, \{ includeBackground: true \}\)/);
assert.match(source, /canvas\.toBlob/);
});
test('archive client submits document JSON and a PNG preview as multipart data', async () => {
const source = await readFile(new URL('../src/lib/productArchive.ts', import.meta.url), 'utf8');
assert.match(source, /form\.append\('document_json'/);
assert.match(source, /form\.append\('preview'/);
});