feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user