diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b694c01..907a31c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,13 @@ import CanvasStudio from './pages/CanvasStudio'; import FindPage from './pages/FindPage'; import HelpPage from './pages/HelpPage'; import OrdersPage from './pages/OrdersPage'; +import ProductArchivePage from './pages/ProductArchivePage'; import TemplateHome from './pages/TemplateHome'; import TestWorkbench from './pages/TestWorkbench'; import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types'; 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 stored = window.localStorage.getItem('wordcloud-theme'); @@ -129,6 +130,18 @@ export default function App() { ); } + if (page === 'products') { + return ( + setPage('home')} + onOpenHelp={openHelp} + /> + ); + } + return ( setPage('orders')} + onOpenProducts={() => setPage('products')} onOpenFind={() => setPage('find')} onOpenHelp={openHelp} /> diff --git a/frontend/src/pages/ProductArchivePage.tsx b/frontend/src/pages/ProductArchivePage.tsx new file mode 100644 index 0000000..f365452 --- /dev/null +++ b/frontend/src/pages/ProductArchivePage.tsx @@ -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([]); + const [query, setQuery] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(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) => { + 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 ( +
+ +
+
+

产品档案后台

+

产品档案包含名单位置数据,请输入生产订单管理口令。

+ setPassword(event.target.value)} + /> + {loginError &&
{loginError}
} + +
+
+
+ ); + } + + 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 ( +
+ + +
+ + +
+ {!selected ? ( +
在左侧选择产品查看详情
+ ) : ( + <> +
+
+

{selected.name}

+ {statusLabel(selected)} + + {[selected.sku, selected.specification].filter(Boolean).join(' · ') || '未填写规格'} + +
+
+ {(selected.status === 'pending_cleanup' || selected.status === 'failed_cleanup') && ( + + )} + {selected.status === 'active' && ( + + )} +
+
+ + {selected.status === 'pending_cleanup' && ( +
+ 待清理 · 将于 {formatDate(selected.purge_after)} 删除。恢复产品可结束本次清理窗口。 +
+ )} + {selected.status === 'failed_cleanup' && ( +
清理失败,请人工检查档案目录后再恢复。
+ )} + {error &&
{error}
} + + {detailLoading &&
正在读取产品详情…
} + {detail && ( +
+
+ {previewUrl + ? {`${selected.name} + :
暂无预览图
} +
+
+
+
+ 归档词云 + {latestVersion?.wordcloud_count ?? 0} 份 +
+
+ 设计版本 + {detail.versions.length} 版 +
+
+ 图片 + {detail.images.length} 张 +
+
+ +
+

版本时间线

+ {detail.versions.length === 0 &&

暂无设计版本。

} + {detail.versions.map(version => ( +
+
+ {formatDateTime(version.created_at)} + {version.wordcloud_count} 份词云位置数据 +
+
+ ))} +
+ +
+ 系统信息 +
+ 产品 ID + {detail.product_id} +
+ +
+
+
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/TemplateHome.tsx b/frontend/src/pages/TemplateHome.tsx index d4b3828..f686312 100644 --- a/frontend/src/pages/TemplateHome.tsx +++ b/frontend/src/pages/TemplateHome.tsx @@ -17,6 +17,7 @@ import { import { serializeDocument } from '../lib/svgExport'; import { loadStickerLibrary } from '../lib/stickerLibrary'; import { + IconArchive, IconGrid, IconCloud, IconFind, @@ -32,6 +33,7 @@ interface TemplateHomeProps { onCreateBlank: () => void; onUseTemplate: (template: CanvasTemplate) => void; onOpenOrders: () => void; + onOpenProducts: () => void; onOpenFind: () => void; onOpenHelp: () => void; } @@ -43,6 +45,7 @@ export default function TemplateHome({ onCreateBlank, onUseTemplate, onOpenOrders, + onOpenProducts, onOpenFind, onOpenHelp, }: TemplateHomeProps) { @@ -143,6 +146,10 @@ export default function TemplateHome({ 生产订单 +