From 6be416c8d6b43a56948109e207fe87d23af84de0 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Thu, 13 Aug 2026 15:53:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(orders):=20=E8=AE=A2=E5=8D=95=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E5=8F=8C=E6=A0=8F=E2=80=94=E2=80=94=E5=B7=A6=E9=98=9F?= =?UTF-8?q?=E5=88=97/=E5=8F=B3=E8=AF=A6=E6=83=85=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=E5=88=86=E5=B1=82=E9=A2=84=E8=A7=88=E4=B8=8E=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E3=80=81=E5=8D=95=E8=AE=A2=E5=8D=95/?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/service/app.py | 82 +++++++++ frontend/src/pages/OrdersPage.tsx | 280 ++++++++++++++++++++++-------- frontend/src/styles.css | 26 +++ 3 files changed, 320 insertions(+), 68 deletions(-) diff --git a/backend/service/app.py b/backend/service/app.py index ce91f3f..5cd738e 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -999,6 +999,88 @@ def get_order(request: Request, order_no: str) -> dict: return o +@app.get("/api/orders/{order_no}/design") +def order_design(request: Request, order_no: str) -> dict: + """订单设计详情:画布 + 各层元素(含素材 URL),供前端做分层/整体预览。""" + _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") + o = json.loads(path.read_text(encoding="utf-8")) + tid = o.get("template_id") + tjson = _design_template_dir(tid) / "template.json" if tid else None + if not tjson or not tjson.exists(): + raise HTTPException(status_code=404, detail="订单设计不存在") + data = json.loads(tjson.read_text(encoding="utf-8")) + doc = data.get("document") or {} + + elements = [] + for e in doc.get("elements") or []: + if not isinstance(e, dict): + continue + item = dict(e) + asset_id = e.get("assetId") + if asset_id: + meta = _read_asset_meta(_asset_dir(str(asset_id))) or {} + item["assetUrl"] = meta.get("file_url") or f"/api/assets/{asset_id}/download" + item["assetName"] = meta.get("name") or str(asset_id) + else: + item["assetUrl"] = "" + elements.append(item) + + try: + status = manager.get_status(str(o.get("job_id") or "")).status + except Exception: + status = o.get("status", "unknown") + + return { + "order_no": order_no, + "status": status, + "job_id": o.get("job_id"), + "template_id": tid, + "design": { + "width": doc.get("width"), + "height": doc.get("height"), + "background": doc.get("background", "#ffffff"), + "layers": doc.get("layers") or [], + "elements": elements, + }, + } + + +@app.get("/api/orders/{order_no}/export") +async def export_order(request: Request, order_no: str) -> StreamingResponse: + """单订单导出多层 ZIP。""" + _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") + o = json.loads(path.read_text(encoding="utf-8")) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + 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": f'attachment; filename="order-{order_no}.zip"'}, + ) + + # ═══════════════════════════════════════════════════════════ # 4. 模板接口 # ═══════════════════════════════════════════════════════════ diff --git a/frontend/src/pages/OrdersPage.tsx b/frontend/src/pages/OrdersPage.tsx index c649390..8eb692f 100644 --- a/frontend/src/pages/OrdersPage.tsx +++ b/frontend/src/pages/OrdersPage.tsx @@ -5,6 +5,7 @@ import { IconExport, IconGrid, IconLock, + IconLayers, IconTrash, } from '../components/Icons'; import { apiUrl, ensureOk } from '../lib/api'; @@ -18,6 +19,35 @@ interface OrderItem { 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 { @@ -27,10 +57,50 @@ interface OrdersPageProps { onOpenHome: () => void; } -/** - * 生产订单页:独立标签页,需登录(POST /api/login → token)后才能查看 - * 小程序下单派发到 wordcloud 的 WCD 生产任务,支持一键导出多层 ZIP。 - */ +/** 在画布上按坐标渲染一组元素(用于分层/整体预览)。 */ +function LayerCanvas({ design, onlyIndex, maxWidth }: { + design: NonNullable; + 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 ( +
+ {elements.map((el, i) => { + const w = (el.width ?? 100) * scale; + const h = (el.height ?? 100) * scale; + return ( + {el.assetName + ); + })} +
+ ); +} + export default function OrdersPage({ themeMode, systemTheme, @@ -41,6 +111,9 @@ export default function OrdersPage({ const [password, setPassword] = useState(''); const [loginError, setLoginError] = useState(''); const [orders, setOrders] = useState([]); + const [selectedNo, setSelectedNo] = useState(null); + const [design, setDesign] = useState(null); + const [previewMode, setPreviewMode] = useState<'overall' | 'layers'>('overall'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [exporting, setExporting] = useState(false); @@ -49,9 +122,7 @@ export default function OrdersPage({ setLoading(true); setError(''); try { - const res = await fetch(apiUrl('/api/orders'), { - headers: { Authorization: `Bearer ${t}` }, - }); + const res = await fetch(apiUrl('/api/orders'), { headers: { Authorization: `Bearer ${t}` } }); if (res.status === 403 || res.status === 401) { localStorage.removeItem(TOKEN_KEY); setToken(''); @@ -71,6 +142,44 @@ export default function OrdersPage({ // 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(''); @@ -94,34 +203,12 @@ export default function OrdersPage({ } }; - 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([]); + setSelectedNo(null); + setDesign(null); }; if (!token) { @@ -186,44 +273,101 @@ export default function OrdersPage({ -
-
- 共 {orders.length} 单 · 登录进入 - -
- {error &&
{error}
} - {loading ? ( -
正在加载订单…
- ) : orders.length === 0 ? ( -
- 暂无生产订单。小程序下单派单后,这里会显示从你平台收到的 WCD 生产任务。 + +
+ {/* 左:订单队列 */} + + + {/* 右:选中订单详情 */} +
+ {!selectedNo || !design ? ( +
在左侧选择订单查看详情
+ ) : ( + <> +
+
+

{design.order_no}

+ {design.status || '-'} + + 任务 {design.job_id} · 模板 {design.template_id} · 画布 {design.design.width}×{design.design.height} + +
+
+ + + +
+
+ + {error &&
{error}
} + + {previewMode === 'overall' ? ( +
+
整体预览
+ {design.job_id ? ( + 整体预览 + ) : ( + + )} +
+ ) : ( +
+
分层预览(每层单独展示)
+
+ {design.design.elements.map((el, i) => ( +
+
+ +
+
{el.assetName || el.name || `层 ${i + 1}`}
+
+ ))} + {design.design.elements.length === 0 &&
该订单没有独立图层
} +
+
合成预览(全部层叠加)
+ +
+ )} + + )} +
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3becb79..2cced28 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2425,3 +2425,29 @@ body.resizing .studio-panel { .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); } + +/* ── OrdersPage 双栏:左订单队列 / 右详情 ────────── */ +.orders-layout { display: flex; gap: 20px; align-items: flex-start; max-width: 1280px; } +.orders-queue { width: 300px; flex-shrink: 0; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; align-self: stretch; max-height: calc(100vh - 140px); display: flex; flex-direction: column; } +.orders-queue-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; color: var(--text-primary); } +.orders-queue-list { flex: 1; overflow: auto; padding: 8px; } +.orders-q-item { display: block; width: 100%; text-align: left; background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 6px; cursor: pointer; } +.orders-q-item:hover { background: var(--bg-panel-alt); } +.orders-q-item.active { border-color: var(--accent); background: var(--accent-light); } +.orders-q-item-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.orders-q-item-sub { color: var(--text-muted); font-size: 12px; margin-top: 4px; } +.orders-detail { flex: 1; min-width: 0; } +.orders-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } +.orders-detail-title { margin: 0 0 6px; font-size: 20px; color: var(--text-primary); } +.orders-detail-meta { color: var(--text-secondary); font-size: 12px; margin-left: 12px; } +.orders-detail-actions { display: flex; align-items: center; gap: 8px; } +.orders-preview-block { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 16px; } +.orders-preview-label { color: var(--text-secondary); font-size: 13px; font-weight: 600; margin-bottom: 12px; } +.orders-overall-img { max-width: 100%; max-height: 60vh; display: block; border-radius: var(--radius-sm); border: 1px solid var(--border); } +.layer-canvas { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background-size: cover; } +.layer-el { position: absolute; object-fit: contain; transform-origin: center; pointer-events: none; } +.orders-layer-list { display: flex; flex-wrap: wrap; gap: 12px; } +.orders-layer-item { width: 220px; } +.orders-layer-thumb { box-shadow: var(--shadow-sm); } +.orders-layer-name { color: var(--text-secondary); font-size: 12px; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-btn-label.active { color: var(--accent); font-weight: 600; }