feat(orders): 一键导出改为所有分层/分组的 SVG 压缩包(前端 createLayerExportZip 打包)
This commit is contained in:
+17
-13
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user