Files
wordcloud/frontend/src/pages/OrdersPage.tsx
T

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type FormEvent } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import {
IconCloud,
IconExport,
IconGrid,
IconLock,
IconLayers,
IconTrash,
} from '../components/Icons';
import { apiUrl, ensureOk } from '../lib/api';
interface OrderItem {
order_id: string;
order_no: string;
job_id: string;
template_id: string;
status: string;
created_at: string;
}
interface DesignElement {
id?: string;
type?: string;
name?: string;
assetId?: string;
assetUrl?: string;
assetName?: string;
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
opacity?: number;
}
interface OrderDesign {
order_no: string;
status: string;
job_id?: string;
template_id?: string;
design: {
width?: number;
height?: number;
background?: string;
layers?: unknown[];
elements: DesignElement[];
};
}
const TOKEN_KEY = 'wordcloud-orders-token';
interface OrdersPageProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenHome: () => void;
}
/** 在画布上按坐标渲染一组元素(用于分层/整体预览)。 */
function LayerCanvas({ design, onlyIndex, maxWidth }: {
design: NonNullable<OrderDesign['design']>;
onlyIndex?: number;
maxWidth: number;
}) {
const width = design.width || 1200;
const height = design.height || 1200;
const scale = maxWidth / width;
const elements = design.elements.filter((_, i) => onlyIndex === undefined || i === onlyIndex);
return (
<div
className="layer-canvas"
style={{
width: maxWidth,
height: Math.round(height * scale),
background: design.background || '#ffffff',
}}
>
{elements.map((el, i) => {
const w = (el.width ?? 100) * scale;
const h = (el.height ?? 100) * scale;
return (
<img
key={`${el.id}-${i}`}
className="layer-el"
src={apiUrl(el.assetUrl || '')}
alt={el.assetName || el.name || `层 ${i + 1}`}
style={{
left: (el.x ?? 0) * scale,
top: (el.y ?? 0) * scale,
width: w,
height: h,
transform: `rotate(${el.rotation ?? 0}deg)`,
opacity: el.opacity ?? 1,
}}
/>
);
})}
</div>
);
}
export default function OrdersPage({
themeMode,
systemTheme,
onThemeModeChange,
onOpenHome,
}: OrdersPageProps) {
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) || '');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
const [orders, setOrders] = useState<OrderItem[]>([]);
const [selectedNo, setSelectedNo] = useState<string | null>(null);
const [design, setDesign] = useState<OrderDesign | null>(null);
const [previewMode, setPreviewMode] = useState<'overall' | 'layers'>('overall');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
const loadOrders = async (t: string) => {
setLoading(true);
setError('');
try {
const res = await fetch(apiUrl('/api/orders'), { headers: { Authorization: `Bearer ${t}` } });
if (res.status === 403 || res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
setToken('');
return;
}
await ensureOk(res, '读取订单失败');
setOrders(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : '读取订单失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) loadOrders(token);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectOrder = async (o: OrderItem) => {
setSelectedNo(o.order_no);
setPreviewMode('overall');
setError('');
try {
const res = await fetch(apiUrl(`/api/orders/${o.order_no}/design`), {
headers: { Authorization: `Bearer ${token}` },
});
await ensureOk(res, '读取订单详情失败');
setDesign(await res.json());
} catch (e) {
setDesign(null);
setError(e instanceof Error ? e.message : '读取订单详情失败');
}
};
const downloadZip = async (path: string) => {
setExporting(true);
setError('');
try {
const res = await fetch(apiUrl(path), { headers: { Authorization: `Bearer ${token}` } });
await ensureOk(res, '导出失败');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = path.startsWith('/api/orders/export') ? 'orders-export.zip' : `${selectedNo}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : '导出失败');
} finally {
setExporting(false);
}
};
const onLogin = async (e: FormEvent) => {
e.preventDefault();
setLoginError('');
try {
const res = await fetch(apiUrl('/api/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.status === 401) {
setLoginError('口令错误');
return;
}
await ensureOk(res, '登录失败');
const data = await res.json();
localStorage.setItem(TOKEN_KEY, data.token);
setToken(data.token);
setPassword('');
} catch (err) {
setLoginError(err instanceof Error ? err.message : '登录失败');
}
};
const onLogout = () => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setOrders([]);
setSelectedNo(null);
setDesign(null);
};
if (!token) {
return (
<div className="orders-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={onLogin}>
<h2 className="login-title">生产订单后台</h2>
<p className="orders-hint">查看小程序下单派发到本平台的 WCD 生产任务,请输入管理口令。</p>
<input
type="password"
className="orders-input"
placeholder="管理口令"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{loginError && <div className="orders-error">{loginError}</div>}
<button type="submit" className="btn btn-primary btn-block">
登录并查看
</button>
</form>
</main>
</div>
);
}
return (
<div className="orders-page">
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></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={() => loadOrders(token)}>
<span className="nav-btn-icon"><IconLock /></span>
<span className="nav-btn-label">刷新</span>
</button>
<button className="nav-btn" onClick={onLogout}>
<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">{orders.length} </span>
</div>
<div className="orders-queue-list">
{loading && <div className="table-empty">加载中…</div>}
{!loading && orders.length === 0 && (
<div className="table-empty">暂无订单</div>
)}
{orders.map((o) => (
<button
key={o.order_id}
className={`orders-q-item ${selectedNo === o.order_no ? 'active' : ''}`}
onClick={() => selectOrder(o)}
>
<div className="orders-q-item-top">
<span className="mono">{o.order_no}</span>
<span className={`orders-status status-${o.status || 'unknown'}`}>{o.status || '-'}</span>
</div>
<div className="orders-q-item-sub">{o.created_at}</div>
</button>
))}
</div>
</aside>
{/* 右:选中订单详情 */}
<section className="orders-detail">
{!selectedNo || !design ? (
<div className="table-empty">在左侧选择订单查看详情</div>
) : (
<>
<div className="orders-detail-head">
<div>
<h2 className="orders-detail-title mono">{design.order_no}</h2>
<span className={`orders-status status-${design.status || 'unknown'}`}>{design.status || '-'}</span>
<span className="orders-detail-meta">
任务 {design.job_id} · 模板 {design.template_id} · 画布 {design.design.width}×{design.design.height}
</span>
</div>
<div className="orders-detail-actions">
<button className="nav-btn" onClick={() => setPreviewMode('overall')}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className={`nav-btn-label ${previewMode === 'overall' ? 'active' : ''}`}>整体预览</span>
</button>
<button className="nav-btn" onClick={() => setPreviewMode('layers')}>
<span className="nav-btn-icon"><IconLayers /></span>
<span className={`nav-btn-label ${previewMode === 'layers' ? 'active' : ''}`}>分层预览</span>
</button>
<button className="btn btn-primary btn-sm" onClick={() => downloadZip(`/api/orders/${design.order_no}/export`)} disabled={exporting}>
<span className="nav-btn-icon"><IconExport /></span>
{exporting ? '导出中…' : '一键导出'}
</button>
</div>
</div>
{error && <div className="orders-error">{error}</div>}
{previewMode === 'overall' ? (
<div className="orders-preview-block">
<div className="orders-preview-label">整体预览</div>
{design.job_id ? (
<img
className="orders-overall-img"
src={apiUrl(`/api/jobs/${design.job_id}/files/png`)}
alt="整体预览"
/>
) : (
<LayerCanvas design={design.design} maxWidth={560} />
)}
</div>
) : (
<div className="orders-preview-block">
<div className="orders-preview-label">分层预览(每层单独展示)</div>
<div className="orders-layer-list">
{design.design.elements.map((el, i) => (
<div className="orders-layer-item" key={`${el.id}-${i}`}>
<div className="orders-layer-thumb">
<LayerCanvas design={design.design} onlyIndex={i} maxWidth={220} />
</div>
<div className="orders-layer-name">{el.assetName || el.name || `层 ${i + 1}`}</div>
</div>
))}
{design.design.elements.length === 0 && <div className="table-empty">该订单没有独立图层</div>}
</div>
<div className="orders-preview-label" style={{ marginTop: 18 }}>合成预览(全部层叠加)</div>
<LayerCanvas design={design.design} maxWidth={560} />
</div>
)}
</>
)}
</section>
</main>
</div>
);
}