diff --git a/backend/service/app.py b/backend/service/app.py index 540c65e..ce91f3f 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -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。""" + 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") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27a4e69..6f4611d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + setPage('home')} /> ); } @@ -117,6 +130,7 @@ export default function App() { }} onOpenCanvas={() => setPage('canvas')} onOpenWordcloud={() => setPage('wordcloud')} + onOpenOrders={() => setPage('orders')} onOpenHelp={openHelp} /> ); diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index 2fe6c44..c0b8cb0 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -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 (
@@ -82,6 +84,10 @@ export default function HelpPage({ 词云 + void; + onOpenHome: () => void; +} + +/** + * 生产订单页:独立标签页,需登录(POST /api/login → token)后才能查看 + * 小程序下单派发到 wordcloud 的 WCD 生产任务,支持一键导出多层 ZIP。 + */ +export default function OrdersPage({ + themeMode, + systemTheme, + onThemeModeChange, + onOpenHome, +}: OrdersPageProps) { + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || ''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(''); + const [orders, setOrders] = useState([]); + 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 ( +
+ +
+
+

生产订单后台

+

查看小程序下单派发到本平台的 WCD 生产任务,请输入管理口令。

+ setPassword(e.target.value)} + /> + {loginError &&
{loginError}
} + +
+
+
+ ); + } + + return ( +
+ +
+
+ 共 {orders.length} 单 · 登录进入 + +
+ {error &&
{error}
} + {loading ? ( +
正在加载订单…
+ ) : orders.length === 0 ? ( +
+ 暂无生产订单。小程序下单派单后,这里会显示从你平台收到的 WCD 生产任务。 +
+ ) : ( + + + + + + {orders.map((o) => ( + + + + + + + + + ))} + +
订单号状态任务ID模板ID创建时间产物
{o.order_no}{o.status || '-'}{o.job_id}{o.template_id}{o.created_at} + {o.status === 'success' && o.job_id ? ( + 查看产物 + ) : '—'} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/TemplateHome.tsx b/frontend/src/pages/TemplateHome.tsx index f5e9170..f1e06e7 100644 --- a/frontend/src/pages/TemplateHome.tsx +++ b/frontend/src/pages/TemplateHome.tsx @@ -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([]); @@ -146,6 +148,10 @@ export default function TemplateHome({ 词云 +