feat: add canvas product archive flow
This commit is contained in:
@@ -277,3 +277,13 @@ export function IconCopy() {
|
||||
</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 '已归档词云数据';
|
||||
}
|
||||
@@ -73,12 +73,14 @@ interface BackendAsset {
|
||||
type: string;
|
||||
mime_type: string;
|
||||
file_url: string;
|
||||
job_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function apiListAssets(): Promise<BackendAsset[]> {
|
||||
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<StickerAsset[]> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<DragState | null>(null);
|
||||
const [openPanels, setOpenPanels] = useState<CanvasPanelId[]>(['layers', 'properties']);
|
||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||
const [archiveSuccessText, setArchiveSuccessText] = useState('');
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceSizeRef = useRef<WorkspaceSize>({ 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<WordcloudStickerPayload['replaceTarget']>,
|
||||
nextAssetId: string,
|
||||
@@ -1018,6 +1040,7 @@ export default function CanvasStudio({
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<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>}
|
||||
<AppSettingsWindow
|
||||
themeMode={themeMode}
|
||||
@@ -1094,6 +1117,22 @@ export default function CanvasStudio({
|
||||
})}
|
||||
{openPanels.map(renderPanel)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+312
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface StickerAsset {
|
||||
type: StickerAssetType;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
jobId?: string;
|
||||
tint?: 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\('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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user