306 lines
12 KiB
TypeScript
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 '已归档词云数据';
|
|
}
|