diff --git a/frontend/src/lib/designPreview.ts b/frontend/src/lib/designPreview.ts new file mode 100644 index 0000000..9e7afb7 --- /dev/null +++ b/frontend/src/lib/designPreview.ts @@ -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, +): Promise { + 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 { + 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((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 输出'); +} diff --git a/frontend/src/lib/productArchive.ts b/frontend/src/lib/productArchive.ts new file mode 100644 index 0000000..cc147c4 --- /dev/null +++ b/frontend/src/lib/productArchive.ts @@ -0,0 +1,64 @@ +import type { CanvasDocument, ProductArchiveResult, ProductDetail, ProductSummary } from '../types'; +import { apiUrl, ensureOk } from './api'; + +async function requestJson(token: string, path: string, init: RequestInit = {}): Promise { + 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; +} + +export async function listProducts(token: string, query = ''): Promise { + const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : ''; + return requestJson(token, `/api/products${suffix}`); +} + +export async function getProduct(token: string, productId: string): Promise { + return requestJson(token, `/api/products/${encodeURIComponent(productId)}`); +} + +export async function createManualProduct( + token: string, + input: { name: string; sku?: string; specification?: string }, +): Promise { + 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 { + 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 { + return requestJson(token, `/api/products/${encodeURIComponent(productId)}/restore`, { + method: 'POST', + }); +} + +export async function deleteProduct(token: string, productId: string): Promise { + return requestJson(token, `/api/products/${encodeURIComponent(productId)}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bda1aaa..11abbb5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; + 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[]; +} diff --git a/frontend/tests/product-archive-ui.test.mjs b/frontend/tests/product-archive-ui.test.mjs new file mode 100644 index 0000000..d1801d2 --- /dev/null +++ b/frontend/tests/product-archive-ui.test.mjs @@ -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'/); +});