diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx index 83d3656..399ca15 100644 --- a/frontend/src/components/Icons.tsx +++ b/frontend/src/components/Icons.tsx @@ -277,3 +277,13 @@ export function IconCopy() { ); } + +export function IconArchive() { + return ( + + + + + + ); +} diff --git a/frontend/src/components/ProductArchiveDialog.tsx b/frontend/src/components/ProductArchiveDialog.tsx new file mode 100644 index 0000000..39d472d --- /dev/null +++ b/frontend/src/components/ProductArchiveDialog.tsx @@ -0,0 +1,305 @@ +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 '已归档词云数据'; +} diff --git a/frontend/src/lib/stickerLibrary.ts b/frontend/src/lib/stickerLibrary.ts index 1f155f4..4ca69ec 100644 --- a/frontend/src/lib/stickerLibrary.ts +++ b/frontend/src/lib/stickerLibrary.ts @@ -73,12 +73,14 @@ interface BackendAsset { type: string; mime_type: string; file_url: string; + job_id?: string; created_at: string; } async function apiListAssets(): Promise { - const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败'); - return res.json(); + const res = await ensureOk(await fetch(apiUrl('/api/assets')), '读取贴纸库失败'); + const assets = await res.json() as BackendAsset[]; + return assets.filter(asset => asset.type === 'sticker' || asset.type === 'wordcloud'); } async function apiUploadAsset( @@ -114,6 +116,7 @@ export async function loadStickerLibrary(): Promise { type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image', source: a.file_url, createdAt: a.created_at, + jobId: a.job_id || undefined, tint: tints[a.asset_id] as StickerAsset['tint'], mimeType: a.mime_type, })); @@ -136,6 +139,7 @@ export async function addStickerAsset( type: input.type, source: asset.file_url, createdAt: asset.created_at, + jobId: asset.job_id || undefined, tint: input.tint, mimeType: asset.mime_type, }; diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx index 25616c2..248df0a 100644 --- a/frontend/src/pages/CanvasStudio.tsx +++ b/frontend/src/pages/CanvasStudio.tsx @@ -8,6 +8,7 @@ import { CanvasLayer, CanvasLayerFolder, LineSpacingAnalysisSummary, + ProductArchiveResult, ShapeCanvasElement, StickerAsset, WordcloudReplaceSession, @@ -52,7 +53,9 @@ import { IconPlus, IconSettings, IconHelp, + IconArchive, } from '../components/Icons'; +import ProductArchiveDialog from '../components/ProductArchiveDialog'; import FloatingPanel from '../components/FloatingPanel'; import DockTabBar, { DockTabItem } from '../components/DockTabBar'; import { DOCK_WIDTH, DOCK_TOP_RESERVED, FloatingPanelLayout, TAB_BAR_HEIGHT, useFloatingPanels } from '../hooks/useFloatingPanels'; @@ -127,6 +130,8 @@ export default function CanvasStudio({ const [zoom, setZoom] = useState(0.55); const [dragState, setDragState] = useState(null); const [openPanels, setOpenPanels] = useState(['layers', 'properties']); + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [archiveSuccessText, setArchiveSuccessText] = useState(''); const stageRef = useRef(null); const workspaceRef = useRef(null); const workspaceSizeRef = useRef({ width: 1200, height: 700 }); @@ -476,6 +481,23 @@ export default function CanvasStudio({ downloadBlob(blob, 'canvas-layers.zip'); }; + const openProductArchive = () => { + setArchiveSuccessText(''); + setArchiveDialogOpen(true); + }; + + const handleProductArchived = (result: ProductArchiveResult, productName: string) => { + const displayName = productName.trim() || `《未命名产品》`; + setArchiveSuccessText(`已归档至产品《${displayName}》· ${result.wordcloud_count || 0} 份词云位置数据已长期保存`); + setArchiveDialogOpen(false); + }; + + useEffect(() => { + if (!archiveSuccessText) return; + const timer = window.setTimeout(() => setArchiveSuccessText(''), 6000); + return () => window.clearTimeout(timer); + }, [archiveSuccessText]); + const applyWordcloudAssetReplace = ( replaceTarget: NonNullable, nextAssetId: string, @@ -1018,6 +1040,7 @@ export default function CanvasStudio({
+ {onOpenHelp && } + + {archiveDialogOpen && ( + setArchiveDialogOpen(false)} + /> + )} + + {archiveSuccessText && ( +
+ + {archiveSuccessText} +
+ )}
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2828849..7136f7f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3092,5 +3092,315 @@ body.resizing .studio-panel { .orders-page .orders-input:focus, .find-page .orders-input:focus { border-color: var(--lf-accent-border); } -/* 未选任务时的居中占位 */ -.find-page .find-stage .table-empty { height: 100%; display: flex; align-items: center; justify-content: center; color: var(--lf-text-faint); } +.find-page .orders-input:focus { border-color: var(--lf-accent-border); } + +/* ===== 产品档案归档弹窗 ===== */ +.product-archive-backdrop { + position: fixed; + inset: 0; + z-index: 110; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(255,255,255,var(--modal-backdrop-opacity)); + backdrop-filter: blur(var(--modal-backdrop-blur)); +} +:root[data-theme="dark"] .product-archive-backdrop { + background: rgba(10,12,18,calc(var(--modal-backdrop-opacity) * 2.4)); +} +.product-archive-dialog { + width: min(920px, 100%); + max-height: calc(100vh - 48px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-panel); + box-shadow: var(--shadow-lg); + animation: template-pop var(--modal-open-duration) var(--modal-open-easing) var(--modal-open-delay) backwards; +} +.product-archive-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.product-archive-eyebrow { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + display: inline-flex; + align-items: center; + gap: 5px; +} +.product-archive-title { + margin: 0; + color: var(--text-primary); + font-size: 15px; + font-weight: 600; +} +.product-archive-close { + margin-left: auto; +} +.product-archive-grid { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(300px, 1.1fr); + gap: 14px; + padding: 16px; + overflow-y: auto; +} +.product-archive-preview-panel, +.product-archive-form-panel { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.product-archive-preview-frame { + width: 100%; + aspect-ratio: 4 / 3; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); + overflow: hidden; +} +.product-archive-preview { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.product-archive-preview-placeholder { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-family: var(--font-main); + font-size: 13px; +} +.product-archive-preview-note, +.product-archive-source-note { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.product-archive-source-note { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); +} +.product-archive-form { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow-y: auto; +} +.product-archive-loading, +.product-archive-empty, +.product-archive-state { + color: var(--text-muted); + font-size: 13px; +} +.product-archive-error { + padding: 10px 12px; + border: 1px solid var(--danger); + border-radius: var(--radius-sm); + background: var(--danger-light); + color: var(--danger); + font-size: 13px; + overflow-wrap: anywhere; +} +.product-archive-empty-copy { + margin: 0; + padding: 9px 11px; + border: 1px solid var(--warn); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); + color: var(--warn); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.product-archive-search-row { + display: grid; + gap: 5px; + color: var(--text-secondary); + font-size: 12px; +} +.product-archive-search, +.product-archive-input { + width: 100%; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); + color: var(--text-primary); + font-family: var(--font-main); + font-size: 13px; +} +.product-archive-search:focus, +.product-archive-input:focus { + outline: none; + border-color: var(--border-focus); +} +.product-archive-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel); +} +.product-archive-option { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 7px 9px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} +.product-archive-option.selected { + background: var(--accent-light); + box-shadow: inset 0 0 0 1px var(--border-focus); +} +.product-archive-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-main); + font-size: 13px; + font-weight: 600; +} +.product-archive-option-sku { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} +.product-archive-option-status { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + line-height: 1.4; + white-space: nowrap; +} +.product-archive-create { + display: grid; + gap: 9px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel-alt); +} +.product-archive-create-title { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} +.product-archive-create label { + display: grid; + gap: 4px; + color: var(--text-muted); + font-size: 11px; +} +.product-archive-meta-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.product-archive-create-actions { + display: flex; + align-items: center; + gap: 8px; +} +.product-archive-create-note { + color: var(--text-muted); + font-size: 11px; +} +.product-archive-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 2px; +} +.product-archive-actions .btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.product-status-archived { + color: var(--success); + background: var(--success-light); +} +.product-status-empty { + color: var(--text-muted); + background: var(--bg-panel-alt); +} +.product-status-pending-cleanup { + color: var(--warn); + background: var(--accent-light); +} +.product-archive-toast { + position: fixed; + left: 50%; + bottom: 26px; + transform: translateX(-50%); + z-index: 120; + display: flex; + align-items: center; + gap: 8px; + max-width: min(720px, calc(100vw - 28px)); + padding: 9px 14px; + border: 1px solid var(--success); + border-radius: var(--radius-sm); + background: var(--success-light); + color: var(--success); + font-size: 13px; + box-shadow: var(--shadow-md); +} +.product-archive-toast-icon { + display: inline-flex; + flex: 0 0 auto; +} +.product-archive-toast span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 720px) { + .product-archive-grid { + grid-template-columns: minmax(0, 1fr); + } + .product-archive-preview-panel { + max-height: 300px; + } + .product-archive-option { + grid-template-columns: minmax(0, 1fr) auto; + } + .product-archive-option-sku { + display: none; + } + .product-archive-meta-row { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c48e092..0a90115 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -109,6 +109,7 @@ export interface StickerAsset { type: StickerAssetType; source: string; createdAt: string; + jobId?: string; tint?: string; mimeType?: string; } diff --git a/frontend/tests/product-archive-ui.test.mjs b/frontend/tests/product-archive-ui.test.mjs index d1801d2..12f9334 100644 --- a/frontend/tests/product-archive-ui.test.mjs +++ b/frontend/tests/product-archive-ui.test.mjs @@ -15,3 +15,42 @@ test('archive client submits document JSON and a PNG preview as multipart data', assert.match(source, /form\.append\('document_json'/); assert.match(source, /form\.append\('preview'/); }); + +test('canvas offers a product archive action and reports detected source count', async () => { + const canvas = await readFile(new URL('../src/pages/CanvasStudio.tsx', import.meta.url), 'utf8'); + const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8'); + + assert.match(canvas, /加入产品列表/); + assert.match(dialog, /已检测到.*份画布词云/); + assert.match(dialog, /将作为产品封面/); +}); + +test('archive dialog states the server scan is authoritative and covers zero-source drafts', async () => { + const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8'); + + assert.match(dialog, /(以服务端扫描为准|服务端扫描为最终依据|最终归档数量以服务端为准)/); + assert.match(dialog, /无词云归档数据/); +}); + +test('archive success hides raw identifiers and reports the archived count', async () => { + const canvas = await readFile(new URL('../src/pages/CanvasStudio.tsx', import.meta.url), 'utf8'); + + assert.match(canvas, /已归档至产品/); + assert.match(canvas, /份词云位置数据已长期保存/); + assert.doesNotMatch(canvas, /已归档至产品.*\{product\.product_id\}/); +}); + +test('archive status styles cover archived, empty and pending cleanup states', async () => { + const styles = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8'); + + assert.match(styles, /\.product-status-archived/); + assert.match(styles, /\.product-status-empty/); + assert.match(styles, /\.product-status-pending-cleanup/); +}); + +test('new product creation archives with the created product id instead of a draft id', async () => { + const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8'); + + assert.match(dialog, /await createManualProduct\(token, \{ name, sku, specification \}\)/); + assert.match(dialog, /archiveProductVersion\(token, product\.product_id, document, previewBlob\)/); +});