feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
+116
View File
@@ -0,0 +1,116 @@
import { CanvasDocument, StickerAsset } from '../types';
import { normalizeDocument } from './canvasDocument';
import { apiUrl } from './api';
import { createZip, ZipFileInput } from './zip';
interface PackageAssetMeta {
id: string;
originalAssetId: string;
name: string;
type: string;
mimeType: string;
size: number;
}
function stickerMimeType(asset: StickerAsset): string {
if (asset.mimeType) return asset.mimeType;
return asset.type === 'svg' ? 'image/svg+xml' : 'image/png';
}
function stickerFileExtension(asset: StickerAsset): string {
if (asset.type === 'svg') return '.svg';
if (asset.mimeType === 'image/jpeg') return '.jpg';
if (asset.mimeType === 'image/png') return '.png';
const source = asset.source.toLowerCase();
if (source.endsWith('.jpg') || source.endsWith('.jpeg')) return '.jpg';
return '.png';
}
export function safePackageBaseName(name: string): string {
const cleaned = name.trim().replace(/[\\/:*?"<>|\n\t]/g, '_').replace(/\s+/g, '_').slice(0, 80);
return cleaned || '画布设计';
}
export async function exportCanvasPackage(
documentModel: CanvasDocument,
stickerById: Map<string, StickerAsset>,
name = '画布设计',
description = '',
): Promise<Blob> {
const doc = normalizeDocument(documentModel);
const usedAssetIds: string[] = [];
const seen = new Set<string>();
doc.elements.forEach(element => {
if (element.type !== 'sticker') return;
if (seen.has(element.assetId)) return;
seen.add(element.assetId);
usedAssetIds.push(element.assetId);
});
const packageIdByAsset = new Map<string, string>();
usedAssetIds.forEach((assetId, index) => {
packageIdByAsset.set(assetId, `asset-${String(index + 1).padStart(3, '0')}`);
});
const files: ZipFileInput[] = [];
const packageAssets: PackageAssetMeta[] = [];
for (const assetId of usedAssetIds) {
const asset = stickerById.get(assetId);
if (!asset) throw new Error(`画布引用了缺失素材:${assetId}`);
const res = await fetch(apiUrl(asset.source));
if (!res.ok) throw new Error(`读取素材失败:${asset.name} (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const packageId = packageIdByAsset.get(assetId) || assetId;
const ext = stickerFileExtension(asset);
packageAssets.push({
id: packageId,
originalAssetId: assetId,
name: asset.name,
type: asset.type,
mimeType: stickerMimeType(asset),
size: bytes.length,
});
files.push({
name: `assets/${packageId}${ext}`,
content: bytes,
});
}
const packageDocument = {
...doc,
elements: doc.elements.map(element => {
if (element.type !== 'sticker') return element;
return {
...element,
assetId: packageIdByAsset.get(element.assetId) || element.assetId,
};
}),
};
const manifest = {
format: 'wordcloud-canvas',
version: 1,
name: name.trim() || '画布设计',
description: description.trim(),
createdAt: new Date().toISOString(),
canvas: {
width: doc.width,
height: doc.height,
background: doc.background,
},
assets: packageAssets,
fonts: [],
};
files.unshift({
name: 'manifest.json',
content: JSON.stringify(manifest, null, 2),
});
files.splice(1, 0, {
name: 'document.json',
content: JSON.stringify(packageDocument, null, 2),
});
return createZip(files);
}
+3
View File
@@ -115,6 +115,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
source: a.file_url,
createdAt: a.created_at,
tint: tints[a.asset_id] as StickerAsset['tint'],
mimeType: a.mime_type,
}));
}
@@ -136,6 +137,7 @@ export async function addStickerAsset(
source: asset.file_url,
createdAt: asset.created_at,
tint: input.tint,
mimeType: asset.mime_type,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
return sticker;
@@ -160,6 +162,7 @@ export async function addStickerAssetFromJob(
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: asset.file_url,
createdAt: asset.created_at,
mimeType: asset.mime_type,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
return sticker;
+25 -4
View File
@@ -6,6 +6,8 @@ import { createZip } from './zip';
export interface SerializeOptions {
layerIds?: string[];
includeBackground?: boolean;
/** Add two in-canvas registration dots for physical/image alignment. */
addRegistrationMarks?: boolean;
}
async function fetchBlobAsDataUrl(url: string): Promise<string> {
@@ -108,6 +110,11 @@ export async function serializeDocument(
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
}
// Keep marks last so canvas elements cannot cover the alignment targets.
if (options.addRegistrationMarks) {
parts.push(serializeRegistrationMarks(doc.width, doc.height));
}
parts.push('</svg>');
return parts.join('\n');
}
@@ -117,6 +124,7 @@ export async function createLayerExportZip(
stickerById: Map<string, StickerAsset>,
selectedLayerIds: string[],
selectedFolderIds: string[],
options: Pick<SerializeOptions, 'addRegistrationMarks'> = {},
) {
const doc = normalizeDocument(documentModel);
const files: { name: string; content: string }[] = [];
@@ -125,7 +133,7 @@ export async function createLayerExportZip(
if (hasCanvasBackground(doc.background)) {
files.push({
name: uniqueSvgName('背景', used),
content: serializeBackgroundLayer(doc),
content: serializeBackgroundLayer(doc, options),
});
}
@@ -134,7 +142,7 @@ export async function createLayerExportZip(
if (!layer) continue;
files.push({
name: uniqueSvgName(layer.name, used),
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false, ...options }),
});
}
@@ -143,24 +151,37 @@ export async function createLayerExportZip(
if (!folder) continue;
files.push({
name: uniqueSvgName(folder.name, used),
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false, ...options }),
});
}
return createZip(files);
}
function serializeBackgroundLayer(documentModel: CanvasDocument) {
function serializeBackgroundLayer(documentModel: CanvasDocument, options: Pick<SerializeOptions, 'addRegistrationMarks'> = {}) {
const doc = normalizeDocument(documentModel);
const widthMm = pxToMm(doc.width).toFixed(1);
const heightMm = pxToMm(doc.height).toFixed(1);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
...(options.addRegistrationMarks ? [serializeRegistrationMarks(doc.width, doc.height)] : []),
'</svg>',
].join('\n');
}
function serializeRegistrationMarks(width: number, height: number) {
const minDimension = Math.max(1, Math.min(width, height));
// Keep the circles inside the canvas so neither SVG nor raster consumers clip them.
const inset = Math.max(4, minDimension * 0.012);
const radius = Math.max(1.5, minDimension * 0.004);
const format = (value: number) => formatSvgNumber(value);
return [
`<circle cx="${format(inset)}" cy="${format(inset)}" r="${format(radius)}" fill="#000000"/>`,
`<circle cx="${format(width - inset)}" cy="${format(height - inset)}" r="${format(radius)}" fill="#000000"/>`,
].join('\n');
}
function serializeInlineSvgSticker(
svgText: string,
targetWidth: number,
+14
View File
@@ -46,6 +46,20 @@ export async function createCanvasTemplate(input: {
return { ...template, document: normalizeDocument(template.document) };
}
export async function importCanvasTemplate(
file: File,
name = '',
description = '',
): Promise<CanvasTemplate> {
const fd = new FormData();
fd.append('file', file);
if (name) fd.append('name', name.trim());
if (description) fd.append('description', description.trim());
const res = await ensureOk(await fetch(apiUrl('/api/design-templates/import'), { method: 'POST', body: fd }), '导入设计包失败');
const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) };
}
export async function updateCanvasTemplate(
id: string,
partial: {