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")
|
@app.get("/api/orders/{order_no}/design")
|
||||||
def order_design(request: Request, order_no: str) -> dict:
|
def order_design(request: Request, order_no: str) -> dict:
|
||||||
"""订单设计详情:画布 + 各层元素(含素材 URL),供前端做分层/整体预览。"""
|
"""订单设计详情:完整 CanvasDocument + 素材映射,供前端分层/整体预览与分层 SVG 导出。"""
|
||||||
_require_orders_auth(request)
|
_require_orders_auth(request)
|
||||||
path = Path(_order_dir(order_no)) / "order.json"
|
path = Path(_order_dir(order_no)) / "order.json"
|
||||||
if not path.exists():
|
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"))
|
data = json.loads(tjson.read_text(encoding="utf-8"))
|
||||||
doc = data.get("document") or {}
|
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 []:
|
for e in doc.get("elements") or []:
|
||||||
if not isinstance(e, dict):
|
if not isinstance(e, dict):
|
||||||
continue
|
continue
|
||||||
item = dict(e)
|
aid = e.get("assetId")
|
||||||
asset_id = e.get("assetId")
|
if not aid or str(aid) in assets:
|
||||||
if asset_id:
|
continue
|
||||||
meta = _read_asset_meta(_asset_dir(str(asset_id))) or {}
|
meta = _read_asset_meta(_asset_dir(str(aid))) or {}
|
||||||
item["assetUrl"] = meta.get("file_url") or f"/api/assets/{asset_id}/download"
|
mime = str(meta.get("mime_type") or "")
|
||||||
item["assetName"] = meta.get("name") or str(asset_id)
|
assets[str(aid)] = {
|
||||||
else:
|
"id": str(aid),
|
||||||
item["assetUrl"] = ""
|
"type": "svg" if mime in ("image/svg+xml",) else "image",
|
||||||
elements.append(item)
|
"source": meta.get("file_url") or f"/api/assets/{aid}/download",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = manager.get_status(str(o.get("job_id") or "")).status
|
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,
|
"status": status,
|
||||||
"job_id": o.get("job_id"),
|
"job_id": o.get("job_id"),
|
||||||
"template_id": tid,
|
"template_id": tid,
|
||||||
"design": {
|
"document": {
|
||||||
"width": doc.get("width"),
|
"width": doc.get("width"),
|
||||||
"height": doc.get("height"),
|
"height": doc.get("height"),
|
||||||
"background": doc.get("background", "#ffffff"),
|
"background": doc.get("background", "#ffffff"),
|
||||||
"layers": doc.get("layers") or [],
|
"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,
|
IconTrash,
|
||||||
} from '../components/Icons';
|
} from '../components/Icons';
|
||||||
import { apiUrl, ensureOk } from '../lib/api';
|
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_id: string;
|
||||||
order_no: string;
|
order_no: string;
|
||||||
job_id: string;
|
job_id?: string;
|
||||||
template_id: string;
|
template_id?: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesignElement {
|
interface DesignAsset {
|
||||||
id?: string;
|
id: string;
|
||||||
type?: string;
|
type: 'svg' | 'image';
|
||||||
name?: string;
|
source: string;
|
||||||
assetId?: string;
|
|
||||||
assetUrl?: string;
|
|
||||||
assetName?: string;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
rotation?: number;
|
|
||||||
opacity?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderDesign {
|
interface OrderDesign {
|
||||||
@@ -39,13 +32,26 @@ interface OrderDesign {
|
|||||||
status: string;
|
status: string;
|
||||||
job_id?: string;
|
job_id?: string;
|
||||||
template_id?: string;
|
template_id?: string;
|
||||||
design: {
|
document: {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
background?: string;
|
background?: string;
|
||||||
layers?: unknown[];
|
layers?: { id: string; name?: string; visible?: boolean }[];
|
||||||
elements: DesignElement[];
|
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';
|
const TOKEN_KEY = 'wordcloud-orders-token';
|
||||||
@@ -57,40 +63,38 @@ interface OrdersPageProps {
|
|||||||
onOpenHome: () => void;
|
onOpenHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在画布上按坐标渲染一组元素(用于分层/整体预览)。 */
|
/** 按画布坐标渲染文档元素(用于分层/整体预览)。 */
|
||||||
function LayerCanvas({ design, onlyIndex, maxWidth }: {
|
function LayerCanvas({ document: doc, assets, onlyIndex, maxWidth }: {
|
||||||
design: NonNullable<OrderDesign['design']>;
|
document: OrderDesign['document'];
|
||||||
|
assets: OrderDesign['assets'];
|
||||||
onlyIndex?: number;
|
onlyIndex?: number;
|
||||||
maxWidth: number;
|
maxWidth: number;
|
||||||
}) {
|
}) {
|
||||||
const width = design.width || 1200;
|
const width = doc.width || 1200;
|
||||||
const height = design.height || 1200;
|
const height = doc.height || 1200;
|
||||||
const scale = maxWidth / width;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="layer-canvas"
|
className="layer-canvas"
|
||||||
style={{
|
style={{ width: maxWidth, height: Math.round(height * scale), background: doc.background || '#ffffff' }}
|
||||||
width: maxWidth,
|
|
||||||
height: Math.round(height * scale),
|
|
||||||
background: design.background || '#ffffff',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{elements.map((el, i) => {
|
{elements.map((el, i) => {
|
||||||
const w = (el.width ?? 100) * scale;
|
if (!el.assetId) return null;
|
||||||
const h = (el.height ?? 100) * scale;
|
const src = assets[el.assetId];
|
||||||
|
if (!src) return null;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
key={`${el.id}-${i}`}
|
key={`${el.id || el.assetId}-${i}`}
|
||||||
className="layer-el"
|
className="layer-el"
|
||||||
src={apiUrl(el.assetUrl || '')}
|
src={apiUrl(src.source)}
|
||||||
alt={el.assetName || el.name || `层 ${i + 1}`}
|
alt={`层 ${i + 1}`}
|
||||||
style={{
|
style={{
|
||||||
left: (el.x ?? 0) * scale,
|
left: (el.x ?? 0) * scale,
|
||||||
top: (el.y ?? 0) * scale,
|
top: (el.y ?? 0) * scale,
|
||||||
width: w,
|
width: (el.width ?? 100) * scale,
|
||||||
height: h,
|
height: (el.height ?? 100) * scale,
|
||||||
transform: `rotate(${el.rotation ?? 0}deg)`,
|
transform: `rotate(${el.rotation ?? 0}deg)`,
|
||||||
opacity: el.opacity ?? 1,
|
opacity: el.opacity ?? 1,
|
||||||
}}
|
}}
|
||||||
@@ -110,7 +114,7 @@ export default function OrdersPage({
|
|||||||
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) || '');
|
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) || '');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loginError, setLoginError] = useState('');
|
const [loginError, setLoginError] = useState('');
|
||||||
const [orders, setOrders] = useState<OrderItem[]>([]);
|
const [orders, setOrders] = useState<OrderList[]>([]);
|
||||||
const [selectedNo, setSelectedNo] = useState<string | null>(null);
|
const [selectedNo, setSelectedNo] = useState<string | null>(null);
|
||||||
const [design, setDesign] = useState<OrderDesign | null>(null);
|
const [design, setDesign] = useState<OrderDesign | null>(null);
|
||||||
const [previewMode, setPreviewMode] = useState<'overall' | 'layers'>('overall');
|
const [previewMode, setPreviewMode] = useState<'overall' | 'layers'>('overall');
|
||||||
@@ -142,7 +146,7 @@ export default function OrdersPage({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const selectOrder = async (o: OrderItem) => {
|
const selectOrder = async (o: OrderList) => {
|
||||||
setSelectedNo(o.order_no);
|
setSelectedNo(o.order_no);
|
||||||
setPreviewMode('overall');
|
setPreviewMode('overall');
|
||||||
setError('');
|
setError('');
|
||||||
@@ -158,17 +162,24 @@ export default function OrdersPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadZip = async (path: string) => {
|
/** 一键导出:所有分层 + 分组打包成 SVG 压缩包(复用画布图层导出逻辑)。 */
|
||||||
|
const onExport = async () => {
|
||||||
|
if (!design) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl(path), { headers: { Authorization: `Bearer ${token}` } });
|
const doc = design.document as unknown as CanvasDocument;
|
||||||
await ensureOk(res, '导出失败');
|
const stickerById = new Map<string, StickerAsset>();
|
||||||
const blob = await res.blob();
|
for (const [aid, a] of Object.entries(design.assets)) {
|
||||||
const url = URL.createObjectURL(blob);
|
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');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
@@ -283,9 +294,7 @@ export default function OrdersPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="orders-queue-list">
|
<div className="orders-queue-list">
|
||||||
{loading && <div className="table-empty">加载中…</div>}
|
{loading && <div className="table-empty">加载中…</div>}
|
||||||
{!loading && orders.length === 0 && (
|
{!loading && orders.length === 0 && <div className="table-empty">暂无订单</div>}
|
||||||
<div className="table-empty">暂无订单</div>
|
|
||||||
)}
|
|
||||||
{orders.map((o) => (
|
{orders.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.order_id}
|
key={o.order_id}
|
||||||
@@ -313,7 +322,7 @@ export default function OrdersPage({
|
|||||||
<h2 className="orders-detail-title mono">{design.order_no}</h2>
|
<h2 className="orders-detail-title mono">{design.order_no}</h2>
|
||||||
<span className={`orders-status status-${design.status || 'unknown'}`}>{design.status || '-'}</span>
|
<span className={`orders-status status-${design.status || 'unknown'}`}>{design.status || '-'}</span>
|
||||||
<span className="orders-detail-meta">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="orders-detail-actions">
|
<div className="orders-detail-actions">
|
||||||
@@ -325,9 +334,9 @@ export default function OrdersPage({
|
|||||||
<span className="nav-btn-icon"><IconLayers /></span>
|
<span className="nav-btn-icon"><IconLayers /></span>
|
||||||
<span className={`nav-btn-label ${previewMode === 'layers' ? 'active' : ''}`}>分层预览</span>
|
<span className={`nav-btn-label ${previewMode === 'layers' ? 'active' : ''}`}>分层预览</span>
|
||||||
</button>
|
</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>
|
<span className="nav-btn-icon"><IconExport /></span>
|
||||||
{exporting ? '导出中…' : '一键导出'}
|
{exporting ? '打包中…' : '一键导出(分层 SVG)'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,31 +347,30 @@ export default function OrdersPage({
|
|||||||
<div className="orders-preview-block">
|
<div className="orders-preview-block">
|
||||||
<div className="orders-preview-label">整体预览</div>
|
<div className="orders-preview-label">整体预览</div>
|
||||||
{design.job_id ? (
|
{design.job_id ? (
|
||||||
<img
|
<img className="orders-overall-img" src={apiUrl(`/api/jobs/${design.job_id}/files/png`)} alt="整体预览" />
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="orders-preview-block">
|
<div className="orders-preview-block">
|
||||||
<div className="orders-preview-label">分层预览(每层单独展示)</div>
|
<div className="orders-preview-label">分层预览(每层单独展示)</div>
|
||||||
<div className="orders-layer-list">
|
<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-item" key={`${el.id}-${i}`}>
|
||||||
<div className="orders-layer-thumb">
|
<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>
|
||||||
<div className="orders-layer-name">{el.assetName || el.name || `层 ${i + 1}`}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{design.design.elements.length === 0 && <div className="table-empty">该订单没有独立图层</div>}
|
{design.document.elements.length === 0 && <div className="table-empty">该订单没有独立图层</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="orders-preview-label" style={{ marginTop: 18 }}>合成预览(全部层叠加)</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user