+ {onResetDefaults && (
+
+
+
+ )}
- {/* SEED */}
+
基础
{
- const v = e.target.value.trim();
- onParamsChange({ seed: v === '' ? null : parseInt(v) });
- }}
+ onChange={e => onParamsChange({ seed: parseOptionalInt(e.target.value) })}
/>
- 相同种子可完整复现布局结果
+ 相同种子可完整复现布局结果;默认 42
-
-
- {/* FONT_COLOR */}
@@ -54,55 +153,386 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
style={{ flex: 1 }}
/>
-
默认黑色,留空则使用调色板渐变
+
默认黑色;清空颜色逻辑由后端调色板接管需另配
-
-
- {/* N_REPETITIONS */}
-
-
-
+
{
- const v = parseInt(e.target.value);
- onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) });
- }}
+ step={1}
+ onChange={v => onParamsChange({ nRepetitions: Math.max(1, Math.min(20, Math.round(v ?? 1))) })}
/>
-
- 词语较少时(如仅 10 个),可增大此值(如 5~10)使每个词语重复出现多次,提升填充率与观感。默认 1(不重复)。
-
+
+
+
+
+
名单较少时可增大重复次数(如 5~10)提升填充观感
+
字号与比例
+
+ onParamsChange({ sizeRatio: v ?? 2 })}
+ />
+ onParamsChange({ packingEfficiency: v ?? 0.85 })}
+ />
+
+
SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差
- {/* STROKE_WEIGHTS */}
-
-
>
diff --git a/frontend/src/components/ExportPanel.tsx b/frontend/src/components/ExportPanel.tsx
index 10edb44..85f1aff 100644
--- a/frontend/src/components/ExportPanel.tsx
+++ b/frontend/src/components/ExportPanel.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
-import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
+import { WordcloudMaskSource, WordcloudReplaceTarget, WordcloudStickerPayload } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
+import { addStickerAssetFromJob } from '../lib/stickerLibrary';
interface ExportPanelProps {
jobId: string | null;
@@ -8,6 +9,7 @@ interface ExportPanelProps {
imageUrl?: string;
onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null;
+ replaceTarget?: WordcloudReplaceTarget | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
embedded?: boolean;
}
@@ -21,6 +23,7 @@ export default function ExportPanel({
imageUrl,
onOpenCanvas,
maskSource,
+ replaceTarget = null,
onImportWordcloudSticker,
embedded = false,
}: ExportPanelProps) {
@@ -74,16 +77,48 @@ export default function ExportPanel({
triggerDownload(url, 'wordcloud.svg');
};
- const handleSaveAsSticker = async () => {
- const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
- if (!url || isSavingSticker) return;
+ const handleSaveAsSticker = async (mode: 'add' | 'replace' = 'add') => {
+ if (isSavingSticker) return;
+ if (mode === 'replace' && !replaceTarget) {
+ alert('当前不是同底图换名单模式');
+ return;
+ }
+ if (!jobId) {
+ alert('请先生成词云');
+ return;
+ }
setIsSavingSticker(true);
try {
+ // 快路径:服务端直接把 job SVG 复制入库,浏览器不下载/再上传大文件
+ if (mode === 'replace') {
+ const asset = await addStickerAssetFromJob(
+ jobId,
+ `词云 ${new Date().toLocaleString('zh-CN')}`,
+ );
+ if (onImportWordcloudSticker) {
+ onImportWordcloudSticker({
+ assetId: asset.id,
+ width: replaceTarget?.width,
+ height: replaceTarget?.height,
+ replaceTarget: replaceTarget || undefined,
+ });
+ } else {
+ onOpenCanvas?.();
+ }
+ return;
+ }
+
+ // 普通“作为贴纸导入画布”仍支持导出面板的描边/填充样式
+ const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
+ if (!url) throw new Error('没有可导出的 SVG');
const res = await ensureOk(await fetch(url), '读取 SVG 失败');
const svg = await res.text();
if (onImportWordcloudSticker) {
- onImportWordcloudSticker({ svg, mask: maskSource || undefined });
+ onImportWordcloudSticker({
+ svg,
+ mask: maskSource || undefined,
+ });
} else {
onOpenCanvas?.();
}
@@ -304,13 +339,28 @@ export default function ExportPanel({
>
导出 SVG
-
+ {replaceTarget ? (
+ <>
+
+ 同底图换名单模式:替换画布上的原词云,不新增贴纸。
+
+
+ >
+ ) : (
+
+ )}
diff --git a/frontend/src/components/FileUploader.tsx b/frontend/src/components/FileUploader.tsx
index bdf8902..32e85ff 100644
--- a/frontend/src/components/FileUploader.tsx
+++ b/frontend/src/components/FileUploader.tsx
@@ -7,9 +7,11 @@ interface FileUploaderProps {
acceptHint: string;
file: File | null;
onFileChange: (file: File | null) => void;
+ disabled?: boolean;
+ statusText?: string;
}
-export default function FileUploader({ label, accept, acceptHint, file, onFileChange }: FileUploaderProps) {
+export default function FileUploader({ label, accept, acceptHint, file, onFileChange, disabled = false, statusText }: FileUploaderProps) {
const inputRef = useRef
(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState(null);
@@ -17,12 +19,14 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
+ if (disabled) return;
const dropped = e.dataTransfer.files[0];
if (!dropped) return;
validateAndSet(dropped);
};
const handleChange = (e: ChangeEvent) => {
+ if (disabled) return;
const selected = e.target.files?.[0];
if (!selected) return;
validateAndSet(selected);
@@ -40,21 +44,32 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
};
const hasFile = file !== null;
- const statusClass = error ? 'error' : hasFile ? 'success' : '';
+ const statusClass = error ? 'error' : (hasFile || (!!statusText && !statusText.includes('失败') && !statusText.includes('载入…') && !statusText.includes('载入中') && !statusText.includes('正在'))) ? 'success' : statusText ? '' : '';
+ const showStatus = !!(error || hasFile || statusText);
+ const filenameText = error ? error : (statusText || file?.name || '');
+ const labelText = error
+ ? '导入失败'
+ : statusText
+ ? (statusText.includes('失败') ? '载入失败' : statusText.includes('正在') || statusText.includes('载入…') ? '载入中' : '已锁定')
+ : hasFile
+ ? '导入成功'
+ : '';
return (
{ e.preventDefault(); setDragging(true); }}
+ className={`upload-zone${dragging ? ' drag-over' : ''}${disabled ? ' disabled' : ''}`}
+ onDragOver={e => { e.preventDefault(); if (!disabled) setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
- onClick={() => inputRef.current?.click()}
+ onClick={() => { if (!disabled) inputRef.current?.click(); }}
+ style={disabled ? { opacity: 0.65, cursor: 'not-allowed' } : undefined}
>
e.stopPropagation()}
/>
@@ -64,13 +79,13 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
{acceptHint}
- {(hasFile || error) && (
+ {showStatus && (
- {error ? error : file?.name}
+ {filenameText}
- {error ? '导入失败' : '导入成功'}
+ {labelText}
)}
diff --git a/frontend/src/components/ImportPanel.tsx b/frontend/src/components/ImportPanel.tsx
index 47bded3..eba7e15 100644
--- a/frontend/src/components/ImportPanel.tsx
+++ b/frontend/src/components/ImportPanel.tsx
@@ -16,6 +16,9 @@ interface ImportPanelProps {
onFontDelete: (fontId: string) => void;
onFontSelect: (fontId: string) => void;
embedded?: boolean;
+ maskLocked?: boolean;
+ maskPreviewUrl?: string | null;
+ maskStatusText?: string;
}
const TEMPLATE_URL = '#';
@@ -25,6 +28,9 @@ export default function ImportPanel({
onMaskChange, onNamesChange, onParamsChange,
onFontUpload, onFontDelete, onFontSelect,
embedded = false,
+ maskLocked = false,
+ maskPreviewUrl = null,
+ maskStatusText,
}: ImportPanelProps) {
const fontInputRef = useRef(null);
@@ -48,12 +54,40 @@ export default function ImportPanel({
{/* 底图导入 */}
底图(mask_image)
+ {maskLocked ? (
+
+ 已锁定画布原底图,只可更换名单。
+
+ ) : null}
+ {maskLocked && maskPreviewUrl ? (
+
+

+
+ ) : null}
diff --git a/frontend/src/lib/stickerLibrary.ts b/frontend/src/lib/stickerLibrary.ts
index 2002ef0..3ef6d46 100644
--- a/frontend/src/lib/stickerLibrary.ts
+++ b/frontend/src/lib/stickerLibrary.ts
@@ -141,6 +141,30 @@ export async function addStickerAsset(
return sticker;
}
+/** 服务端直接从 job 产物复制入库,避免浏览器下载/再上传大 SVG */
+export async function addStickerAssetFromJob(
+ jobId: string,
+ name?: string,
+): Promise {
+ const form = new FormData();
+ form.append('name', name || `词云 ${new Date().toLocaleString('zh-CN')}`);
+ form.append('type', 'sticker');
+ const res = await ensureOk(
+ await fetch(apiUrl(`/api/assets/from-job/${jobId}`), { method: 'POST', body: form }),
+ '从任务导入贴纸失败',
+ );
+ const asset = await res.json() as BackendAsset;
+ const sticker: StickerAsset = {
+ id: asset.asset_id,
+ name: asset.name,
+ type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
+ source: asset.file_url,
+ createdAt: asset.created_at,
+ };
+ window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
+ return sticker;
+}
+
export async function deleteStickerAsset(id: string): Promise {
await apiDeleteAsset(id);
removeTint(id);
diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx
index ca340ae..c412884 100644
--- a/frontend/src/pages/CanvasStudio.tsx
+++ b/frontend/src/pages/CanvasStudio.tsx
@@ -9,6 +9,7 @@ import {
LineSpacingAnalysisSummary,
ShapeCanvasElement,
StickerAsset,
+ WordcloudReplaceSession,
WordcloudStickerPayload,
} from '../types';
import {
@@ -62,6 +63,7 @@ interface CanvasStudioProps {
onOpenHome: () => void;
onOpenWordcloud: () => void;
onOpenHelp?: () => void;
+ onReplaceWordcloudNames?: (session: WordcloudReplaceSession) => void;
initialDocument?: CanvasDocument | null;
onConsumeInitialDocument?: () => void;
pendingWordcloudSticker?: WordcloudStickerPayload | null;
@@ -110,6 +112,7 @@ export default function CanvasStudio({
onOpenHome,
onOpenWordcloud,
onOpenHelp,
+ onReplaceWordcloudNames,
initialDocument,
onConsumeInitialDocument,
pendingWordcloudSticker,
@@ -471,7 +474,165 @@ export default function CanvasStudio({
downloadBlob(blob, 'canvas-layers.zip');
};
+ const applyWordcloudAssetReplace = (
+ replaceTarget: NonNullable,
+ nextAssetId: string,
+ ) => {
+ const targetId = replaceTarget.elementId;
+ const maskElementId = replaceTarget.maskElementId;
+ const maskAssetId = replaceTarget.maskAssetId;
+
+ // 安全约束:遮罩元素绝不能成为替换目标
+ if (maskElementId && targetId === maskElementId) {
+ alert('内部错误:替换目标不能是原图遮罩');
+ return;
+ }
+
+ const target = normalizedDocument.elements.find(item => item.id === targetId);
+ if (!target || target.type !== 'sticker') {
+ alert('未找到要替换的词云元素');
+ return;
+ }
+
+ // 若误把遮罩当成了目标,直接拒绝
+ const targetAsset = stickerById.get(target.assetId);
+ if (isMaskStickerAsset(targetAsset) || looksLikeMaskName(layerNameOf(target, normalizedDocument))) {
+ alert('当前目标是原图遮罩,已取消替换。请选择词云图层。');
+ return;
+ }
+
+ // 替换前快照遮罩元素:后续强制写回,确保“原图遮罩”图层内容不变
+ const maskSnapshot = maskElementId
+ ? normalizedDocument.elements.find(item => item.id === maskElementId)
+ : undefined;
+ if (maskElementId && !maskSnapshot) {
+ alert('未找到原图遮罩元素,已取消替换,避免破坏组结构');
+ return;
+ }
+
+ // 库事件通常已触发刷新;这里异步兜底,确保新 asset 可渲染
+ void loadStickerLibrary().then(setStickers);
+
+ setDocumentModel(prev => {
+ let replacedCloud = false;
+ let nextElements = prev.elements.map(item => {
+ // 遮罩元素:完全保留原对象(assetId / opacity / 位置 / 图层 / 组)
+ if (maskElementId && item.id === maskElementId) {
+ return maskSnapshot || item;
+ }
+ if (item.id !== targetId || item.type !== 'sticker') {
+ return item;
+ }
+ replacedCloud = true;
+ // 只替换词云贴纸的资源引用
+ return {
+ ...item,
+ assetId: nextAssetId,
+ lineSpacingAnalysis: undefined,
+ };
+ });
+
+ // 若遮罩元素在文档里丢了,强制插回(放在词云前面,保持在下层)
+ if (maskSnapshot && !nextElements.some(el => el.id === maskSnapshot.id)) {
+ const cloudIndex = nextElements.findIndex(el => el.id === targetId);
+ if (cloudIndex >= 0) {
+ nextElements = [
+ ...nextElements.slice(0, cloudIndex),
+ maskSnapshot,
+ ...nextElements.slice(cloudIndex),
+ ];
+ } else {
+ nextElements = [...nextElements, maskSnapshot];
+ }
+ }
+
+ // 再次强制:遮罩 assetId 必须是原来的
+ if (maskSnapshot && maskSnapshot.type === 'sticker') {
+ nextElements = nextElements.map(el => {
+ if (el.id !== maskSnapshot.id || el.type !== 'sticker') return el;
+ return {
+ ...el,
+ assetId: maskAssetId || maskSnapshot.assetId,
+ opacity: maskSnapshot.opacity,
+ x: maskSnapshot.x,
+ y: maskSnapshot.y,
+ width: maskSnapshot.width,
+ height: maskSnapshot.height,
+ rotation: maskSnapshot.rotation,
+ layerId: maskSnapshot.layerId,
+ groupId: maskSnapshot.groupId,
+ };
+ });
+ }
+
+ // 确保遮罩层、词云层都可见
+ const nextLayers = (prev.layers || []).map(layer => {
+ if (maskSnapshot && layer.id === maskSnapshot.layerId) {
+ return { ...layer, visible: true };
+ }
+ if (layer.id === target.layerId) {
+ return { ...layer, visible: true };
+ }
+ return layer;
+ });
+
+ if (!replacedCloud) {
+ console.warn('[replace] cloud element was not updated', targetId);
+ }
+
+ return {
+ ...prev,
+ layers: nextLayers,
+ elements: nextElements,
+ };
+ });
+
+ setSelectedId(targetId);
+ setActiveLayerId(target.layerId || activeLayerId);
+ setOpenPanels(prev => {
+ const next = new Set(prev);
+ next.add('properties');
+ next.add('layers');
+ return Array.from(next);
+ });
+ };
+
const importWordcloudSticker = async (payload: WordcloudStickerPayload) => {
+
+ // 快路径:调用方已完成入库,这里只换词云层 assetId,遮罩层完全不动
+ if (payload.replaceTarget && payload.assetId) {
+ applyWordcloudAssetReplace(
+ payload.replaceTarget,
+ payload.assetId,
+ );
+ return;
+ }
+
+ if (!payload.svg) {
+ alert('缺少词云 SVG,无法导入');
+ return;
+ }
+
+ let cloudAsset: StickerAsset;
+ try {
+ cloudAsset = await addStickerAsset({
+ name: `词云 ${new Date().toLocaleString('zh-CN')}`,
+ type: 'svg',
+ source: payload.svg,
+ });
+ } catch (e) {
+ alert(e instanceof Error ? e.message : '贴纸保存失败');
+ return;
+ }
+
+ setStickers(await loadStickerLibrary());
+
+ // 兼容旧路径:若带 replaceTarget 但没 assetId,仍可上传后替换
+ if (payload.replaceTarget) {
+ applyWordcloudAssetReplace(payload.replaceTarget, cloudAsset.id);
+ return;
+ }
+
const folderId = makeId('folder');
const elementGroupId = makeId('element-group');
const maskLayerId = makeId('layer');
@@ -481,25 +642,20 @@ export default function CanvasStudio({
const x = Math.round((normalizedDocument.width - width) / 2);
const y = Math.round((normalizedDocument.height - height) / 2);
- let cloudAsset: StickerAsset;
let maskAsset: StickerAsset | null = null;
- try {
- cloudAsset = await addStickerAsset({
- name: `词云 ${new Date().toLocaleString('zh-CN')}`,
- type: 'svg',
- source: payload.svg,
- });
- if (payload.mask) {
+ if (payload.mask) {
+ try {
maskAsset = await addStickerAsset({
name: `遮罩 ${payload.mask.name}`,
type: payload.mask.type === 'svg' ? 'svg' : 'image',
source: payload.mask.source,
tint: 'gray',
});
+ } catch (e) {
+ alert(e instanceof Error ? e.message : '遮罩贴纸保存失败');
+ return;
}
- } catch (e) {
- alert(e instanceof Error ? e.message : '贴纸保存失败');
- return;
+ setStickers(await loadStickerLibrary());
}
const nextLayers: CanvasLayer[] = [
@@ -513,7 +669,6 @@ export default function CanvasStudio({
layerIds: maskAsset ? [maskLayerId, cloudLayerId] : [cloudLayerId],
};
- setStickers(await loadStickerLibrary());
setDocumentModel(prev => ({
...prev,
layers: nextLayers,
@@ -554,6 +709,43 @@ export default function CanvasStudio({
setOpenPanels(prev => prev.includes('layers') ? prev : [...prev, 'layers']);
};
+ const startReplaceWordcloudNames = async (element: CanvasElement) => {
+ if (!onReplaceWordcloudNames) {
+ alert('当前环境不支持切换名单');
+ return;
+ }
+ if (element.type !== 'sticker') {
+ alert('请选择词云贴纸');
+ return;
+ }
+
+ try {
+ const pair = resolveWordcloudPair(element, normalizedDocument, stickerById);
+ if (!pair) {
+ alert('未识别到词云组。请选择正常导入/模板中的词云贴纸(同组含底图遮罩)。');
+ return;
+ }
+ const maskAsset = stickerById.get(pair.maskElement.assetId);
+ if (!maskAsset) {
+ alert('底图资源不存在,无法切换名单');
+ return;
+ }
+ const maskSource = await stickerAssetToMaskSource(maskAsset);
+ onReplaceWordcloudNames({
+ mask: maskSource,
+ target: {
+ elementId: pair.cloudElement.id,
+ maskElementId: pair.maskElement.id,
+ maskAssetId: pair.maskElement.assetId,
+ width: pair.cloudElement.width,
+ height: pair.cloudElement.height,
+ },
+ });
+ } catch (error) {
+ alert(error instanceof Error ? error.message : '准备切换名单失败');
+ }
+ };
+
const resetDesign = () => {
setDocumentModel(createDefaultDocument());
setSelectedId(null);
@@ -715,6 +907,7 @@ export default function CanvasStudio({
onDelete={deleteSelected}
onBringForward={bringForward}
onSendBackward={sendBackward}
+ onReplaceWordcloudNames={selectedElement ? () => { void startReplaceWordcloudNames(selectedElement); } : undefined}
/>
);
default:
@@ -1294,6 +1487,7 @@ function PropertiesPanel({
onDelete,
onBringForward,
onSendBackward,
+ onReplaceWordcloudNames,
}: {
element: CanvasElement | null;
elements: CanvasElement[];
@@ -1306,6 +1500,7 @@ function PropertiesPanel({
onDelete: () => void;
onBringForward: () => void;
onSendBackward: () => void;
+ onReplaceWordcloudNames?: () => void;
}) {
const [spacingPercentile, setSpacingPercentile] = useState('3');
const [spacingBusy, setSpacingBusy] = useState(false);
@@ -1315,9 +1510,10 @@ function PropertiesPanel({
const percentile = parseSpacingPercentile(spacingPercentile);
const selectedAsset = element?.type === 'sticker' ? stickerById.get(element.assetId) : undefined;
- const selectedIsWordcloud = !!(element && isWordcloudStickerElement(element, selectedAsset));
+ const selectedIsWordcloud = !!(element && isWordcloudStickerElement(element, selectedAsset, documentModel, stickerById));
+ const selectedCanReplaceNames = !!(element && resolveWordcloudPair(element, documentModel, stickerById));
const wordcloudElements = elements.filter(item => (
- item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId))
+ item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId), documentModel, stickerById)
));
useEffect(() => {
@@ -1461,9 +1657,25 @@ function PropertiesPanel({
onUpdate({ opacity: value })} />
- {selectedIsWordcloud && (
+ {(selectedIsWordcloud || selectedCanReplaceNames) && (
+
词云名单
+
+ {selectedCanReplaceNames
+ ? '保持当前底图与画布位置,仅更换名单重新生成。'
+ : '当前贴纸缺少同组底图,无法切换名单。'}
+
+
+ {selectedIsWordcloud && (
+ <>
词云轮廓线距
)}
+ >
+ )}
)}
@@ -1676,11 +1890,216 @@ function parseSpacingPercentile(raw: string) {
return Math.max(0, Math.min(100, value));
}
-function isWordcloudStickerElement(element: CanvasElement, asset?: StickerAsset) {
- return element.type === 'sticker'
- && asset?.type === 'svg'
- && asset.tint !== 'gray'
- && /^词云(?:\s|$)/.test(asset.name.trim());
+function layerNameOf(element: CanvasElement, documentModel: CanvasDocument) {
+ if (!element.layerId) return '';
+ return (documentModel.layers || []).find(layer => layer.id === element.layerId)?.name?.trim() || '';
+}
+
+function looksLikeMaskName(name: string) {
+ return /遮罩|底图|mask/i.test(name);
+}
+
+function looksLikeWordcloudName(name: string) {
+ return /词云|word\s*cloud/i.test(name);
+}
+
+function isMaskStickerAsset(asset?: StickerAsset) {
+ if (!asset) return false;
+ if (asset.tint === 'gray') return true;
+ return looksLikeMaskName(asset.name);
+}
+
+function isWordcloudStickerElement(
+ element: CanvasElement,
+ asset?: StickerAsset,
+ documentModel?: CanvasDocument,
+ stickerById?: Map
,
+) {
+ if (element.type !== 'sticker' || !asset) return false;
+ if (asset.tint === 'gray') return false;
+
+ const layerName = documentModel ? layerNameOf(element, documentModel) : '';
+ if (looksLikeWordcloudName(asset.name) || looksLikeWordcloudName(layerName)) {
+ return asset.type === 'svg';
+ }
+
+ // 模板/正常导入词云组:同组有底图遮罩时,非遮罩 SVG 贴纸也视为词云
+ if (documentModel && stickerById) {
+ const pair = resolveWordcloudPair(element, documentModel, stickerById);
+ return !!pair && pair.cloudElement.id === element.id;
+ }
+
+ return asset.type === 'svg' && /^词云(?:\s|$)/.test(asset.name.trim());
+}
+
+function stickerSiblings(
+ element: CanvasElement,
+ documentModel: CanvasDocument,
+) {
+ const elements = documentModel.elements.filter(item => item.type === 'sticker' && item.id !== element.id) as Array;
+ if (element.groupId) {
+ const sameGroup = elements.filter(item => item.groupId === element.groupId);
+ if (sameGroup.length > 0) return sameGroup;
+ }
+
+ const layer = (documentModel.layers || []).find(item => item.id === element.layerId);
+ const folderId = layer?.folderId;
+ if (folderId) {
+ const folderLayerIds = new Set(
+ (documentModel.layers || [])
+ .filter(item => item.folderId === folderId)
+ .map(item => item.id),
+ );
+ const sameFolder = elements.filter(item => item.layerId && folderLayerIds.has(item.layerId));
+ if (sameFolder.length > 0) return sameFolder;
+ }
+
+ return elements;
+}
+
+function almostSameBox(a: CanvasElement, b: CanvasElement) {
+ return Math.abs(a.x - b.x) <= 2
+ && Math.abs(a.y - b.y) <= 2
+ && Math.abs(a.width - b.width) <= 2
+ && Math.abs(a.height - b.height) <= 2;
+}
+
+function scoreMaskCandidate(
+ element: CanvasElement,
+ asset: StickerAsset | undefined,
+ documentModel: CanvasDocument,
+ pairHint?: CanvasElement,
+) {
+ if (!asset) return -100;
+ let score = 0;
+ const layerName = layerNameOf(element, documentModel);
+ if (asset.tint === 'gray') score += 5;
+ if (looksLikeMaskName(asset.name)) score += 4;
+ if (looksLikeMaskName(layerName)) score += 5;
+ if (element.opacity > 0 && element.opacity < 0.9) score += 3;
+ if (asset.type === 'image') score += 1;
+ if (pairHint && almostSameBox(element, pairHint)) score += 2;
+ if (pairHint && element.groupId && element.groupId === pairHint.groupId) score += 2;
+ if (looksLikeWordcloudName(asset.name) || looksLikeWordcloudName(layerName)) score -= 6;
+ return score;
+}
+
+function scoreCloudCandidate(
+ element: CanvasElement,
+ asset: StickerAsset | undefined,
+ documentModel: CanvasDocument,
+ pairHint?: CanvasElement,
+) {
+ if (!asset || asset.type !== 'svg') return -100;
+ if (asset.tint === 'gray') return -100;
+ let score = 1;
+ const layerName = layerNameOf(element, documentModel);
+ if (looksLikeWordcloudName(asset.name)) score += 5;
+ if (looksLikeWordcloudName(layerName)) score += 5;
+ if (element.opacity >= 0.9) score += 2;
+ if (pairHint && almostSameBox(element, pairHint)) score += 2;
+ if (pairHint && element.groupId && element.groupId === pairHint.groupId) score += 2;
+ if (looksLikeMaskName(asset.name) || looksLikeMaskName(layerName)) score -= 6;
+ return score;
+}
+
+function resolveWordcloudPair(
+ element: CanvasElement,
+ documentModel: CanvasDocument,
+ stickerById: Map,
+): { cloudElement: CanvasElement & { type: 'sticker' }; maskElement: CanvasElement & { type: 'sticker' } } | null {
+ if (element.type !== 'sticker') return null;
+ const selected = element as CanvasElement & { type: 'sticker' };
+ const selectedAsset = stickerById.get(selected.assetId);
+ const siblings = stickerSiblings(selected, documentModel);
+ if (siblings.length === 0) return null;
+
+ // 优先同位置/同尺寸的贴纸(正常导入与模板词云组通常重叠)
+ const rankedSiblings = [...siblings].sort((a, b) => {
+ const aScore = (almostSameBox(selected, a) ? 10 : 0) + (a.groupId && a.groupId === selected.groupId ? 5 : 0);
+ const bScore = (almostSameBox(selected, b) ? 10 : 0) + (b.groupId && b.groupId === selected.groupId ? 5 : 0);
+ return bScore - aScore;
+ });
+
+ const selectedMaskScore = scoreMaskCandidate(selected, selectedAsset, documentModel);
+ const selectedCloudScore = scoreCloudCandidate(selected, selectedAsset, documentModel);
+
+ // 选中遮罩时,在同组找词云
+ if (selectedMaskScore >= 2 && selectedMaskScore >= selectedCloudScore) {
+ let best: { item: CanvasElement & { type: 'sticker' }; score: number } | null = null;
+ for (const item of rankedSiblings) {
+ if (item.type !== 'sticker') continue;
+ const score = scoreCloudCandidate(item, stickerById.get(item.assetId), documentModel, selected);
+ if (!best || score > best.score) best = { item: item as CanvasElement & { type: 'sticker' }, score };
+ }
+ if (best && best.score >= 1) {
+ return { cloudElement: best.item, maskElement: selected };
+ }
+ }
+
+ // 选中词云(或疑似词云)时,在同组找遮罩
+ let bestMask: { item: CanvasElement & { type: 'sticker' }; score: number } | null = null;
+ for (const item of rankedSiblings) {
+ if (item.type !== 'sticker') continue;
+ const score = scoreMaskCandidate(item, stickerById.get(item.assetId), documentModel, selected);
+ if (!bestMask || score > bestMask.score) bestMask = { item: item as CanvasElement & { type: 'sticker' }, score };
+ }
+ if (bestMask && bestMask.score >= 2 && selectedCloudScore >= 1) {
+ return { cloudElement: selected, maskElement: bestMask.item };
+ }
+
+ // 兜底:同组/同框恰好两个贴纸,按分数分配
+ if (rankedSiblings.length >= 1) {
+ const other = rankedSiblings[0];
+ if (other.type === 'sticker' && (selected.groupId === other.groupId || almostSameBox(selected, other))) {
+ const otherAsset = stickerById.get(other.assetId);
+ const aMask = scoreMaskCandidate(selected, selectedAsset, documentModel, other);
+ const aCloud = scoreCloudCandidate(selected, selectedAsset, documentModel, other);
+ const bMask = scoreMaskCandidate(other, otherAsset, documentModel, selected);
+ const bCloud = scoreCloudCandidate(other, otherAsset, documentModel, selected);
+ if (aCloud >= 1 && bMask >= 2) return { cloudElement: selected, maskElement: other as CanvasElement & { type: 'sticker' } };
+ if (bCloud >= 1 && aMask >= 2) return { cloudElement: other as CanvasElement & { type: 'sticker' }, maskElement: selected };
+ }
+ }
+
+ return null;
+}
+
+async function stickerAssetToMaskSource(asset: StickerAsset): Promise {
+ const url = assetToDataUrl(asset);
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error('读取底图资源失败');
+ }
+
+ const cleanName = asset.name.replace(/^(遮罩|底图)\s*/i, '').trim() || asset.name;
+ const contentType = res.headers.get('content-type') || '';
+ const isSvg = asset.type === 'svg'
+ || contentType.includes('svg')
+ || /\.svg(?:$|\?)/i.test(asset.source || '');
+
+ if (isSvg) {
+ const source = await res.text();
+ return {
+ name: cleanName,
+ type: 'svg',
+ source,
+ };
+ }
+
+ const blob = await res.blob();
+ // 统一转 data URL,避免跨页后相对路径失效
+ const source = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = () => reject(new Error('读取底图失败'));
+ reader.readAsDataURL(blob);
+ });
+ return {
+ name: cleanName,
+ type: 'image',
+ source,
+ };
}
function CanvasElementView({
diff --git a/frontend/src/pages/TestWorkbench.tsx b/frontend/src/pages/TestWorkbench.tsx
index 4eac599..14a4f7e 100644
--- a/frontend/src/pages/TestWorkbench.tsx
+++ b/frontend/src/pages/TestWorkbench.tsx
@@ -10,6 +10,7 @@ import {
NameLocation,
Font,
WordcloudMaskSource,
+ WordcloudReplaceSession,
WordcloudStickerPayload,
} from '../types';
import ImportPanel from '../components/ImportPanel';
@@ -36,13 +37,70 @@ import {
} from '../components/Icons';
const DEFAULT_PARAMS: JobParams = {
+ // 导入/基础(seed 前端预置 42;其余对齐 backend/core/config.py)
seed: 42,
- dataColIndex: 1, // 0-based,默认第2列(B列)
- headerRow: 1, // 1-based,默认第1行为表头,数据从第2行开始
- weightColIndex: null, // 不指定权重列,由 ENABLE_STROKE_WEIGHTS 决定是否启用笔画权重
- fontColor: '#000000', // 字体颜色,默认黑色
- nRepetitions: 1, // 词语重复填充次数,词语较少时可增大以提升填充率
- strokeWeights: true, // 根据笔画复杂度调整权重
+ dataColIndex: 1,
+ headerRow: 1,
+ weightColIndex: null,
+ fontColor: '#000000',
+ nRepetitions: 1,
+ strokeWeights: true,
+
+ // 字号与填充
+ sizeRatio: 2.0,
+ packingEfficiency: 0.85,
+ targetFillRatio: 0.0,
+ userMinFontSize: null,
+ userMaxFontSize: null,
+ minFontFloor: 2,
+ minReadableHeightPx: 25,
+ workScale: 0.25,
+
+ // 字号搜索
+ fontScaleMin: 0.5,
+ fontScaleMax: 1.2,
+ scaleSearchSteps: 7,
+ scaleSearchRounds: 5,
+ scaleDecay: 0.85,
+ scaleFloor: 0.25,
+ autoShrinkRounds: 4,
+ requireAllWords: true,
+
+ // 权重混合
+ logWeightRatio: 0.72,
+ rankWeightRatio: 0.28,
+
+ // 大字限制
+ enableSmartLargeFontReduction: true,
+ limitLargeFonts: true,
+ largeFontLimitRatio: 0.2,
+ largeFontThresholdRatio: 0.8,
+ largeFontCapRatio: 0.6,
+
+ // 分层采样
+ enableStratifiedSampling: true,
+ stratifiedBands: 3,
+
+ // 填充重试
+ minAcceptFillRatio: 0.75,
+ fillRetryRelaxLargeCap: true,
+ fillRetryMaxRounds: 3,
+ fillRetryMaxScale: 1.5,
+ growFontOnLowFill: false,
+ growFontStep: 1.05,
+
+ // 画布
+ autoExpandCanvas: true,
+ expandForSpiral: true,
+ expandRatio: 2.5,
+ fillOn: 'BLACK',
+ canvasRetryMaxRounds: 1,
+ canvasRetryGrowth: 1.12,
+ maxAttempts: 5,
+
+ // 布局可复现
+ layoutOrderMode: 'SORTED',
+ layoutSeed: null,
};
type NavItem = {
@@ -76,6 +134,8 @@ interface TestWorkbenchProps {
onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
onOpenHelp?: () => void;
+ replaceSession?: WordcloudReplaceSession | null;
+ onConsumeReplaceSession?: () => void;
}
export default function TestWorkbench({
@@ -85,6 +145,8 @@ export default function TestWorkbench({
onOpenCanvas,
onImportWordcloudSticker,
onOpenHelp,
+ replaceSession,
+ onConsumeReplaceSession,
}: TestWorkbenchProps) {
const [maskFile, setMaskFile] = useState(null);
const [maskSubmitFile, setMaskSubmitFile] = useState(null);
@@ -97,6 +159,10 @@ export default function TestWorkbench({
const [progress, setProgress] = useState(null);
const [isGenerating, setIsGenerating] = useState(false);
const [openPanels, setOpenPanels] = useState(['import', 'advanced']);
+ const [activeReplaceSession, setActiveReplaceSession] = useState(null);
+ const [replaceMaskReady, setReplaceMaskReady] = useState(false);
+ const [replaceMaskError, setReplaceMaskError] = useState('');
+ const appliedReplaceSessionKeyRef = useRef(null);
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
const [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState(null);
@@ -104,6 +170,79 @@ export default function TestWorkbench({
const [selectedFontId, setSelectedFontId] = useState('__default__');
const sseRef = useRef(null);
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
+ const focusWorkbenchPanel = floatingPanels.focusPanel;
+
+ // 从画布“切换名单”进入:锁定底图,清空旧任务,等待用户只导入新名单
+ useEffect(() => {
+ if (!replaceSession) return;
+
+ const sessionKey = [
+ replaceSession.target.elementId,
+ replaceSession.target.maskElementId || '',
+ replaceSession.mask.name,
+ replaceSession.mask.type,
+ String(replaceSession.mask.source.length),
+ ].join('|');
+
+ let cancelled = false;
+ let finished = false;
+ const session = replaceSession;
+
+ // 同步先把会话和底图源挂上,导入区立刻能预览原底图
+ setActiveReplaceSession(session);
+ setMaskSource(session.mask);
+ setReplaceMaskReady(false);
+ setReplaceMaskError('');
+ setNamesFile(null);
+ setNameEntries([]);
+ setJobId(null);
+ setJobResult(null);
+ setProgress(null);
+ setIsGenerating(false);
+ setOpenPanels(prev => {
+ const next = new Set(prev);
+ next.add('import');
+ next.add('export');
+ return Array.from(next);
+ });
+ focusWorkbenchPanel('import');
+
+ (async () => {
+ try {
+ const { maskFile: nextMaskFile, maskSubmitFile: nextMaskSubmitFile } =
+ await maskSourceToFiles(session.mask);
+ if (cancelled) return;
+ setMaskFile(nextMaskFile);
+ setMaskSubmitFile(nextMaskSubmitFile);
+ setMaskSource(session.mask);
+ setReplaceMaskReady(true);
+ setReplaceMaskError('');
+ appliedReplaceSessionKeyRef.current = sessionKey;
+ finished = true;
+ onConsumeReplaceSession?.();
+ } catch (error) {
+ console.error(error);
+ if (cancelled) return;
+ const message = error instanceof Error ? error.message : '载入底图失败';
+ setReplaceMaskReady(false);
+ setReplaceMaskError(message);
+ setMaskFile(null);
+ setMaskSubmitFile(null);
+ // 保留 maskSource 供预览;生成前仍会拦截
+ finished = true;
+ onConsumeReplaceSession?.();
+ alert(message);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ // StrictMode 首次 effect 会被清理;若尚未完成则允许重跑
+ if (!finished && appliedReplaceSessionKeyRef.current !== sessionKey) {
+ // no-op: key 未标记完成,下一次 mount 会重新应用
+ }
+ };
+ }, [replaceSession, onConsumeReplaceSession, focusWorkbenchPanel]);
const workspaceRef = useRef(null);
const workspaceSizeRef = useRef({ width: 1200, height: 700 });
const [workspaceSize, setWorkspaceSize] = useState({ width: 1200, height: 700 });
@@ -298,6 +437,10 @@ export default function TestWorkbench({
// ─── 生成任务提交 ─────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (isGenerating) return;
+ if (activeReplaceSession && (!replaceMaskReady || !maskSubmitFile)) {
+ alert(replaceMaskError || '原底图尚未载入完成,请稍候再生成');
+ return;
+ }
if (!namesFile) {
alert('请先导入名单(.xlsx)');
return;
@@ -319,23 +462,64 @@ export default function TestWorkbench({
const paramsObj: Record = {
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
DATA_COL_INDEX: usingEditedEntries ? 1 : params.dataColIndex,
+ // 高级参数:始终传当前值,默认与 backend/core/config.py 对齐
+ FONT_COLOR: params.fontColor || '#000000',
+ N_REPETITIONS: params.nRepetitions,
+ ENABLE_STROKE_WEIGHTS: params.strokeWeights,
+ SIZE_RATIO: params.sizeRatio,
+ PACKING_EFFICIENCY: params.packingEfficiency,
+ TARGET_FILL_RATIO: params.targetFillRatio,
+ MIN_FONT_FLOOR: params.minFontFloor,
+ MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
+ WORK_SCALE: params.workScale,
+ FONT_SCALE_MIN: params.fontScaleMin,
+ FONT_SCALE_MAX: params.fontScaleMax,
+ SCALE_SEARCH_STEPS: params.scaleSearchSteps,
+ SCALE_SEARCH_ROUNDS: params.scaleSearchRounds,
+ SCALE_DECAY: params.scaleDecay,
+ SCALE_FLOOR: params.scaleFloor,
+ AUTO_SHRINK_ROUNDS: params.autoShrinkRounds,
+ REQUIRE_ALL_WORDS: params.requireAllWords,
+ LOG_WEIGHT_RATIO: params.logWeightRatio,
+ RANK_WEIGHT_RATIO: params.rankWeightRatio,
+ ENABLE_SMART_LARGE_FONT_REDUCTION: params.enableSmartLargeFontReduction,
+ LIMIT_LARGE_FONTS: params.limitLargeFonts,
+ LARGE_FONT_LIMIT_RATIO: params.largeFontLimitRatio,
+ LARGE_FONT_THRESHOLD_RATIO: params.largeFontThresholdRatio,
+ LARGE_FONT_CAP_RATIO: params.largeFontCapRatio,
+ ENABLE_STRATIFIED_SAMPLING: params.enableStratifiedSampling,
+ STRATIFIED_BANDS: params.stratifiedBands,
+ MIN_ACCEPT_FILL_RATIO: params.minAcceptFillRatio,
+ FILL_RETRY_RELAX_LARGE_CAP: params.fillRetryRelaxLargeCap,
+ FILL_RETRY_MAX_ROUNDS: params.fillRetryMaxRounds,
+ FILL_RETRY_MAX_SCALE: params.fillRetryMaxScale,
+ GROW_FONT_ON_LOW_FILL: params.growFontOnLowFill,
+ GROW_FONT_STEP: params.growFontStep,
+ AUTO_EXPAND_CANVAS: params.autoExpandCanvas,
+ EXPAND_FOR_SPIRAL: params.expandForSpiral,
+ EXPAND_RATIO: params.expandRatio,
+ FILL_ON: params.fillOn,
+ CANVAS_RETRY_MAX_ROUNDS: params.canvasRetryMaxRounds,
+ CANVAS_RETRY_GROWTH: params.canvasRetryGrowth,
+ MAX_ATTEMPTS: params.maxAttempts,
+ LAYOUT_ORDER_MODE: params.layoutOrderMode,
};
if (params.seed !== null) {
- paramsObj.SEED = params.seed; // 全局随机种子
+ paramsObj.SEED = params.seed;
}
if (usingEditedEntries) {
paramsObj.WEIGHT_COL_INDEX = 2;
} else if (params.weightColIndex !== null) {
- paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列
+ paramsObj.WEIGHT_COL_INDEX = params.weightColIndex;
}
- if (params.fontColor) {
- paramsObj.FONT_COLOR = params.fontColor;
+ if (params.userMinFontSize !== null) {
+ paramsObj.USER_MIN_FONT_SIZE = params.userMinFontSize;
}
- if (params.nRepetitions > 1) {
- paramsObj.N_REPETITIONS = params.nRepetitions; // 词语重复填充次数
+ if (params.userMaxFontSize !== null) {
+ paramsObj.USER_MAX_FONT_SIZE = params.userMaxFontSize;
}
- if (!params.strokeWeights) {
- paramsObj.ENABLE_STROKE_WEIGHTS = false; // 关闭笔画权重
+ if (params.layoutSeed !== null) {
+ paramsObj.LAYOUT_SEED = params.layoutSeed;
}
// ── FormData(字段名严格按 README 6.5)────────────────────────────
@@ -563,6 +747,21 @@ export default function TestWorkbench({
params={params}
fonts={fonts}
selectedFontId={selectedFontId}
+ maskLocked={!!activeReplaceSession}
+ maskPreviewUrl={activeReplaceSession ? (
+ activeReplaceSession.mask.type === 'image'
+ ? activeReplaceSession.mask.source
+ : `data:image/svg+xml;charset=utf-8,${encodeURIComponent(activeReplaceSession.mask.source)}`
+ ) : null}
+ maskStatusText={
+ activeReplaceSession
+ ? (replaceMaskError
+ ? `底图载入失败:${replaceMaskError}`
+ : replaceMaskReady
+ ? `已载入原底图:${maskFile?.name || activeReplaceSession.mask.name}`
+ : '正在载入原底图…')
+ : undefined
+ }
onMaskChange={handleMaskChange}
onNamesChange={handleNamesChange}
onParamsChange={handleParamsChange}
@@ -579,7 +778,16 @@ export default function TestWorkbench({
imageUrl={jobResult?.image_url}
onOpenCanvas={onOpenCanvas}
maskSource={maskSource}
- onImportWordcloudSticker={onImportWordcloudSticker}
+ replaceTarget={activeReplaceSession?.target}
+ onImportWordcloudSticker={(payload) => {
+ onImportWordcloudSticker?.(payload);
+ if (payload.replaceTarget) {
+ setActiveReplaceSession(null);
+ setReplaceMaskReady(false);
+ setReplaceMaskError('');
+ appliedReplaceSessionKeyRef.current = null;
+ }
+ }}
/>
)}
{panel === 'edit' && (
@@ -601,6 +809,7 @@ export default function TestWorkbench({
embedded
params={params}
onParamsChange={handleParamsChange}
+ onResetDefaults={() => setParams(DEFAULT_PARAMS)}
/>
)}
@@ -614,6 +823,9 @@ export default function TestWorkbench({
词云工具
+ {activeReplaceSession && (
+
同底图换名单
+ )}
{NAV_ITEMS.map(item => (
@@ -705,6 +917,54 @@ export default function TestWorkbench({
);
}
+async function maskSourceToFiles(mask: WordcloudMaskSource) {
+ const baseName = (mask.name || 'mask').replace(/[\\/:*?"<>|]/g, '-').trim() || 'mask';
+
+ if (mask.type === 'svg') {
+ const svgText = mask.source.startsWith('data:image/svg+xml')
+ ? await dataUrlToText(mask.source)
+ : mask.source;
+ const maskFile = new File([svgText], `${baseName}.svg`, { type: 'image/svg+xml' });
+ const maskSubmitFile = await svgToPngFile(svgText, maskFile.name);
+ return { maskFile, maskSubmitFile };
+ }
+
+ // image: data URL / blob URL / http(s) / relative API path
+ let blob: Blob;
+ if (mask.source.startsWith('data:')) {
+ blob = dataUrlToBlob(mask.source);
+ } else {
+ const response = await fetch(mask.source);
+ if (!response.ok) {
+ throw new Error(`读取底图失败(HTTP ${response.status})`);
+ }
+ blob = await response.blob();
+ }
+
+ const isJpeg = blob.type.includes('jpeg') || blob.type.includes('jpg') || /\.jpe?g(?:$|\?)/i.test(mask.source);
+ const ext = isJpeg ? 'jpg' : 'png';
+ const mime = blob.type || (isJpeg ? 'image/jpeg' : 'image/png');
+ const maskFile = new File([blob], `${baseName}.${ext}`, { type: mime });
+ return { maskFile, maskSubmitFile: maskFile };
+}
+
+function dataUrlToBlob(dataUrl: string): Blob {
+ const [header, payload = ''] = dataUrl.split(',', 2);
+ const mime = header.match(/data:([^;]+)/)?.[1] || 'application/octet-stream';
+ if (header.includes(';base64')) {
+ const binary = atob(payload);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return new Blob([bytes], { type: mime });
+ }
+ return new Blob([decodeURIComponent(payload)], { type: mime });
+}
+
+async function dataUrlToText(dataUrl: string): Promise
{
+ const blob = dataUrlToBlob(dataUrl);
+ return blob.text();
+}
+
function fileToDataUrl(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 84e495c..48e4c3b 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -131,6 +131,20 @@ body {
letter-spacing: 0;
}
+.navbar-mode-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+ color: var(--accent);
+ border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
.navbar-actions {
display: flex;
flex: 1;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 04d12d6..faa1b8e 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -4,19 +4,76 @@ export interface NameEntry {
weight: number;
}
-// 前端持有的参数状态
+// 前端持有的参数状态(字段默认值与 backend/core/config.py 对齐)
// dataColIndex: 0-based,对应后端 DATA_COL_INDEX
// headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用)
-// seed: 对应后端 SEED
+// seed: 对应后端 SEED(前端预置 42 便于复现;后端默认 null)
// weightColIndex: 0-based,对应后端 WEIGHT_COL_INDEX(null 表示不传)
export interface JobParams {
+ // 导入/基础
seed: number | null;
- dataColIndex: number; // 0-based,名字列索引
- headerRow: number; // 1-based,表头行号;数据从下一行开始
- weightColIndex: number | null; // 0-based,权重列索引;null=不传
- fontColor: string; // 字体颜色,默认 #000000
- nRepetitions: number; // 词语重复填充次数,默认 1;词语较少时可增大以提升填充率
- strokeWeights: boolean; // 是否根据笔画复杂度调整权重,默认 true
+ dataColIndex: number;
+ headerRow: number;
+ weightColIndex: number | null;
+ fontColor: string;
+ nRepetitions: number;
+ strokeWeights: boolean;
+
+ // 字号与填充
+ sizeRatio: number;
+ packingEfficiency: number;
+ targetFillRatio: number;
+ userMinFontSize: number | null;
+ userMaxFontSize: number | null;
+ minFontFloor: number;
+ minReadableHeightPx: number;
+ workScale: number;
+
+ // 字号搜索
+ fontScaleMin: number;
+ fontScaleMax: number;
+ scaleSearchSteps: number;
+ scaleSearchRounds: number;
+ scaleDecay: number;
+ scaleFloor: number;
+ autoShrinkRounds: number;
+ requireAllWords: boolean;
+
+ // 权重混合
+ logWeightRatio: number;
+ rankWeightRatio: number;
+
+ // 大字限制
+ enableSmartLargeFontReduction: boolean;
+ limitLargeFonts: boolean;
+ largeFontLimitRatio: number;
+ largeFontThresholdRatio: number;
+ largeFontCapRatio: number;
+
+ // 分层采样
+ enableStratifiedSampling: boolean;
+ stratifiedBands: number;
+
+ // 填充重试
+ minAcceptFillRatio: number;
+ fillRetryRelaxLargeCap: boolean;
+ fillRetryMaxRounds: number;
+ fillRetryMaxScale: number;
+ growFontOnLowFill: boolean;
+ growFontStep: number;
+
+ // 画布
+ autoExpandCanvas: boolean;
+ expandForSpiral: boolean;
+ expandRatio: number;
+ fillOn: 'BLACK' | 'WHITE';
+ canvasRetryMaxRounds: number;
+ canvasRetryGrowth: number;
+ maxAttempts: number;
+
+ // 布局可复现
+ layoutOrderMode: 'SORTED' | 'INTERLEAVED_RANDOM';
+ layoutSeed: number | null;
}
// 后端 /api/jobs/{id}/result 返回结构(根据 README 6.5 + 输出说明推断)
@@ -209,9 +266,30 @@ export interface WordcloudMaskSource {
source: string;
}
+export interface WordcloudReplaceTarget {
+ /** 要替换的词云元素 id(绝不是遮罩) */
+ elementId: string;
+ /** 同组原图遮罩元素 id,替换时必须原样保留 */
+ maskElementId?: string;
+ /** 原图遮罩当前 assetId,用于完整性校验 */
+ maskAssetId?: string;
+ width: number;
+ height: number;
+}
+
+export interface WordcloudReplaceSession {
+ mask: WordcloudMaskSource;
+ target: WordcloudReplaceTarget;
+}
+
export interface WordcloudStickerPayload {
- svg: string;
+ /** 新增导入时使用的 SVG 文本;快路径替换可省略 */
+ svg?: string;
mask?: WordcloudMaskSource;
width?: number;
height?: number;
+ /** 已入库贴纸 ID:画布侧只换 assetId,不再二次上传 */
+ assetId?: string;
+ /** 存在时表示替换画布上已有词云,而不是新增贴纸 */
+ replaceTarget?: WordcloudReplaceTarget;
}
diff --git a/install-ubuntu.sh b/install-ubuntu.sh
new file mode 100755
index 0000000..ca365e8
--- /dev/null
+++ b/install-ubuntu.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+# WordCloud — Ubuntu 一键安装 / 启动脚本
+# 用法:
+# chmod +x install-ubuntu.sh
+# ./install-ubuntu.sh
+#
+# 可选环境变量:
+# SKIP_DOCKER_INSTALL=1 跳过 Docker 安装检测
+# NO_BUILD=1 不强制 --build(沿用已有镜像)
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$ROOT_DIR"
+
+log() { echo "[INFO] $*"; }
+ok() { echo "[OK] $*"; }
+warn() { echo "[WARN] $*"; }
+err() { echo "[ERROR] $*" >&2; }
+
+need_cmd() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+compose() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ elif need_cmd docker-compose; then
+ docker-compose "$@"
+ else
+ err "未找到 docker compose / docker-compose"
+ exit 1
+ fi
+}
+
+require_root_for_docker_install() {
+ if [[ "${EUID}" -ne 0 ]]; then
+ if need_cmd sudo; then
+ SUDO="sudo"
+ else
+ err "需要 root 权限安装 Docker,请用 root 运行或安装 sudo"
+ exit 1
+ fi
+ else
+ SUDO=""
+ fi
+}
+
+install_docker_ubuntu() {
+ require_root_for_docker_install
+ log "检测到未安装 Docker,开始在 Ubuntu 上安装..."
+
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y ca-certificates curl gnupg lsb-release
+
+ if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
+ $SUDO install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+ $SUDO chmod a+r /etc/apt/keyrings/docker.gpg
+ fi
+
+ local codename
+ codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$UBUNTU_CODENAME}")"
+ echo \
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable" \
+ | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
+
+ $SUDO apt-get update -y
+ $SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+
+ $SUDO systemctl enable --now docker
+ ok "Docker 安装完成"
+}
+
+ensure_docker() {
+ if need_cmd docker && docker info >/dev/null 2>&1; then
+ ok "Docker 可用"
+ return
+ fi
+
+ if [[ "${SKIP_DOCKER_INSTALL:-0}" == "1" ]]; then
+ err "Docker 不可用,且设置了 SKIP_DOCKER_INSTALL=1"
+ exit 1
+ fi
+
+ if [[ -f /etc/os-release ]]; then
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ if [[ "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *"ubuntu"* || "${ID_LIKE:-}" == *"debian"* ]]; then
+ install_docker_ubuntu
+ else
+ err "当前系统不是 Ubuntu/Debian,请先手动安装 Docker 后再运行本脚本"
+ exit 1
+ fi
+ else
+ err "无法识别操作系统,请先手动安装 Docker"
+ exit 1
+ fi
+
+ if ! docker info >/dev/null 2>&1; then
+ warn "当前用户可能无 Docker 权限,尝试使用 sudo..."
+ if need_cmd sudo && sudo docker info >/dev/null 2>&1; then
+ DOCKER_PREFIX="sudo"
+ else
+ err "Docker 已安装但当前用户无权限。请执行: sudo usermod -aG docker \$USER 后重新登录"
+ exit 1
+ fi
+ fi
+}
+
+DOCKER_PREFIX=""
+run_compose() {
+ if [[ -n "${DOCKER_PREFIX}" ]]; then
+ $DOCKER_PREFIX docker compose "$@" 2>/dev/null || $DOCKER_PREFIX docker-compose "$@"
+ else
+ compose "$@"
+ fi
+}
+
+main() {
+ echo "========================================="
+ echo " WordCloud Ubuntu 一键部署"
+ echo " 目录: $ROOT_DIR"
+ echo "========================================="
+
+ if [[ ! -f "$ROOT_DIR/docker-compose.yml" ]]; then
+ err "未找到 docker-compose.yml,请在 release 包根目录执行本脚本"
+ exit 1
+ fi
+
+ ensure_docker
+
+ log "构建并启动服务(首次会编译 C++ 扩展与前端,可能需要几分钟)..."
+ if [[ "${NO_BUILD:-0}" == "1" ]]; then
+ run_compose up -d
+ else
+ run_compose up -d --build
+ fi
+
+ ok "服务已启动"
+ echo ""
+ echo "访问地址:"
+ echo " 前端: http://<服务器IP>:3000"
+ echo " 后端文档: http://<服务器IP>:8000/docs"
+ echo " 健康检查: http://<服务器IP>:8000/api/health"
+ echo ""
+ echo "常用命令:"
+ echo " make logs # 查看日志"
+ echo " make down # 停止服务"
+ echo " make restart # 重启服务"
+ echo " ./install-ubuntu.sh # 再次执行可重建并启动"
+}
+
+main "$@"
diff --git a/scripts/pack-release.sh b/scripts/pack-release.sh
new file mode 100755
index 0000000..6c345ac
--- /dev/null
+++ b/scripts/pack-release.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+# 重新生成 release/ 与 release.tar.gz,供 Ubuntu Docker Compose 一键部署。
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+RELEASE_DIR="$ROOT_DIR/release"
+STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wordcloud-release.XXXXXX")"
+ARCHIVE="$ROOT_DIR/release.tar.gz"
+PKG_NAME="wordcloud"
+
+cleanup() {
+ rm -rf "$STAGING_DIR"
+}
+trap cleanup EXIT
+
+log() { echo "[release] $*"; }
+
+log "staging at $STAGING_DIR"
+mkdir -p "$STAGING_DIR/$PKG_NAME"
+
+# ── root deploy files ───────────────────────────────────────
+cp -a "$ROOT_DIR/docker-compose.yml" "$STAGING_DIR/$PKG_NAME/"
+cp -a "$ROOT_DIR/Makefile" "$STAGING_DIR/$PKG_NAME/"
+cp -a "$ROOT_DIR/DOCKER.md" "$STAGING_DIR/$PKG_NAME/"
+cp -a "$ROOT_DIR/install-ubuntu.sh" "$STAGING_DIR/$PKG_NAME/"
+cp -a "$ROOT_DIR/README.md" "$STAGING_DIR/$PKG_NAME/" 2>/dev/null || true
+cp -a "$ROOT_DIR/CHANGELOG.md" "$STAGING_DIR/$PKG_NAME/" 2>/dev/null || true
+
+cat > "$STAGING_DIR/$PKG_NAME/.gitignore" <<'GI'
+.DS_Store
+.idea/
+.claude/
+__pycache__/
+*.py[cod]
+.venv/
+node_modules/
+frontend/dist/
+backend/service_workspace/
+backend/service_assets/
+backend/service_projects/
+backend/service_fonts/
+backend/EfficientWordCloud/build/
+backend/EfficientWordCloud/dist/
+backend/EfficientWordCloud/**/*.so
+*.log
+*.db
+GI
+
+# ── backend (source only) ───────────────────────────────────
+mkdir -p "$STAGING_DIR/$PKG_NAME/backend"
+rsync -a \
+ --exclude '.venv/' \
+ --exclude '.runtime/' \
+ --exclude '.matplotlib/' \
+ --exclude '.idea/' \
+ --exclude '.claude/' \
+ --exclude '.DS_Store' \
+ --exclude '__pycache__/' \
+ --exclude '*.py[cod]' \
+ --exclude '*.so' \
+ --exclude '*.o' \
+ --exclude '*.egg-info/' \
+ --exclude 'service_workspace/' \
+ --exclude 'service_assets/' \
+ --exclude 'service_projects/' \
+ --exclude 'service_fonts/' \
+ --exclude 'service_design_templates/' \
+ --exclude 'output/' \
+ --exclude 'output_cli_test/' \
+ --exclude 'ref/' \
+ --exclude 'docs/' \
+ --exclude 'EfficientWordCloud/build/' \
+ --exclude 'EfficientWordCloud/dist/' \
+ --exclude 'EfficientWordCloud/efficient_wordcloud.egg-info/' \
+ --exclude 'EfficientWordCloud/**/__pycache__/' \
+ --exclude 'EfficientWordCloud/**/*.so' \
+ --exclude 'EfficientWordCloud/**/*.o' \
+ "$ROOT_DIR/backend/" "$STAGING_DIR/$PKG_NAME/backend/"
+
+# design template seeds (json only)
+if [[ -d "$ROOT_DIR/backend/service_design_templates" ]]; then
+ mkdir -p "$STAGING_DIR/$PKG_NAME/backend/service_design_templates"
+ rsync -a \
+ --include '*/' \
+ --include 'template.json' \
+ --exclude '*' \
+ "$ROOT_DIR/backend/service_design_templates/" \
+ "$STAGING_DIR/$PKG_NAME/backend/service_design_templates/" || true
+fi
+
+# ensure runtime dirs exist as empty placeholders (volumes will mount over them)
+mkdir -p \
+ "$STAGING_DIR/$PKG_NAME/backend/service_workspace" \
+ "$STAGING_DIR/$PKG_NAME/backend/service_assets" \
+ "$STAGING_DIR/$PKG_NAME/backend/service_projects" \
+ "$STAGING_DIR/$PKG_NAME/backend/service_fonts"
+# keep design templates if seeded; otherwise empty
+mkdir -p "$STAGING_DIR/$PKG_NAME/backend/service_design_templates"
+
+# ── frontend ────────────────────────────────────────────────
+mkdir -p "$STAGING_DIR/$PKG_NAME/frontend"
+rsync -a \
+ --exclude 'node_modules/' \
+ --exclude 'dist/' \
+ --exclude '.DS_Store' \
+ --exclude '.claude/' \
+ --exclude '.idea/' \
+ "$ROOT_DIR/frontend/" "$STAGING_DIR/$PKG_NAME/frontend/"
+
+# ── docs ────────────────────────────────────────────────────
+if [[ -d "$ROOT_DIR/docs" ]]; then
+ mkdir -p "$STAGING_DIR/$PKG_NAME/docs"
+ rsync -a \
+ --exclude '.DS_Store' \
+ "$ROOT_DIR/docs/" "$STAGING_DIR/$PKG_NAME/docs/" || true
+fi
+
+chmod +x "$STAGING_DIR/$PKG_NAME/install-ubuntu.sh"
+chmod +x "$STAGING_DIR/$PKG_NAME/backend/start-dev.sh" 2>/dev/null || true
+
+# sanity: refuse if venv/workspace leaked in
+if [[ -e "$STAGING_DIR/$PKG_NAME/backend/.venv" ]]; then
+ echo "[release] ERROR: .venv leaked into staging" >&2
+ exit 1
+fi
+if find "$STAGING_DIR/$PKG_NAME/backend/service_workspace" -mindepth 1 | read -r _; then
+ echo "[release] ERROR: service_workspace not empty in staging" >&2
+ exit 1
+fi
+
+# ── overwrite release/ ──────────────────────────────────────
+log "overwrite $RELEASE_DIR"
+rm -rf "$RELEASE_DIR"
+mkdir -p "$RELEASE_DIR"
+rsync -a "$STAGING_DIR/$PKG_NAME/" "$RELEASE_DIR/"
+
+# ── overwrite release.tar.gz ────────────────────────────────
+log "write $ARCHIVE"
+rm -f "$ARCHIVE"
+# write to temp then atomic move to avoid truncated archive on interrupt
+TMP_ARCHIVE="$(mktemp "${TMPDIR:-/tmp}/wordcloud-release-tar.XXXXXX").tar.gz"
+tar -C "$STAGING_DIR" -czf "$TMP_ARCHIVE" "$PKG_NAME"
+mv -f "$TMP_ARCHIVE" "$ARCHIVE"
+
+# verify archive
+tar -tzf "$ARCHIVE" >/dev/null
+python3 - <