feat: add product archive management page
This commit is contained in:
+15
-1
@@ -4,12 +4,13 @@ import CanvasStudio from './pages/CanvasStudio';
|
|||||||
import FindPage from './pages/FindPage';
|
import FindPage from './pages/FindPage';
|
||||||
import HelpPage from './pages/HelpPage';
|
import HelpPage from './pages/HelpPage';
|
||||||
import OrdersPage from './pages/OrdersPage';
|
import OrdersPage from './pages/OrdersPage';
|
||||||
|
import ProductArchivePage from './pages/ProductArchivePage';
|
||||||
import TemplateHome from './pages/TemplateHome';
|
import TemplateHome from './pages/TemplateHome';
|
||||||
import TestWorkbench from './pages/TestWorkbench';
|
import TestWorkbench from './pages/TestWorkbench';
|
||||||
import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
|
import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
|
||||||
import { createDefaultDocument } from './lib/canvasDocument';
|
import { createDefaultDocument } from './lib/canvasDocument';
|
||||||
|
|
||||||
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'find' | 'help';
|
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'products' | 'find' | 'help';
|
||||||
|
|
||||||
const getStoredTheme = (): ThemeMode => {
|
const getStoredTheme = (): ThemeMode => {
|
||||||
const stored = window.localStorage.getItem('wordcloud-theme');
|
const stored = window.localStorage.getItem('wordcloud-theme');
|
||||||
@@ -129,6 +130,18 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (page === 'products') {
|
||||||
|
return (
|
||||||
|
<ProductArchivePage
|
||||||
|
themeMode={themeMode}
|
||||||
|
systemTheme={systemTheme}
|
||||||
|
onThemeModeChange={setThemeMode}
|
||||||
|
onOpenHome={() => setPage('home')}
|
||||||
|
onOpenHelp={openHelp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TemplateHome
|
<TemplateHome
|
||||||
themeMode={themeMode}
|
themeMode={themeMode}
|
||||||
@@ -143,6 +156,7 @@ export default function App() {
|
|||||||
setPage('canvas');
|
setPage('canvas');
|
||||||
}}
|
}}
|
||||||
onOpenOrders={() => setPage('orders')}
|
onOpenOrders={() => setPage('orders')}
|
||||||
|
onOpenProducts={() => setPage('products')}
|
||||||
onOpenFind={() => setPage('find')}
|
onOpenFind={() => setPage('find')}
|
||||||
onOpenHelp={openHelp}
|
onOpenHelp={openHelp}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
|
||||||
|
import { ensureOk } from '../lib/api';
|
||||||
|
import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons';
|
||||||
|
import {
|
||||||
|
deleteProduct,
|
||||||
|
getProduct,
|
||||||
|
listProducts,
|
||||||
|
restoreProduct,
|
||||||
|
} from '../lib/productArchive';
|
||||||
|
import type { ProductDetail, ProductStatus, ProductSummary } from '../types';
|
||||||
|
|
||||||
|
interface ProductArchivePageProps {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
systemTheme: 'light' | 'dark';
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
|
onOpenHome: () => void;
|
||||||
|
onOpenHelp?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'wordcloud-orders-token';
|
||||||
|
|
||||||
|
function statusClass(status: ProductStatus): string {
|
||||||
|
if (status === 'active') return 'product-status-archived';
|
||||||
|
if (status === 'pending_cleanup' || status === 'failed_cleanup') return 'product-status-pending-cleanup';
|
||||||
|
return 'product-status-empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(product: ProductSummary): string {
|
||||||
|
if (product.status === 'active') return '已归档词云数据';
|
||||||
|
if (product.status === 'pending_cleanup') return '待清理';
|
||||||
|
if (product.status === 'failed_cleanup') return '清理失败';
|
||||||
|
return '已清理';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductArchivePage({
|
||||||
|
themeMode,
|
||||||
|
systemTheme,
|
||||||
|
onThemeModeChange,
|
||||||
|
onOpenHome,
|
||||||
|
onOpenHelp,
|
||||||
|
}: ProductArchivePageProps) {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [products, setProducts] = useState<ProductSummary[]>([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<ProductDetail | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
|
||||||
|
const selected = products.find(product => product.product_id === selectedId) || null;
|
||||||
|
|
||||||
|
const loadProducts = async (authToken: string, search = query) => {
|
||||||
|
if (!authToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const items = await listProducts(authToken, search);
|
||||||
|
setProducts(items);
|
||||||
|
setSelectedId(current => items.some(item => item.product_id === current) ? current : null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '产品档案读取失败');
|
||||||
|
if (err instanceof Error && (err.message.includes('401') || err.message.includes('403'))) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken('');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProducts(token);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!token || !selectedId) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailLoading(true);
|
||||||
|
setError('');
|
||||||
|
getProduct(token, selectedId)
|
||||||
|
.then(next => {
|
||||||
|
if (!cancelled) setDetail(next);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(err instanceof Error ? err.message : '产品详情读取失败');
|
||||||
|
setDetail(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setDetailLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedId, token]);
|
||||||
|
|
||||||
|
const handleLogin = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoginError('');
|
||||||
|
try {
|
||||||
|
const res = await ensureOk(await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}), '登录失败');
|
||||||
|
const data = await res.json() as { token: string };
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.token);
|
||||||
|
setPassword('');
|
||||||
|
setToken(data.token);
|
||||||
|
} catch (err) {
|
||||||
|
setLoginError(err instanceof Error ? err.message : '登录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action: () => Promise<ProductSummary>) => {
|
||||||
|
if (!token || !selectedId || actionBusy) return;
|
||||||
|
setActionBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await loadProducts(token);
|
||||||
|
const nextDetail = await getProduct(token, selectedId);
|
||||||
|
setDetail(nextDetail);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyProductId = async () => {
|
||||||
|
if (!detail) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(detail.product_id);
|
||||||
|
} catch {
|
||||||
|
setError('复制产品 ID 失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="orders-page product-archive-page">
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="navbar-brand">
|
||||||
|
<div className="navbar-brand-icon"><IconLock /></div>
|
||||||
|
<span className="navbar-brand-name">产品档案 · 登录</span>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-actions" />
|
||||||
|
<div className="navbar-end">
|
||||||
|
<button className="nav-btn" onClick={onOpenHome}>
|
||||||
|
<span className="nav-btn-icon"><IconGrid /></span>
|
||||||
|
<span className="nav-btn-label">模板</span>
|
||||||
|
</button>
|
||||||
|
<AppSettingsWindow themeMode={themeMode} systemTheme={systemTheme} onThemeModeChange={onThemeModeChange} />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="orders-main">
|
||||||
|
<form className="login-card" onSubmit={handleLogin}>
|
||||||
|
<h2 className="login-title">产品档案后台</h2>
|
||||||
|
<p className="orders-hint">产品档案包含名单位置数据,请输入生产订单管理口令。</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="orders-input"
|
||||||
|
placeholder="管理口令"
|
||||||
|
value={password}
|
||||||
|
onChange={event => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
{loginError && <div className="orders-error">{loginError}</div>}
|
||||||
|
<button type="submit" className="btn btn-primary btn-block">登录并查看</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailImages = detail?.images || [];
|
||||||
|
const previewImage = detail
|
||||||
|
? detailImages.find(image => image.image_id === detail.cover_image_id) || detailImages[0]
|
||||||
|
: null;
|
||||||
|
const previewUrl = previewImage?.image_url || '';
|
||||||
|
const latestVersion = detail?.versions[detail.versions.length - 1] || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="orders-page product-archive-page">
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="navbar-brand">
|
||||||
|
<div className="navbar-brand-icon"><IconArchive /></div>
|
||||||
|
<span className="navbar-brand-name">产品档案</span>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-actions" />
|
||||||
|
<div className="navbar-end">
|
||||||
|
<button className="nav-btn" onClick={onOpenHome}>
|
||||||
|
<span className="nav-btn-icon"><IconGrid /></span>
|
||||||
|
<span className="nav-btn-label">模板</span>
|
||||||
|
</button>
|
||||||
|
<button className="nav-btn" onClick={() => loadProducts(token)}>
|
||||||
|
<span className="nav-btn-icon"><IconRefresh /></span>
|
||||||
|
<span className="nav-btn-label">刷新</span>
|
||||||
|
</button>
|
||||||
|
{onOpenHelp && (
|
||||||
|
<button className="nav-btn" onClick={onOpenHelp}>
|
||||||
|
<span className="nav-btn-icon"><IconHelp /></span>
|
||||||
|
<span className="nav-btn-label">帮助</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken('');
|
||||||
|
setProducts([]);
|
||||||
|
setDetail(null);
|
||||||
|
setSelectedId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="nav-btn-icon"><IconTrash /></span>
|
||||||
|
<span className="nav-btn-label">退出</span>
|
||||||
|
</button>
|
||||||
|
<AppSettingsWindow themeMode={themeMode} systemTheme={systemTheme} onThemeModeChange={onThemeModeChange} />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="orders-main orders-layout">
|
||||||
|
<aside className="orders-queue">
|
||||||
|
<div className="orders-queue-head">
|
||||||
|
<span>产品列表</span>
|
||||||
|
<span className="orders-count">{products.length} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="product-archive-search-block">
|
||||||
|
<input
|
||||||
|
className="orders-input"
|
||||||
|
placeholder="产品名称"
|
||||||
|
value={query}
|
||||||
|
onChange={event => setQuery(event.target.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') void loadProducts(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => loadProducts(token)}>搜索</button>
|
||||||
|
</div>
|
||||||
|
<div className="orders-queue-list">
|
||||||
|
{loading && <div className="table-empty">加载中…</div>}
|
||||||
|
{!loading && products.length === 0 && <div className="table-empty">暂无产品档案</div>}
|
||||||
|
{products.map(product => {
|
||||||
|
const coverId = product.cover_image_id;
|
||||||
|
const rowDetail = detail?.product_id === product.product_id ? detail : null;
|
||||||
|
const rowCover = rowDetail?.images.find(image => image.image_id === coverId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={product.product_id}
|
||||||
|
className={`product-archive-row${selectedId === product.product_id ? ' active' : ''}`}
|
||||||
|
onClick={() => setSelectedId(product.product_id)}
|
||||||
|
>
|
||||||
|
<span className="product-archive-thumb">
|
||||||
|
{rowCover?.image_url
|
||||||
|
? <img src={rowCover.image_url} alt={product.name} />
|
||||||
|
: <span className="product-archive-thumb-empty">暂无预览图</span>}
|
||||||
|
</span>
|
||||||
|
<span className="product-archive-row-body">
|
||||||
|
<span className="product-archive-row-name">{product.name}</span>
|
||||||
|
<span className="product-archive-row-meta">
|
||||||
|
{[product.sku, product.specification].filter(Boolean).join(' · ') || '未填写规格'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`product-status ${statusClass(product.status)}`}>{statusLabel(product)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="orders-detail">
|
||||||
|
{!selected ? (
|
||||||
|
<div className="table-empty">在左侧选择产品查看详情</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="orders-detail-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="orders-detail-title">{selected.name}</h2>
|
||||||
|
<span className={`orders-status ${statusClass(selected.status)}`}>{statusLabel(selected)}</span>
|
||||||
|
<span className="orders-detail-meta">
|
||||||
|
{[selected.sku, selected.specification].filter(Boolean).join(' · ') || '未填写规格'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="orders-detail-actions">
|
||||||
|
{(selected.status === 'pending_cleanup' || selected.status === 'failed_cleanup') && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => runAction(() => restoreProduct(token, selected.product_id))}
|
||||||
|
>
|
||||||
|
恢复产品
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.status === 'active' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={actionBusy}
|
||||||
|
onClick={() => runAction(() => deleteProduct(token, selected.product_id))}
|
||||||
|
>
|
||||||
|
软删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.status === 'pending_cleanup' && (
|
||||||
|
<div className="orders-error product-archive-warning">
|
||||||
|
待清理 · 将于 {formatDate(selected.purge_after)} 删除。恢复产品可结束本次清理窗口。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.status === 'failed_cleanup' && (
|
||||||
|
<div className="orders-error product-archive-warning">清理失败,请人工检查档案目录后再恢复。</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="orders-error">{error}</div>}
|
||||||
|
|
||||||
|
{detailLoading && <div className="table-empty">正在读取产品详情…</div>}
|
||||||
|
{detail && (
|
||||||
|
<div className="product-archive-detail">
|
||||||
|
<div className="product-archive-cover">
|
||||||
|
{previewUrl
|
||||||
|
? <img src={previewUrl} alt={`${selected.name} 预览图`} />
|
||||||
|
: <div className="product-archive-cover-empty">暂无预览图</div>}
|
||||||
|
</div>
|
||||||
|
<div className="product-archive-detail-body">
|
||||||
|
<div className="product-archive-metrics">
|
||||||
|
<div>
|
||||||
|
<span className="product-archive-metric-label">归档词云</span>
|
||||||
|
<strong>{latestVersion?.wordcloud_count ?? 0} 份</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="product-archive-metric-label">设计版本</span>
|
||||||
|
<strong>{detail.versions.length} 版</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="product-archive-metric-label">图片</span>
|
||||||
|
<strong>{detail.images.length} 张</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="product-archive-timeline">
|
||||||
|
<h3>版本时间线</h3>
|
||||||
|
{detail.versions.length === 0 && <p>暂无设计版本。</p>}
|
||||||
|
{detail.versions.map(version => (
|
||||||
|
<div className="product-archive-version" key={version.version_id}>
|
||||||
|
<div>
|
||||||
|
<span>{formatDateTime(version.created_at)}</span>
|
||||||
|
<span>{version.wordcloud_count} 份词云位置数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="product-archive-system">
|
||||||
|
<summary>系统信息</summary>
|
||||||
|
<div className="product-archive-system-row">
|
||||||
|
<span>产品 ID</span>
|
||||||
|
<code>{detail.product_id}</code>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={copyProductId}>复制产品 ID</button>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { serializeDocument } from '../lib/svgExport';
|
import { serializeDocument } from '../lib/svgExport';
|
||||||
import { loadStickerLibrary } from '../lib/stickerLibrary';
|
import { loadStickerLibrary } from '../lib/stickerLibrary';
|
||||||
import {
|
import {
|
||||||
|
IconArchive,
|
||||||
IconGrid,
|
IconGrid,
|
||||||
IconCloud,
|
IconCloud,
|
||||||
IconFind,
|
IconFind,
|
||||||
@@ -32,6 +33,7 @@ interface TemplateHomeProps {
|
|||||||
onCreateBlank: () => void;
|
onCreateBlank: () => void;
|
||||||
onUseTemplate: (template: CanvasTemplate) => void;
|
onUseTemplate: (template: CanvasTemplate) => void;
|
||||||
onOpenOrders: () => void;
|
onOpenOrders: () => void;
|
||||||
|
onOpenProducts: () => void;
|
||||||
onOpenFind: () => void;
|
onOpenFind: () => void;
|
||||||
onOpenHelp: () => void;
|
onOpenHelp: () => void;
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ export default function TemplateHome({
|
|||||||
onCreateBlank,
|
onCreateBlank,
|
||||||
onUseTemplate,
|
onUseTemplate,
|
||||||
onOpenOrders,
|
onOpenOrders,
|
||||||
|
onOpenProducts,
|
||||||
onOpenFind,
|
onOpenFind,
|
||||||
onOpenHelp,
|
onOpenHelp,
|
||||||
}: TemplateHomeProps) {
|
}: TemplateHomeProps) {
|
||||||
@@ -143,6 +146,10 @@ export default function TemplateHome({
|
|||||||
<span className="nav-btn-icon"><IconCloud /></span>
|
<span className="nav-btn-icon"><IconCloud /></span>
|
||||||
<span className="nav-btn-label">生产订单</span>
|
<span className="nav-btn-label">生产订单</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="nav-btn" onClick={onOpenProducts}>
|
||||||
|
<span className="nav-btn-icon"><IconArchive /></span>
|
||||||
|
<span className="nav-btn-label">产品档案</span>
|
||||||
|
</button>
|
||||||
<button className="nav-btn" onClick={onOpenFind}>
|
<button className="nav-btn" onClick={onOpenFind}>
|
||||||
<span className="nav-btn-icon"><IconFind /></span>
|
<span className="nav-btn-icon"><IconFind /></span>
|
||||||
<span className="nav-btn-label">查找</span>
|
<span className="nav-btn-label">查找</span>
|
||||||
|
|||||||
@@ -3404,3 +3404,197 @@ body.resizing .studio-panel {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 产品档案管理页 ===== */
|
||||||
|
.product-archive-search-block {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.product-archive-search-block .orders-input {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.product-archive-row {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.product-archive-row:hover,
|
||||||
|
.product-archive-row.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.product-archive-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
.product-archive-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.product-archive-thumb-empty,
|
||||||
|
.product-archive-cover-empty {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.product-archive-row-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.product-archive-row-name,
|
||||||
|
.product-archive-row-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.product-archive-row-name {
|
||||||
|
font-family: var(--font-main);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.product-archive-row-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.product-archive-warning {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.product-archive-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.75fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.product-archive-cover {
|
||||||
|
min-height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
.product-archive-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.product-archive-cover-empty {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.product-archive-detail-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.product-archive-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.product-archive-metrics > div {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
.product-archive-metric-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.product-archive-metrics strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.product-archive-timeline {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
.product-archive-timeline h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.product-archive-timeline p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.product-archive-version {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.product-archive-version:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.product-archive-version > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.product-archive-system {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.product-archive-system summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.product-archive-system-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.product-archive-system-row code {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.product-archive-detail {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.product-archive-row {
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.product-archive-row .product-status {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,3 +54,20 @@ test('new product creation archives with the created product id instead of a dra
|
|||||||
assert.match(dialog, /await createManualProduct\(token, \{ name, sku, specification \}\)/);
|
assert.match(dialog, /await createManualProduct\(token, \{ name, sku, specification \}\)/);
|
||||||
assert.match(dialog, /archiveProductVersion\(token, product\.product_id, document, previewBlob\)/);
|
assert.match(dialog, /archiveProductVersion\(token, product\.product_id, document, previewBlob\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('app routes to product archives and list keeps IDs out of primary cells', async () => {
|
||||||
|
const app = await readFile('frontend/src/App.tsx', 'utf8');
|
||||||
|
const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8');
|
||||||
|
assert.match(app, /'products'/);
|
||||||
|
assert.match(page, /产品档案/);
|
||||||
|
assert.match(page, /产品名称/);
|
||||||
|
assert.doesNotMatch(page, /<td>\{product\.product_id\}<\/td>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product archive page exposes restore and soft delete actions', async () => {
|
||||||
|
const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8');
|
||||||
|
assert.match(page, /恢复产品/);
|
||||||
|
assert.match(page, /软删除/);
|
||||||
|
assert.match(page, /系统信息/);
|
||||||
|
assert.match(page, /待清理 · 将于/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user