375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|