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
+25 -1
View File
@@ -178,6 +178,12 @@ export default function AdvancedPanel({
</div>
</div>
<Hint> 5~10</Hint>
<BoolField
label="自动重复填充至轮廓完整"
checked={params.autoRepeatToFill}
onChange={v => onParamsChange({ autoRepeatToFill: v })}
hint="开启后,当掩膜轮廓填不满时自动循环追加名字副本,直到形状轮廓填充完毕(最多 20 次)"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
@@ -198,8 +204,17 @@ export default function AdvancedPanel({
step={0.01}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })}
/>
<NumberField
label="竖排概率 VERTICAL_RATIO"
value={params.verticalRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ verticalRatio: v ?? 0.18 })}
/>
</div>
<Hint>SIZE_RATIO 2.0</Hint>
<Hint>VERTICAL_RATIO 0.18</Hint>
<div className="form-row">
<NumberField
@@ -235,7 +250,7 @@ export default function AdvancedPanel({
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ workScale: v ?? 0.2 })}
onChange={v => onParamsChange({ workScale: v ?? 0.18 })}
/>
<NumberField
label="目标填充率 TARGET_FILL"
@@ -257,6 +272,15 @@ export default function AdvancedPanel({
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="生成调试文件"
checked={params.saveDebugImages}
onChange={v => onParamsChange({ saveDebugImages: v })}
hint="开启后保存掩膜和占用网格等中间图片;正常生成建议关闭以减少磁盘 I/O"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
+4 -2
View File
@@ -9,10 +9,11 @@ interface CanvasAreaProps {
viewMode: '2d' | '3d';
zoom: number;
highlightLocation: NameLocation | null;
onImageLoaded?: () => void;
}
export default function CanvasArea({
maskFile, jobResult, viewMode, zoom, highlightLocation
maskFile, jobResult, viewMode, zoom, highlightLocation, onImageLoaded
}: CanvasAreaProps) {
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -44,7 +45,7 @@ export default function CanvasArea({
</div>
) : viewMode === '3d' ? (
<div className="view-3d-container">
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" onLoad={onImageLoaded} />
</div>
) : (
<div className="canvas-image-wrapper">
@@ -52,6 +53,7 @@ export default function CanvasArea({
src={displayUrl}
alt="wordcloud"
className="canvas-image"
onLoad={onImageLoaded}
style={{ transform: `scale(${zoom})` }}
/>
{highlightLocation && jobResult && (
+9
View File
@@ -268,3 +268,12 @@ export function IconHelp() {
</Icon>
);
}
export function IconCopy() {
return (
<Icon>
<rect x="4" y="2" width="9" height="11" rx="1.5" />
<path d="M3 5h-.5a1.5 1.5 0 0 0-1.5 1.5v6A1.5 1.5 0 0 0 2.5 14h6A1.5 1.5 0 0 0 10 12.5V12" />
</Icon>
);
}
+70 -3
View File
@@ -1,12 +1,33 @@
import { useCallback, useEffect, useRef } from 'react';
import { SSEProgress } from '../types';
import { IconCross, IconCheckmark, IconGear } from './Icons';
import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons';
interface ProgressPanelProps {
progress: SSEProgress | null;
logLines: string[];
visible: boolean;
}
export default function ProgressPanel({ progress, visible }: ProgressPanelProps) {
export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) {
const logEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll the log view to the bottom whenever new lines arrive.
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [logLines]);
// All hooks MUST run before any early return, so derive the values the
// callback needs without depending on `progress` being non-null.
const message = progress?.message ?? '';
const handleCopyError = useCallback(() => {
const text = logLines.length > 0 ? logLines.join('\n') : message;
navigator.clipboard.writeText(text).catch(() => {
const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null;
if (el) { el.select(); document.execCommand('copy'); }
});
}, [logLines, message]);
if (!visible || !progress) return null;
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
@@ -20,6 +41,13 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
{progress.stage}
</div>
{(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && (
<div className="progress-timing" style={{ color: isDone ? 'var(--success)' : 'var(--text-muted)' }}>
{progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)}`}
{progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''}
{progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)}`}
</div>
)}
{!isFailed && (
<div className="progress-bar-track">
<div
@@ -31,6 +59,33 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
/>
</div>
)}
{/* ── 详细日志(实时滚动) ───────────────────────────────── */}
{logLines.length > 0 && (
<div
className="progress-log-view"
style={{
marginTop: 8,
maxHeight: 320,
overflowY: 'auto',
background: 'var(--bg-secondary, #1a1a2e)',
borderRadius: 6,
padding: '8px 10px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--text-muted, #888)',
border: '1px solid var(--border-color, #333)',
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
<div ref={logEndRef} />
</div>
)}
{/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
<div
className="progress-message"
style={{
@@ -41,7 +96,19 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
marginTop: isFailed ? 8 : 0,
}}
>
{progress.message}
{isFailed ? (
<div className="error-detail-container">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleCopyError}
title="复制完整日志"
style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<IconCopy />
</button>
</div>
) : null}
</div>
</div>
);
+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: {
+158 -8
View File
@@ -32,6 +32,7 @@ import {
} from '../lib/canvasDocument';
import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary';
import { createLayerExportZip, serializeDocument } from '../lib/svgExport';
import { exportCanvasPackage, safePackageBaseName } from '../lib/canvasPackage';
import { apiUrl, ensureOk } from '../lib/api';
import {
IconGrid,
@@ -461,16 +462,16 @@ export default function CanvasStudio({
});
};
const exportSvg = useCallback(async () => {
const svg = await serializeDocument(normalizedDocument, stickerById);
const exportSvg = useCallback(async (addRegistrationMarks: boolean) => {
const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks });
downloadBlob(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
'canvas-design.svg',
);
}, [normalizedDocument, stickerById]);
const exportLayerZip = async (layerIds: string[], folderIds: string[]) => {
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds);
const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
downloadBlob(blob, 'canvas-layers.zip');
};
@@ -858,6 +859,7 @@ export default function CanvasStudio({
activeLayerId={activeLayerId}
onActiveLayerChange={setActiveLayerId}
onChange={setDocumentModel}
stickerById={stickerById}
/>
);
case 'sticker':
@@ -1096,16 +1098,121 @@ export default function CanvasStudio({
);
}
function LayerThumbnail({
documentModel,
layer,
stickerById,
}: {
documentModel: CanvasDocument;
layer: CanvasLayer;
stickerById: Map<string, StickerAsset>;
}) {
const elements = documentModel.elements.filter(element => element.layerId === layer.id);
const fitScale = Math.min(1, 52 / Math.max(documentModel.width, documentModel.height, 1));
const scaledWidth = Math.max(1, documentModel.width * fitScale);
const scaledHeight = Math.max(1, documentModel.height * fitScale);
return (
<div className={`layer-thumb${layer.visible === false ? ' layer-thumb-hidden' : ''}`}>
<div
className="layer-thumb-scale"
style={{ width: scaledWidth, height: scaledHeight }}
>
<div
className="layer-thumb-doc"
style={{ width: documentModel.width, height: documentModel.height, transform: `scale(${fitScale})` }}
>
{elements.map(element => (
<LayerThumbElement
key={element.id}
element={element}
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
/>
))}
</div>
</div>
</div>
);
}
function LayerThumbElement({
element,
asset,
}: {
element: CanvasElement;
asset?: StickerAsset;
}) {
const baseStyle: CSSProperties = {
position: 'absolute',
left: element.x,
top: element.y,
width: element.width,
height: element.height,
opacity: element.opacity,
transform: `rotate(${element.rotation}deg)`,
};
if (element.type === 'sticker') {
if (!asset) return (<div className="missing-sticker" style={baseStyle}></div>);
return (
<img
className={`studio-sticker-image${asset.tint === 'gray' ? ' gray-mask' : ''} layer-thumb-image`}
style={baseStyle}
src={assetToDataUrl(asset)}
alt={asset.name}
draggable={false}
/>
);
}
if (element.type === 'text') {
return (
<div
className="layer-thumb-text"
style={{
...baseStyle,
color: element.fill,
fontFamily: element.fontFamily,
fontSize: element.fontSize,
fontWeight: element.fontWeight,
}}
>
{element.text}
</div>
);
}
if (element.type === 'line') {
return (
<div className="layer-thumb-line" style={baseStyle}>
<div style={{ width: '100%', height: Math.max(1, element.strokeWidth), background: element.stroke }} />
</div>
);
}
return (
<div
className={`layer-thumb-shape${element.type === 'ellipse' ? ' layer-thumb-ellipse' : ''}`}
style={{
...baseStyle,
background: element.fill === 'transparent' ? undefined : element.fill,
border: element.strokeWidth > 0 ? `${Math.max(0, element.strokeWidth)}px solid ${element.stroke}` : undefined,
}}
/>
);
}
function LayersPanel({
documentModel,
activeLayerId,
onActiveLayerChange,
onChange,
stickerById,
}: {
documentModel: CanvasDocument;
activeLayerId: string;
onActiveLayerChange: (id: string) => void;
onChange: (documentModel: CanvasDocument) => void;
stickerById: Map<string, StickerAsset>;
}) {
const layers = documentModel.layers || [];
const folders = documentModel.layerFolders || [];
@@ -1206,6 +1313,10 @@ function LayersPanel({
))}
{layers.slice().reverse().map(layer => (
<div key={layer.id} className={`layer-row${layer.id === activeLayerId ? ' active' : ''}`}>
<div className="layer-thumb-wrap">
<LayerThumbnail documentModel={documentModel} layer={layer} stickerById={stickerById} />
</div>
<div className="layer-info">
<div className="layer-main-line">
<button className="icon-btn layer-icon-btn" title="显示/隐藏" onClick={() => updateLayer(layer.id, { visible: !layer.visible })}>
{layer.visible ? <IconEyeOpen /> : <IconEyeClosed />}
@@ -1235,6 +1346,7 @@ function LayersPanel({
<button className="icon-btn layer-icon-btn" title="删除" onClick={() => deleteLayer(layer.id)}><IconTrash /></button>
</div>
</div>
</div>
</div>
))}
</div>
@@ -1308,8 +1420,8 @@ function CanvasExportPanel({
documentModel: CanvasDocument;
stickerById: Map<string, StickerAsset>;
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
onExportSvg: () => void;
onExportLayerZip: (layerIds: string[], folderIds: string[]) => void;
onExportSvg: (addRegistrationMarks: boolean) => void;
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
onReset: () => void;
}) {
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
@@ -1318,6 +1430,8 @@ function CanvasExportPanel({
const [templateName, setTemplateName] = useState('');
const [templateDescription, setTemplateDescription] = useState('');
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
const [exportingPackage, setExportingPackage] = useState(false);
const layers = documentModel.layers || [];
const folders = documentModel.layerFolders || [];
const backgroundEnabled = hasCanvasBackground(documentModel.background);
@@ -1355,6 +1469,24 @@ function CanvasExportPanel({
}
};
const exportPackage = async () => {
if (exportingPackage) return;
setExportingPackage(true);
try {
const blob = await exportCanvasPackage(
documentModel,
stickerById,
templateName || '画布设计',
templateDescription,
);
downloadBlob(blob, `${safePackageBaseName(templateName || '画布设计')}.wcd`);
} catch (error) {
alert(error instanceof Error ? error.message : '导出设计包失败');
} finally {
setExportingPackage(false);
}
};
return (
<>
<div className="form-row">
@@ -1412,7 +1544,18 @@ function CanvasExportPanel({
/>
</div>
)}
<button className="btn btn-primary btn-block" onClick={onExportSvg}> SVG</button>
<div className="form-group">
<label className="checkbox-row">
<input
type="checkbox"
checked={addRegistrationMarks}
onChange={event => setAddRegistrationMarks(event.target.checked)}
/>
<span></span>
</label>
<div className="note-text"></div>
</div>
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}> SVG</button>
<div className="section-divider" />
<div className="section-title"></div>
@@ -1441,7 +1584,7 @@ function CanvasExportPanel({
</div>
<button
className="btn btn-secondary btn-block"
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds)}
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds, addRegistrationMarks)}
>
SVG
</button>
@@ -1469,6 +1612,13 @@ function CanvasExportPanel({
<button className="btn btn-secondary btn-block" disabled={savingTemplate} onClick={saveTemplate}>
{savingTemplate ? '保存中' : '保存当前画布为模板'}
</button>
<button
className="btn btn-secondary btn-block"
disabled={exportingPackage}
onClick={exportPackage}
>
{exportingPackage ? '打包中' : '导出 .wcd'}
</button>
<button className="btn btn-danger btn-block" onClick={onReset}></button>
<span style={{ display: 'none' }}>{stickerById.size}</span>
</>
+33 -1
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import {
assetUrl,
deleteCanvasTemplate,
importCanvasTemplate,
listAssets,
listDesignTemplates,
templateCoverId,
@@ -19,6 +20,7 @@ import {
IconCloud,
IconRefresh,
IconCanvas,
IconDownload,
IconHelp,
} from '../components/Icons';
@@ -48,7 +50,9 @@ export default function TemplateHome({
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [importing, setImporting] = useState(false);
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
const importFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadStickerLibrary().then(items => {
@@ -92,6 +96,23 @@ export default function TemplateHome({
refresh();
};
const handleImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || importing) return;
setImporting(true);
setError('');
try {
await importCanvasTemplate(file);
await refresh();
alert('设计包已导入');
} catch (err) {
setError(err instanceof Error ? err.message : '导入设计包失败');
} finally {
setImporting(false);
}
};
return (
<div className="template-home">
<nav className="navbar">
@@ -104,6 +125,17 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={() => importFileRef.current?.click()} disabled={importing}>
<span className="nav-btn-icon"><IconDownload /></span>
<span className="nav-btn-label">{importing ? '导入中' : '导入 .wcd'}</span>
</button>
<input
ref={importFileRef}
style={{ display: 'none' }}
type="file"
accept=".wcd"
onChange={handleImportFile}
/>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenCanvas}>
+112 -5
View File
@@ -45,14 +45,21 @@ const DEFAULT_PARAMS: JobParams = {
fontColor: '#000000',
nRepetitions: 1,
strokeWeights: true,
autoRepeatToFill: true,
// Intermediate mask/occupancy images are useful for diagnosis but add
// extra disk I/O, so keep them disabled for normal generation.
saveDebugImages: false,
// 字号与填充
sizeRatio: 2.0,
packingEfficiency: 0.9,
verticalRatio: 0.18,
targetFillRatio: 0.45,
userMinFontSize: null,
userMaxFontSize: null,
minReadableHeightPx: 22,
// 0.18 keeps the 750-word service request complete on the default mask;
// lower values can make the coarse grid too small to place every word.
workScale: 0.18,
// 画布
@@ -118,7 +125,9 @@ export default function TestWorkbench({
const [jobId, setJobId] = useState<string | null>(null);
const [jobResult, setJobResult] = useState<JobResult | null>(null);
const [progress, setProgress] = useState<SSEProgress | null>(null);
const [logLines, setLogLines] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [clientElapsedSeconds, setClientElapsedSeconds] = useState<number | null>(null);
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
@@ -130,6 +139,10 @@ export default function TestWorkbench({
const [fonts, setFonts] = useState<Font[]>([]);
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const sseRef = useRef<EventSource | null>(null);
const requestStartedAtRef = useRef<number | null>(null);
const displayedJobRef = useRef<string | null>(null);
const previewRequestedJobRef = useRef<string | null>(null);
const displayElapsedRef = useRef<number | null>(null);
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
const focusWorkbenchPanel = floatingPanels.focusPanel;
@@ -408,7 +421,13 @@ export default function TestWorkbench({
}
setIsGenerating(true);
requestStartedAtRef.current = performance.now();
displayedJobRef.current = null;
previewRequestedJobRef.current = null;
displayElapsedRef.current = null;
setClientElapsedSeconds(null);
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
setLogLines([]);
setJobResult(null);
setHighlightLocation(null);
@@ -427,8 +446,11 @@ export default function TestWorkbench({
FONT_COLOR: params.fontColor || '#000000',
N_REPETITIONS: params.nRepetitions,
ENABLE_STROKE_WEIGHTS: params.strokeWeights,
AUTO_REPEAT_TO_FILL: params.autoRepeatToFill,
SAVE_DEBUG_IMAGES: params.saveDebugImages,
SIZE_RATIO: params.sizeRatio,
PACKING_EFFICIENCY: params.packingEfficiency,
VERTICAL_RATIO: params.verticalRatio,
TARGET_FILL_RATIO: params.targetFillRatio,
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
WORK_SCALE: params.workScale,
@@ -487,11 +509,23 @@ export default function TestWorkbench({
sse.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
const localElapsed = requestStartedAtRef.current == null
? undefined
: (performance.now() - requestStartedAtRef.current) / 1000;
setProgress({
stage: msg.stage ?? '',
percent: msg.progress_percent ?? 0,
message: msg.message ?? '',
elapsedSeconds: typeof msg.elapsed_seconds === 'number' ? msg.elapsed_seconds : undefined,
clientElapsedSeconds: localElapsed,
});
if (msg.message) {
setLogLines(prev => [...prev, msg.message]);
}
if (msg.stage === 'preview_ready' && previewRequestedJobRef.current !== id) {
previewRequestedJobRef.current = id;
fetchPreview(id);
}
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
sse.close();
fetchResult(id);
@@ -512,12 +546,48 @@ export default function TestWorkbench({
}
};
const fetchPreview = async (id: string) => {
try {
const res = await fetch(apiUrl(`/api/jobs/${id}/result`));
if (!res.ok) return;
const data: JobResult = await res.json();
if (!data.image_url || data.status === 'failed') return;
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
const elapsed = requestStartedAtRef.current == null
? undefined
: (performance.now() - requestStartedAtRef.current) / 1000;
setClientElapsedSeconds(elapsed ?? null);
setJobResult(prev => {
// The final request may win the race with this preview fetch. Never
// downgrade a completed result back to the partial running payload.
if (prev?.status === 'success') return prev;
return { ...prev, ...data, image_url: imageUrl };
});
setProgress(prev => {
if (prev?.stage === '完成' || prev?.stage === '生成失败') return prev;
return {
stage: 'preview_ready',
percent: Math.max(prev?.percent ?? 0, 94),
message: '预览已显示,后台继续导出其余文件',
elapsedSeconds: prev?.elapsedSeconds,
clientElapsedSeconds: elapsed,
};
});
} catch {
// The final result request remains the source of truth if preview fetch fails.
}
};
// ─── 获取结果 ─────────────────────────────────────────────────────────────
// GET /api/jobs/{job_id}/result
const fetchResult = async (id: string) => {
try {
const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
const data: JobResult = await res.json();
const resultElapsed = requestStartedAtRef.current == null
? null
: (performance.now() - requestStartedAtRef.current) / 1000;
setClientElapsedSeconds(resultElapsed);
if (data.status === 'failed') {
// 尝试从 /detail 拿更详细的错误信息
@@ -526,7 +596,8 @@ export default function TestWorkbench({
const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
if (dr.ok) {
const dd = await dr.json();
detail = dd.error ?? dd.message ?? '';
// API 返回 { status: { error: "...", ... }, recent_events: [...] }
detail = dd.status?.error ?? '';
} else {
detail = await readApiError(dr);
}
@@ -534,7 +605,11 @@ export default function TestWorkbench({
setProgress({
stage: '生成失败',
percent: 0,
message: `任务失败${detail ? '' + detail : ''}。可用 docker logs 查看后端堆栈。`,
message: detail
? `任务失败:\n${detail}`
: '任务失败。可用 docker logs 查看后端堆栈。',
elapsedSeconds: typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined,
clientElapsedSeconds: resultElapsed ?? undefined,
});
setIsGenerating(false);
return;
@@ -546,8 +621,17 @@ export default function TestWorkbench({
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
setTimeout(() => setProgress(null), 3000);
const backendElapsed = typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined;
const visibleElapsed = displayElapsedRef.current ?? resultElapsed ?? undefined;
setProgress({
stage: '完成',
percent: 100,
message: backendElapsed != null
? `词云生成完成,用时 ${backendElapsed.toFixed(2)}${visibleElapsed != null ? `,前端显示 ${visibleElapsed.toFixed(2)} 秒(QoS判断根据)` : ''}`
: '词云生成完成!',
elapsedSeconds: backendElapsed,
clientElapsedSeconds: visibleElapsed,
});
} catch (err) {
const msg = err instanceof Error ? err.message : '未知错误';
setProgress({ stage: '错误', percent: 0, message: msg });
@@ -556,6 +640,28 @@ export default function TestWorkbench({
}
};
const handleImageLoaded = () => {
const currentJobId = jobResult?.job_id;
if (!currentJobId) return;
const isFinal = progress?.stage === '完成';
const displayKey = `${currentJobId}:${isFinal ? 'final' : 'preview'}`;
if (displayedJobRef.current === displayKey) return;
displayedJobRef.current = displayKey;
if (requestStartedAtRef.current == null) return;
const elapsed = (performance.now() - requestStartedAtRef.current) / 1000;
displayElapsedRef.current = elapsed;
setClientElapsedSeconds(elapsed);
setProgress(prev => prev ? {
...prev,
clientElapsedSeconds: elapsed,
message: isFinal
? (prev.elapsedSeconds != null
? `词云生成完成,用时 ${prev.elapsedSeconds.toFixed(2)} 秒,前端显示 ${elapsed.toFixed(2)}`
: `词云生成完成,前端显示 ${elapsed.toFixed(2)}`)
: `预览已显示,前端耗时 ${elapsed.toFixed(2)}`,
} : prev);
};
const handleLocate = (loc: NameLocation) => {
setHighlightLocation(loc);
setViewMode('2d');
@@ -803,9 +909,10 @@ export default function TestWorkbench({
viewMode={viewMode}
zoom={zoom}
highlightLocation={highlightLocation}
onImageLoaded={handleImageLoaded}
/>
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
<ProgressPanel progress={progress} logLines={logLines} visible={isGenerating || !!progress} />
<ViewControls
zoom={zoom}
+83 -2
View File
@@ -1640,8 +1640,90 @@ body.resizing {
}
.layer-row {
grid-template-columns: 56px minmax(0, 1fr);
gap: 8px;
padding: 6px;
align-items: start;
}
.layer-info {
display: grid;
gap: 5px;
padding: 5px;
min-width: 0;
}
.layer-thumb-wrap {
position: relative;
width: 56px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.layer-thumb {
position: absolute;
inset: 0;
overflow: hidden;
background:
linear-gradient(45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
}
.layer-thumb-hidden {
opacity: 0.45;
}
.layer-thumb-scale {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
overflow: hidden;
}
.layer-thumb-doc {
position: relative;
transform-origin: 0 0;
pointer-events: none;
}
.layer-thumb-image,
.layer-thumb-text,
.layer-thumb-shape,
.layer-thumb-line {
position: relative;
pointer-events: none;
user-select: none;
box-sizing: border-box;
}
.layer-thumb-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.layer-thumb-text {
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.1;
}
.layer-thumb-line {
display: flex;
align-items: center;
}
.layer-thumb-ellipse {
border-radius: 50%;
}
.layer-row.active {
@@ -1667,7 +1749,6 @@ body.resizing {
.layer-sub-line {
grid-template-columns: minmax(0, 1fr) auto;
padding-left: 49px;
}
.layer-action-group {
+7
View File
@@ -18,10 +18,13 @@ export interface JobParams {
fontColor: string;
nRepetitions: number;
strokeWeights: boolean;
autoRepeatToFill: boolean;
saveDebugImages: boolean;
// 字号与填充
sizeRatio: number;
packingEfficiency: number;
verticalRatio: number;
targetFillRatio: number;
userMinFontSize: number | null;
userMaxFontSize: number | null;
@@ -47,6 +50,7 @@ export interface JobResult {
svg_stroke_url: string;
db_url: string;
metrics_url: string;
elapsed_seconds?: number | null;
}
export interface NameLocation {
@@ -71,6 +75,8 @@ export interface SSEProgress {
stage: string;
percent: number;
message: string;
elapsedSeconds?: number;
clientElapsedSeconds?: number;
}
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
@@ -104,6 +110,7 @@ export interface StickerAsset {
source: string;
createdAt: string;
tint?: string;
mimeType?: string;
}
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';