feat: add product archive management page

This commit is contained in:
2026-09-12 17:34:03 +08:00
parent e13ddd5562
commit cd9308ae8f
5 changed files with 637 additions and 1 deletions
+404
View File
@@ -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>
);
}