feat(orders): 登录保护的生产订单标签页 + 一键导出多层 ZIP(小型程序 WCD 任务)

This commit is contained in:
2026-08-13 15:46:40 +08:00
parent e518540235
commit 8490b5c14d
6 changed files with 352 additions and 6 deletions
+15 -1
View File
@@ -2,12 +2,13 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import type { ThemeMode } from './components/AppSettingsWindow';
import CanvasStudio from './pages/CanvasStudio';
import HelpPage from './pages/HelpPage';
import OrdersPage from './pages/OrdersPage';
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' | 'help';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'help';
const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme');
@@ -98,6 +99,18 @@ export default function App() {
onOpenHome={() => setPage('home')}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenOrders={() => setPage('orders')}
/>
);
}
if (page === 'orders') {
return (
<OrdersPage
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
/>
);
}
@@ -117,6 +130,7 @@ export default function App() {
}}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenOrders={() => setPage('orders')}
onOpenHelp={openHelp}
/>
);
+6
View File
@@ -13,6 +13,7 @@ interface HelpPageProps {
onOpenHome: () => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
onOpenOrders: () => void;
}
const quickSteps = [
@@ -60,6 +61,7 @@ export default function HelpPage({
onOpenHome,
onOpenCanvas,
onOpenWordcloud,
onOpenOrders,
}: HelpPageProps) {
return (
<div className="help-page">
@@ -82,6 +84,10 @@ export default function HelpPage({
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenOrders}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useState, type FormEvent } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import {
IconCloud,
IconExport,
IconGrid,
IconLock,
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;
}
const TOKEN_KEY = 'wordcloud-orders-token';
interface OrdersPageProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenHome: () => void;
}
/**
* 生产订单页:独立标签页,需登录(POST /api/login → token)后才能查看
* 小程序下单派发到 wordcloud 的 WCD 生产任务,支持一键导出多层 ZIP。
*/
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 [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 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 onExport = async () => {
setExporting(true);
setError('');
try {
const res = await fetch(apiUrl('/api/orders/export'), {
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 = 'orders-export.zip';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : '导出失败');
} finally {
setExporting(false);
}
};
const onLogout = () => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setOrders([]);
};
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">
<div className="orders-toolbar">
<span className="orders-count"> {orders.length} · </span>
<button className="btn btn-primary btn-sm" onClick={onExport} disabled={exporting || !orders.length}>
<span className="nav-btn-icon"><IconExport /></span>
{exporting ? '导出中…' : '一键导出多层 ZIP'}
</button>
</div>
{error && <div className="orders-error">{error}</div>}
{loading ? (
<div className="table-empty"></div>
) : orders.length === 0 ? (
<div className="table-empty">
WCD
</div>
) : (
<table className="orders-table">
<thead>
<tr><th></th><th></th><th>ID</th><th>ID</th><th></th><th></th></tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.order_id}>
<td className="mono">{o.order_no}</td>
<td><span className={`orders-status status-${o.status || 'unknown'}`}>{o.status || '-'}</span></td>
<td className="mono">{o.job_id}</td>
<td className="mono">{o.template_id}</td>
<td>{o.created_at}</td>
<td>
{o.status === 'success' && o.job_id ? (
<a className="orders-preview" href={apiUrl(`/api/jobs/${o.job_id}/files/png`)} target="_blank" rel="noreferrer"></a>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
</div>
);
}
+6
View File
@@ -32,6 +32,7 @@ interface TemplateHomeProps {
onUseTemplate: (template: CanvasTemplate) => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
onOpenOrders: () => void;
onOpenHelp: () => void;
}
@@ -43,6 +44,7 @@ export default function TemplateHome({
onUseTemplate,
onOpenCanvas,
onOpenWordcloud,
onOpenOrders,
onOpenHelp,
}: TemplateHomeProps) {
const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
@@ -146,6 +148,10 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenOrders}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenHelp}>
<span className="nav-btn-icon"><IconHelp /></span>
<span className="nav-btn-label"></span>
+24
View File
@@ -2401,3 +2401,27 @@ body.resizing .side-panel,
body.resizing .studio-panel {
transition: none !important;
}
/* ── OrdersPage(生产订单,登录后可见)────────────── */
.orders-page { min-height: 100vh; display: flex; flex-direction: column; background: var(--workspace-bg, var(--bg)); }
.orders-main { flex: 1; padding: 24px 32px; max-width: 1080px; width: 100%; margin: 0 auto; box-sizing: border-box; }
.orders-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.orders-count { color: var(--text-secondary); font-size: 14px; }
.orders-error { color: var(--danger); background: var(--danger-light); padding: 10px 14px; border-radius: var(--radius-sm); margin: 12px 0; }
.orders-table { width: 100%; border-collapse: collapse; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-sm); }
.orders-table th, .orders-table td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
.orders-table th { color: var(--text-secondary); font-weight: 600; background: var(--bg-panel-alt); }
.orders-table tr:last-child td { border-bottom: none; }
.orders-status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; text-transform: capitalize; }
.status-success { background: var(--success-light); color: var(--success); }
.status-running { background: var(--accent-light); color: var(--accent); }
.status-queued { background: var(--bg-panel-alt); color: var(--text-secondary); }
.status-failed { background: var(--danger-light); color: var(--danger); }
.mono { font-family: var(--font-mono); font-size: 12px; }
.orders-preview { color: var(--accent); text-decoration: none; }
.orders-preview:hover { text-decoration: underline; }
.login-card { max-width: 400px; margin: 80px auto; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 28px 32px; box-shadow: var(--shadow-md); }
.login-title { margin: 0 0 8px; font-size: 20px; color: var(--text-primary); }
.orders-hint { color: var(--text-secondary); font-size: 13px; margin: 0 0 18px; line-height: 1.5; }
.orders-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text-primary); font-size: 14px; margin-bottom: 12px; }
.orders-input:focus { outline: none; border-color: var(--border-focus); }