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
+71 -5
View File
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from PIL import Image, ImageDraw, ImageFont
@@ -895,12 +895,42 @@ def get_file(job_id: str, kind: str):
# ═══════════════════════════════════════════════════════════
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务)
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务,需登录
# ═══════════════════════════════════════════════════════════
# 生产订单管理后台口令(建议生产通过环境变量 ORDERS_ADMIN_PASSWORD 覆盖)
ADMIN_PASSWORD = os.environ.get("ORDERS_ADMIN_PASSWORD") or "zhihui2024"
_ORDER_TOKEN_SECRET = os.environ.get("ORDERS_TOKEN_SECRET") or "wordcloud-orders-demo"
def _orders_token() -> str:
return hashlib.sha256((ADMIN_PASSWORD + ":" + _ORDER_TOKEN_SECRET).encode("utf-8")).hexdigest()
def _require_orders_auth(request: Request) -> None:
"""订单相关接口登录校验:Authorization: Bearer <token> 或 ?token= 需等于鉴权 token。"""
auth = request.headers.get("Authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
token = bearer or request.query_params.get("token", "")
if not token or token != _orders_token():
raise HTTPException(status_code=403, detail="需要登录") # noqa: S105
@app.post("/api/login")
async def login(request: Request) -> dict:
try:
payload = await request.json()
except Exception:
payload = {}
if (payload or {}).get("password") != ADMIN_PASSWORD:
raise HTTPException(status_code=401, detail="口令错误")
return {"token": _orders_token()}
@app.get("/api/orders")
def list_orders() -> list[dict]:
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务。"""
def list_orders(request: Request) -> list[dict]:
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务(需登录)"""
_require_orders_auth(request)
orders = []
if ORDERS_DIR.exists():
for item in list(ORDERS_DIR.iterdir()):
@@ -919,8 +949,44 @@ def list_orders() -> list[dict]:
return list(reversed(orders))
@app.get("/api/orders/export")
async def export_orders(request: Request) -> StreamingResponse:
"""一键导出多层 ZIP:每个订单一个嵌套文件夹(order.json + design + result)。"""
_require_orders_auth(request)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
dirs = [d for d in ORDERS_DIR.iterdir() if d.is_dir() and (d / "order.json").exists()]
for item in sorted(dirs):
o = _read_order(item)
if not o:
continue
order_no = str(o.get("order_no") or "order")
folder = f"orders/{order_no}/"
zf.writestr(folder + "order.json", json.dumps(o, ensure_ascii=False, indent=2))
tid = o.get("template_id")
job_id = o.get("job_id")
if tid:
tjson = _design_template_dir(tid) / "template.json"
if tjson.exists():
zf.write(str(tjson), folder + "design/template.json")
if job_id:
try:
st = manager.get_status(str(job_id))
png = manager.resolve_artifact_path(st, "png")
zf.write(str(png), folder + "result/result.png")
except Exception:
pass
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="orders-export.zip"'},
)
@app.get("/api/orders/{order_no}")
def get_order(order_no: str) -> dict:
def get_order(request: Request, order_no: str) -> dict:
_require_orders_auth(request)
path = Path(_order_dir(order_no)) / "order.json"
if not path.exists():
raise HTTPException(status_code=404, detail="order not found")
+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); }