feat: add canvas product archive flow

This commit is contained in:
2026-09-12 16:58:24 +08:00
parent f900be30ba
commit e13ddd5562
7 changed files with 712 additions and 4 deletions
+10
View File
@@ -277,3 +277,13 @@ export function IconCopy() {
</Icon> </Icon>
); );
} }
export function IconArchive() {
return (
<Icon>
<rect x="3" y="3" width="10" height="10" rx="1" />
<path d="M5 3l3 3 1 0h2v3M10 10v3h-3" />
<path d="M5 13h6M3 13v-1M10 13v-1" />
</Icon>
);
}
@@ -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<string, StickerAsset>;
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<ProductStatus, string> = {
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<ArchiveDialogState>('loadingProducts');
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState('');
const [creating, setCreating] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(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<Blob | null>(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 (
<div className="product-archive-backdrop" role="dialog" aria-modal="true" aria-label="加入产品列表" onClick={onClose}>
<div className="product-archive-dialog" onClick={(event: ReactMouseEvent) => event.stopPropagation()}>
<header className="product-archive-head">
<span className="product-archive-eyebrow"><IconArchive /> </span>
<h2 className="product-archive-title"></h2>
<button className="icon-btn product-archive-close" title="关闭" onClick={onClose}><IconClose /></button>
</header>
<main className="product-archive-grid">
<section className="product-archive-preview-panel">
<div className="product-archive-preview-frame">
{previewUrl
? <img className="product-archive-preview" src={previewUrl} alt="当前完整设计预览(将作为产品封面)" />
: <div className="product-archive-preview-placeholder"></div>}
</div>
<p className="product-archive-preview-note"></p>
<p className="product-archive-source-note"> {visibleWordcloudCount} </p>
</section>
<section className="product-archive-form-panel">
{state === 'loadingProducts' && (
<p className="product-archive-state product-archive-loading"></p>
)}
{state === 'error' && (
<div className="product-archive-error">
<p>{errorText}</p>
</div>
)}
{(state === 'creatingProduct' || state === 'archiving') && (
<div className="product-archive-form">
{visibleWordcloudCount === 0 && (
<p className="product-archive-empty-copy"></p>
)}
<label className="product-archive-search-row">
<span></span>
<input
className="product-archive-search"
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="按名称或 SKU 搜索"
/>
</label>
<div className="product-archive-list">
{filteredProducts.map(product => (
<button
key={product.product_id}
type="button"
className={`product-archive-option${selectedId === product.product_id && !creating ? ' selected' : ''}`}
onClick={() => chooseProduct(product)}
>
<span className="product-archive-option-name">{product.name}</span>
<span className="product-archive-option-sku">{product.sku}</span>
<span className={`product-archive-option-status ${statusClass(product.status)}`}>
{productStatusLabel(product.status)}
</span>
</button>
))}
{filteredProducts.length === 0 && (
<p className="product-archive-empty"></p>
)}
</div>
<div className="product-archive-create">
<span className="product-archive-create-title"></span>
<label>
<span></span>
<input
className="product-archive-input"
value={name}
onFocus={() => startNewProduct()}
onChange={event => setName(event.target.value)}
placeholder="产品名称(必填)"
/>
</label>
<div className="product-archive-meta-row">
<label>
<span>SKU</span>
<input
className="product-archive-input"
value={sku}
onChange={event => setSku(event.target.value)}
placeholder="可选"
/>
</label>
<label>
<span></span>
<input
className="product-archive-input"
value={specification}
onChange={event => setSpecification(event.target.value)}
placeholder="可选"
/>
</label>
</div>
<div className="product-archive-create-actions">
<button className="btn btn-secondary btn-sm" type="button" onClick={startNewProduct}></button>
{creating && <span className="product-archive-create-note"></span>}
</div>
</div>
<footer className="product-archive-actions">
<button className="btn btn-primary btn-sm btn-block" disabled={!canConfirm || !previewBlob || state === 'archiving'} onClick={submitArchive}>
{state === 'archiving' ? '正在归档…' : previewBlob ? '确认归档' : '正在生成预览…'}
</button>
<button className="btn btn-secondary btn-sm" type="button" onClick={onClose}></button>
</footer>
</div>
)}
</section>
</main>
</div>
</div>
);
}
function countVisibleWordclouds(document: CanvasDocument, stickers: Map<string, StickerAsset>): number {
const visibleLayers = new Set(
(document.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id),
);
const seen = new Set<string>();
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 '已归档词云数据';
}
+6 -2
View File
@@ -73,12 +73,14 @@ interface BackendAsset {
type: string; type: string;
mime_type: string; mime_type: string;
file_url: string; file_url: string;
job_id?: string;
created_at: string; created_at: string;
} }
async function apiListAssets(): Promise<BackendAsset[]> { async function apiListAssets(): Promise<BackendAsset[]> {
const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败'); const res = await ensureOk(await fetch(apiUrl('/api/assets')), '读取贴纸库失败');
return res.json(); const assets = await res.json() as BackendAsset[];
return assets.filter(asset => asset.type === 'sticker' || asset.type === 'wordcloud');
} }
async function apiUploadAsset( async function apiUploadAsset(
@@ -114,6 +116,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image', type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: a.file_url, source: a.file_url,
createdAt: a.created_at, createdAt: a.created_at,
jobId: a.job_id || undefined,
tint: tints[a.asset_id] as StickerAsset['tint'], tint: tints[a.asset_id] as StickerAsset['tint'],
mimeType: a.mime_type, mimeType: a.mime_type,
})); }));
@@ -136,6 +139,7 @@ export async function addStickerAsset(
type: input.type, type: input.type,
source: asset.file_url, source: asset.file_url,
createdAt: asset.created_at, createdAt: asset.created_at,
jobId: asset.job_id || undefined,
tint: input.tint, tint: input.tint,
mimeType: asset.mime_type, mimeType: asset.mime_type,
}; };
+39
View File
@@ -8,6 +8,7 @@ import {
CanvasLayer, CanvasLayer,
CanvasLayerFolder, CanvasLayerFolder,
LineSpacingAnalysisSummary, LineSpacingAnalysisSummary,
ProductArchiveResult,
ShapeCanvasElement, ShapeCanvasElement,
StickerAsset, StickerAsset,
WordcloudReplaceSession, WordcloudReplaceSession,
@@ -52,7 +53,9 @@ import {
IconPlus, IconPlus,
IconSettings, IconSettings,
IconHelp, IconHelp,
IconArchive,
} from '../components/Icons'; } from '../components/Icons';
import ProductArchiveDialog from '../components/ProductArchiveDialog';
import FloatingPanel from '../components/FloatingPanel'; import FloatingPanel from '../components/FloatingPanel';
import DockTabBar, { DockTabItem } from '../components/DockTabBar'; import DockTabBar, { DockTabItem } from '../components/DockTabBar';
import { DOCK_WIDTH, DOCK_TOP_RESERVED, FloatingPanelLayout, TAB_BAR_HEIGHT, useFloatingPanels } from '../hooks/useFloatingPanels'; 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 [zoom, setZoom] = useState(0.55);
const [dragState, setDragState] = useState<DragState | null>(null); const [dragState, setDragState] = useState<DragState | null>(null);
const [openPanels, setOpenPanels] = useState<CanvasPanelId[]>(['layers', 'properties']); const [openPanels, setOpenPanels] = useState<CanvasPanelId[]>(['layers', 'properties']);
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const [archiveSuccessText, setArchiveSuccessText] = useState('');
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<HTMLDivElement>(null); const workspaceRef = useRef<HTMLDivElement>(null);
const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 }); const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 });
@@ -476,6 +481,23 @@ export default function CanvasStudio({
downloadBlob(blob, 'canvas-layers.zip'); 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 = ( const applyWordcloudAssetReplace = (
replaceTarget: NonNullable<WordcloudStickerPayload['replaceTarget']>, replaceTarget: NonNullable<WordcloudStickerPayload['replaceTarget']>,
nextAssetId: string, nextAssetId: string,
@@ -1018,6 +1040,7 @@ export default function CanvasStudio({
</div> </div>
<div className="navbar-end"> <div className="navbar-end">
<button className="btn btn-secondary btn-sm" onClick={onOpenWordcloud}></button> <button className="btn btn-secondary btn-sm" onClick={onOpenWordcloud}></button>
<button className="btn btn-secondary btn-sm" onClick={openProductArchive}><IconArchive /> </button>
{onOpenHelp && <button className="btn btn-secondary btn-sm" onClick={onOpenHelp}><IconHelp /> </button>} {onOpenHelp && <button className="btn btn-secondary btn-sm" onClick={onOpenHelp}><IconHelp /> </button>}
<AppSettingsWindow <AppSettingsWindow
themeMode={themeMode} themeMode={themeMode}
@@ -1094,6 +1117,22 @@ export default function CanvasStudio({
})} })}
{openPanels.map(renderPanel)} {openPanels.map(renderPanel)}
</div> </div>
{archiveDialogOpen && (
<ProductArchiveDialog
documentModel={normalizedDocument}
stickers={stickerById}
onArchived={handleProductArchived}
onClose={() => setArchiveDialogOpen(false)}
/>
)}
{archiveSuccessText && (
<div className="product-archive-toast" role="status">
<span className="product-archive-toast-icon"><IconArchive /></span>
<span>{archiveSuccessText}</span>
</div>
)}
</div> </div>
); );
} }
+312 -2
View File
@@ -3092,5 +3092,315 @@ body.resizing .studio-panel {
.orders-page .orders-input:focus, .orders-page .orders-input:focus,
.find-page .orders-input:focus { border-color: var(--lf-accent-border); } .find-page .orders-input:focus { border-color: var(--lf-accent-border); }
/* 未选任务时的居中占位 */ .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); }
/* ===== 产品档案归档弹窗 ===== */
.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);
}
}
+1
View File
@@ -109,6 +109,7 @@ export interface StickerAsset {
type: StickerAssetType; type: StickerAssetType;
source: string; source: string;
createdAt: string; createdAt: string;
jobId?: string;
tint?: string; tint?: string;
mimeType?: string; mimeType?: string;
} }
@@ -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\('document_json'/);
assert.match(source, /form\.append\('preview'/); 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\)/);
});