Files
wordcloud/frontend/src/components/ProductArchiveDialog.tsx
T

306 lines
12 KiB
TypeScript

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 '已归档词云数据';
}