feat(orders): 一键导出改为所有分层/分组的 SVG 压缩包(前端 createLayerExportZip 打包)

This commit is contained in:
2026-08-13 16:01:10 +08:00
parent 6be416c8d6
commit ee3b7745b7
2 changed files with 87 additions and 75 deletions
+17 -13
View File
@@ -1001,7 +1001,7 @@ def get_order(request: Request, order_no: str) -> dict:
@app.get("/api/orders/{order_no}/design")
def order_design(request: Request, order_no: str) -> dict:
"""订单设计详情:画布 + 各层元素(含素材 URL,供前端分层/整体预览。"""
"""订单设计详情:完整 CanvasDocument + 素材映射,供前端分层/整体预览与分层 SVG 导出"""
_require_orders_auth(request)
path = Path(_order_dir(order_no)) / "order.json"
if not path.exists():
@@ -1014,19 +1014,21 @@ def order_design(request: Request, order_no: str) -> dict:
data = json.loads(tjson.read_text(encoding="utf-8"))
doc = data.get("document") or {}
elements = []
# 素材映射:元素 assetId -> {id,type(source 是 svg 还是 image),source(下载 URL)}
assets: dict[str, dict] = {}
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)
aid = e.get("assetId")
if not aid or str(aid) in assets:
continue
meta = _read_asset_meta(_asset_dir(str(aid))) or {}
mime = str(meta.get("mime_type") or "")
assets[str(aid)] = {
"id": str(aid),
"type": "svg" if mime in ("image/svg+xml",) else "image",
"source": meta.get("file_url") or f"/api/assets/{aid}/download",
}
try:
status = manager.get_status(str(o.get("job_id") or "")).status
@@ -1038,13 +1040,15 @@ def order_design(request: Request, order_no: str) -> dict:
"status": status,
"job_id": o.get("job_id"),
"template_id": tid,
"design": {
"document": {
"width": doc.get("width"),
"height": doc.get("height"),
"background": doc.get("background", "#ffffff"),
"layers": doc.get("layers") or [],
"elements": elements,
"layerFolders": doc.get("layerFolders") or [],
"elements": doc.get("elements") or [],
},
"assets": assets,
}
+70 -62
View File
@@ -9,29 +9,22 @@ import {
IconTrash,
} from '../components/Icons';
import { apiUrl, ensureOk } from '../lib/api';
import { createLayerExportZip } from '../lib/svgExport';
import type { CanvasDocument, StickerAsset } from '../types';
interface OrderItem {
interface OrderList {
order_id: string;
order_no: string;
job_id: string;
template_id: 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 DesignAsset {
id: string;
type: 'svg' | 'image';
source: string;
}
interface OrderDesign {
@@ -39,13 +32,26 @@ interface OrderDesign {
status: string;
job_id?: string;
template_id?: string;
design: {
document: {
width?: number;
height?: number;
background?: string;
layers?: unknown[];
elements: DesignElement[];
layers?: { id: string; name?: string; visible?: boolean }[];
layerFolders?: { id: string; name?: string; layerIds?: string[] }[];
elements: Array<{
id?: string;
type?: string;
name?: string;
assetId?: string;
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
opacity?: number;
}>;
};
assets: Record<string, DesignAsset>;
}
const TOKEN_KEY = 'wordcloud-orders-token';
@@ -57,40 +63,38 @@ interface OrdersPageProps {
onOpenHome: () => void;
}
/** 画布上按坐标渲染一组元素(用于分层/整体预览)。 */
function LayerCanvas({ design, onlyIndex, maxWidth }: {
design: NonNullable<OrderDesign['design']>;
/** 画布坐标渲染文档元素(用于分层/整体预览)。 */
function LayerCanvas({ document: doc, assets, onlyIndex, maxWidth }: {
document: OrderDesign['document'];
assets: OrderDesign['assets'];
onlyIndex?: number;
maxWidth: number;
}) {
const width = design.width || 1200;
const height = design.height || 1200;
const width = doc.width || 1200;
const height = doc.height || 1200;
const scale = maxWidth / width;
const elements = design.elements.filter((_, i) => onlyIndex === undefined || i === onlyIndex);
const elements = doc.elements.filter((_, i) => onlyIndex === undefined || i === onlyIndex);
return (
<div
className="layer-canvas"
style={{
width: maxWidth,
height: Math.round(height * scale),
background: design.background || '#ffffff',
}}
style={{ width: maxWidth, height: Math.round(height * scale), background: doc.background || '#ffffff' }}
>
{elements.map((el, i) => {
const w = (el.width ?? 100) * scale;
const h = (el.height ?? 100) * scale;
if (!el.assetId) return null;
const src = assets[el.assetId];
if (!src) return null;
return (
<img
key={`${el.id}-${i}`}
key={`${el.id || el.assetId}-${i}`}
className="layer-el"
src={apiUrl(el.assetUrl || '')}
alt={el.assetName || el.name || `${i + 1}`}
src={apiUrl(src.source)}
alt={`${i + 1}`}
style={{
left: (el.x ?? 0) * scale,
top: (el.y ?? 0) * scale,
width: w,
height: h,
width: (el.width ?? 100) * scale,
height: (el.height ?? 100) * scale,
transform: `rotate(${el.rotation ?? 0}deg)`,
opacity: el.opacity ?? 1,
}}
@@ -110,7 +114,7 @@ export default function OrdersPage({
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) || '');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
const [orders, setOrders] = useState<OrderItem[]>([]);
const [orders, setOrders] = useState<OrderList[]>([]);
const [selectedNo, setSelectedNo] = useState<string | null>(null);
const [design, setDesign] = useState<OrderDesign | null>(null);
const [previewMode, setPreviewMode] = useState<'overall' | 'layers'>('overall');
@@ -142,7 +146,7 @@ export default function OrdersPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectOrder = async (o: OrderItem) => {
const selectOrder = async (o: OrderList) => {
setSelectedNo(o.order_no);
setPreviewMode('overall');
setError('');
@@ -158,17 +162,24 @@ export default function OrdersPage({
}
};
const downloadZip = async (path: string) => {
/** 一键导出:所有分层 + 分组打包成 SVG 压缩包(复用画布图层导出逻辑)。 */
const onExport = async () => {
if (!design) return;
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 doc = design.document as unknown as CanvasDocument;
const stickerById = new Map<string, StickerAsset>();
for (const [aid, a] of Object.entries(design.assets)) {
stickerById.set(aid, { id: a.id, type: a.type, source: a.source } as StickerAsset);
}
const layerIds = (doc.layers || []).map((l) => l.id);
const folderIds = (doc.layerFolders || []).map((f) => f.id);
const zip = await createLayerExportZip(doc, stickerById, layerIds, folderIds);
const url = URL.createObjectURL(zip);
const a = document.createElement('a');
a.href = url;
a.download = path.startsWith('/api/orders/export') ? 'orders-export.zip' : `${selectedNo}.zip`;
a.download = `${design.order_no}-layers.zip`;
document.body.appendChild(a);
a.click();
a.remove();
@@ -283,9 +294,7 @@ export default function OrdersPage({
</div>
<div className="orders-queue-list">
{loading && <div className="table-empty"></div>}
{!loading && orders.length === 0 && (
<div className="table-empty"></div>
)}
{!loading && orders.length === 0 && <div className="table-empty"></div>}
{orders.map((o) => (
<button
key={o.order_id}
@@ -313,7 +322,7 @@ export default function OrdersPage({
<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}
{design.job_id} · {design.template_id} · {design.document.width}×{design.document.height}
</span>
</div>
<div className="orders-detail-actions">
@@ -325,9 +334,9 @@ export default function OrdersPage({
<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}>
<button className="btn btn-primary btn-sm" onClick={onExport} disabled={exporting}>
<span className="nav-btn-icon"><IconExport /></span>
{exporting ? '导出中…' : '一键导出'}
{exporting ? '打包中…' : '一键导出(分层 SVG'}
</button>
</div>
</div>
@@ -338,31 +347,30 @@ export default function OrdersPage({
<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="整体预览"
/>
<img className="orders-overall-img" src={apiUrl(`/api/jobs/${design.job_id}/files/png`)} alt="整体预览" />
) : (
<LayerCanvas design={design.design} maxWidth={560} />
<LayerCanvas document={design.document} assets={design.assets} 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) => (
{design.document.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} />
<LayerCanvas document={design.document} assets={design.assets} onlyIndex={i} maxWidth={220} />
</div>
<div className="orders-layer-name">
{el.name || design.assets[el.assetId || ''] ?
design.assets[el.assetId || '']?.source?.split('/').pop() || `${i + 1}` : `${i + 1}`}
</div>
<div className="orders-layer-name">{el.assetName || el.name || `${i + 1}`}</div>
</div>
))}
{design.design.elements.length === 0 && <div className="table-empty"></div>}
{design.document.elements.length === 0 && <div className="table-empty"></div>}
</div>
<div className="orders-preview-label" style={{ marginTop: 18 }}></div>
<LayerCanvas design={design.design} maxWidth={560} />
<LayerCanvas document={design.document} assets={design.assets} maxWidth={560} />
</div>
)}
</>