import { useEffect, useMemo, useState } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react'; import { CanvasDocument, ProductArchiveResult, ProductStatus, ProductSummary, StickerAsset, } from '../types'; import { normalizeDocument } from '../lib/canvasDocument'; import { archiveProductVersion, createManualProduct, listProducts, } from '../lib/productArchive'; import { createDesignPreviewBlob } from '../lib/designPreview'; import { IconArchive, IconClose } from './Icons'; interface ProductArchiveDialogProps { documentModel: CanvasDocument; stickers: Map; onArchived: (result: ProductArchiveResult, productName: string) => void; onClose: () => void; } type ArchiveDialogState = 'loadingProducts' | 'creatingProduct' | 'archiving' | 'error'; const ORDERS_TOKEN_KEY = 'wordcloud-orders-token'; const ACTIVE_STATUS_CLASS: Record = { active: 'product-status-archived', pending_cleanup: 'product-status-pending-cleanup', failed_cleanup: 'product-status-pending-cleanup', purged: 'product-status-empty', }; export default function ProductArchiveDialog({ documentModel, stickers, onArchived, onClose, }: ProductArchiveDialogProps) { const document = normalizeDocument(documentModel); const token = window.localStorage.getItem(ORDERS_TOKEN_KEY) || ''; const [state, setState] = useState('loadingProducts'); const [products, setProducts] = useState([]); const [query, setQuery] = useState(''); const [creating, setCreating] = useState(false); const [selectedId, setSelectedId] = useState(null); const [name, setName] = useState(''); const [sku, setSku] = useState(''); const [specification, setSpecification] = useState(''); const [errorText, setErrorText] = useState(''); const [previewUrl, setPreviewUrl] = useState(''); const [previewBlob, setPreviewBlob] = useState(null); const [visibleWordcloudCount, setVisibleWordcloudCount] = useState(0); useEffect(() => { let cancelled = false; const objectUrls: string[] = []; const loadPreview = async () => { try { const blob = await createDesignPreviewBlob(document, stickers); const url = URL.createObjectURL(blob); objectUrls.push(url); if (!cancelled) { setPreviewBlob(blob); setPreviewUrl(url); } } catch { // 预览是辅助信息;生成失败时显示占位帧,不阻塞归档。 } }; const load = async () => { try { const items = await listProducts(token); if (cancelled) return; setProducts(items); setVisibleWordcloudCount(countVisibleWordclouds(document, stickers)); setState('creatingProduct'); } catch (error) { if (cancelled) return; setErrorText(error instanceof Error ? error.message : '产品列表加载失败'); setState('error'); } }; void loadPreview(); void load(); return () => { cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const filteredProducts = useMemo( () => products.filter(product => !query.trim() || product.name.includes(query.trim()) || product.sku.includes(query.trim())), [products, query], ); const selectedProduct = selectedId ? products.find(product => product.product_id === selectedId) : null; const confirmTarget = creating ? { name: name.trim(), sku: sku.trim(), specification: specification.trim() } : selectedProduct; const canConfirm = creating ? Boolean(name.trim()) : Boolean(confirmTarget); const startNewProduct = () => { setCreating(true); setSelectedId(null); setErrorText(''); setState('creatingProduct'); }; const chooseProduct = (product: ProductSummary) => { setCreating(false); setSelectedId(product.product_id); setErrorText(''); setState('creatingProduct'); }; const submitArchive = async () => { if (!token || !canConfirm || !confirmTarget) return; if (!previewBlob) { setErrorText('设计预览尚未生成,请稍候再试'); setState('error'); return; } setErrorText(''); setState('archiving'); try { const product = creating ? await createManualProduct(token, { name, sku, specification }) : selectedProduct; if (!product) throw new Error('归档产品未选定'); const result = await archiveProductVersion(token, product.product_id, document, previewBlob); onArchived(result, product.name); onClose(); } catch (error) { setErrorText(error instanceof Error ? error.message : '归档请求失败'); setState('error'); } }; return (
event.stopPropagation()}>
产品档案

加入产品列表

{previewUrl ? 当前完整设计预览(将作为产品封面) :
设计预览生成中…
}

当前完整设计将保存为产品预览图,后续可替换为实景图。

已检测到 {visibleWordcloudCount} 份画布词云,将全部归档。最终归档数量以服务端扫描为准。

{state === 'loadingProducts' && (

正在加载产品列表…

)} {state === 'error' && (

{errorText}

)} {(state === 'creatingProduct' || state === 'archiving') && (
{visibleWordcloudCount === 0 && (

当前画布未检测到可归档词云。可以建立产品,但该版本会标记为“无词云归档数据”。

)}
{filteredProducts.map(product => ( ))} {filteredProducts.length === 0 && (

无匹配产品,可新建产品。

)}
新建产品
{creating && 填写名称后可直接归档}
)}
); } function countVisibleWordclouds(document: CanvasDocument, stickers: Map): number { const visibleLayers = new Set( (document.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id), ); const seen = new Set(); for (const element of document.elements) { if (element.type !== 'sticker') continue; if (!element.layerId || !visibleLayers.has(element.layerId)) continue; const asset = stickers.get(element.assetId); const sourceJobId = wordcloudSourceJobId(asset); if (!sourceJobId) continue; seen.add(sourceJobId); } return seen.size; } /** * 画布贴纸元数据中可追溯的词云来源任务 ID。类型上仅定义基础字段, * 运行时来自生成接口的素材携带 type=wordcloud 与 job_id。 */ function wordcloudSourceJobId(asset: StickerAsset | undefined): string { return asset?.jobId || ''; } function statusClass(status: ProductStatus): string { return ACTIVE_STATUS_CLASS[status] || 'product-status-empty'; } function productStatusLabel(status: ProductStatus): string { if (status === 'pending_cleanup' || status === 'failed_cleanup') return '待清理'; if (status === 'purged') return '已清理'; return '已归档词云数据'; }