Add advanced canvas editing and Ubuntu deployment

This commit is contained in:
2026-07-14 20:32:42 +08:00
parent f8a907e7c5
commit bf2b138007
19 changed files with 2026 additions and 255 deletions
+13 -2
View File
@@ -4,7 +4,7 @@ import CanvasStudio from './pages/CanvasStudio';
import HelpPage from './pages/HelpPage';
import TemplateHome from './pages/TemplateHome';
import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudStickerPayload } from './types';
import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help';
@@ -23,6 +23,7 @@ export default function App() {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
const [pendingReplaceSession, setPendingReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const openHelp = () => setPage('help');
@@ -53,8 +54,11 @@ export default function App() {
onThemeModeChange={setThemeMode}
onOpenCanvas={() => setPage('canvas')}
onOpenHelp={openHelp}
replaceSession={pendingReplaceSession}
onConsumeReplaceSession={() => setPendingReplaceSession(null)}
onImportWordcloudSticker={(payload) => {
setPendingWordcloudSticker(payload);
setPendingReplaceSession(null);
setPage('canvas');
}}
/>
@@ -68,8 +72,15 @@ export default function App() {
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenWordcloud={() => {
setPendingReplaceSession(null);
setPage('wordcloud');
}}
onOpenHelp={openHelp}
onReplaceWordcloudNames={(session) => {
setPendingReplaceSession(session);
setPage('wordcloud');
}}
initialDocument={initialDocument}
onConsumeInitialDocument={() => setInitialDocument(null)}
pendingWordcloudSticker={pendingWordcloudSticker}
+474 -44
View File
@@ -1,12 +1,110 @@
import type { ReactNode } from 'react';
import { JobParams } from '../types';
interface AdvancedPanelProps {
params: JobParams;
onParamsChange: (partial: Partial<JobParams>) => void;
embedded?: boolean;
onResetDefaults?: () => void;
}
export default function AdvancedPanel({ params, onParamsChange, embedded = false }: AdvancedPanelProps) {
function parseOptionalInt(raw: string): number | null {
const v = raw.trim();
if (v === '') return null;
const n = parseInt(v, 10);
return Number.isNaN(n) ? null : n;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<div
className="form-label"
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-primary)',
marginTop: 4,
marginBottom: 2,
}}
>
{children}
</div>
);
}
function Hint({ children }: { children: ReactNode }) {
return <span className="text-xs text-muted">{children}</span>;
}
function NumberField({
label,
value,
onChange,
min,
max,
step,
placeholder,
}: {
label: string;
value: number | null;
onChange: (v: number | null) => void;
min?: number;
max?: number;
step?: number | string;
placeholder?: string;
}) {
return (
<div className="form-group">
<label className="form-label">{label}</label>
<input
className="form-input"
type="number"
min={min}
max={max}
step={step}
placeholder={placeholder}
value={value ?? ''}
onChange={e => {
if (e.target.value.trim() === '') {
onChange(null);
return;
}
const n = Number(e.target.value);
onChange(Number.isNaN(n) ? null : n);
}}
/>
</div>
);
}
function BoolField({
label,
checked,
onChange,
hint,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
hint?: string;
}) {
return (
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
{label}
</label>
{hint ? <Hint>{hint}</Hint> : null}
</div>
);
}
export default function AdvancedPanel({
params,
onParamsChange,
embedded = false,
onResetDefaults,
}: AdvancedPanelProps) {
return (
<>
{!embedded && (
@@ -15,8 +113,15 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
</div>
)}
<div className="panel-body">
{onResetDefaults && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
<button type="button" className="btn btn-secondary btn-sm" onClick={onResetDefaults}>
</button>
</div>
)}
{/* SEED */}
<SectionTitle></SectionTitle>
<div className="form-group">
<label className="form-label">SEED</label>
<input
@@ -25,17 +130,11 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
min={0}
value={params.seed ?? ''}
placeholder="留空=不固定种子"
onChange={e => {
const v = e.target.value.trim();
onParamsChange({ seed: v === '' ? null : parseInt(v) });
}}
onChange={e => onParamsChange({ seed: parseOptionalInt(e.target.value) })}
/>
<span className="text-xs text-muted"></span>
<Hint> 42</Hint>
</div>
<div className="section-divider" />
{/* FONT_COLOR */}
<div className="form-group">
<label className="form-label"></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -54,55 +153,386 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
style={{ flex: 1 }}
/>
</div>
<span className="text-xs text-muted">使</span>
<Hint></Hint>
</div>
<div className="section-divider" />
{/* N_REPETITIONS */}
<div className="form-group">
<label className="form-label"></label>
<input
className="form-input"
type="number"
<div className="form-row">
<NumberField
label="词语重复次数"
value={params.nRepetitions}
min={1}
max={20}
value={params.nRepetitions}
onChange={e => {
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))) })}
/>
<span className="text-xs text-muted">
10 5~10使 1
</span>
<div className="form-group">
<label className="form-label"> FILL_ON</label>
<select
className="form-input"
value={params.fillOn}
onChange={e => onParamsChange({ fillOn: e.target.value as JobParams['fillOn'] })}
>
<option value="BLACK">BLACK</option>
<option value="WHITE">WHITE</option>
</select>
</div>
</div>
<Hint> 5~10</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
<NumberField
label="最大/最小字号比 SIZE_RATIO"
value={params.sizeRatio}
min={1}
max={20}
step={0.1}
onChange={v => onParamsChange({ sizeRatio: v ?? 2 })}
/>
<NumberField
label="打包效率 PACKING_EFFICIENCY"
value={params.packingEfficiency}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.85 })}
/>
</div>
<Hint>SIZE_RATIO 2.0</Hint>
{/* STROKE_WEIGHTS */}
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={params.strokeWeights}
onChange={e => onParamsChange({ strokeWeights: e.target.checked })}
<div className="form-row">
<NumberField
label="用户最小字号"
value={params.userMinFontSize}
min={1}
step={1}
placeholder="留空=自动"
onChange={v => onParamsChange({ userMinFontSize: v === null ? null : Math.round(v) })}
/>
<NumberField
label="用户最大字号"
value={params.userMaxFontSize}
min={1}
step={1}
placeholder="留空=自动"
onChange={v => onParamsChange({ userMaxFontSize: v === null ? null : Math.round(v) })}
/>
</div>
<div className="form-row">
<NumberField
label="字号下限 MIN_FONT_FLOOR"
value={params.minFontFloor}
min={1}
step={1}
onChange={v => onParamsChange({ minFontFloor: Math.max(1, Math.round(v ?? 2)) })}
/>
<NumberField
label="可读高度(px)"
value={params.minReadableHeightPx}
min={1}
step={1}
onChange={v => onParamsChange({ minReadableHeightPx: Math.max(1, Math.round(v ?? 25)) })}
/>
</div>
<div className="form-row">
<NumberField
label="工作缩放 WORK_SCALE"
value={params.workScale}
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ workScale: v ?? 0.25 })}
/>
<NumberField
label="目标填充率 TARGET_FILL"
value={params.targetFillRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ targetFillRatio: v ?? 0 })}
/>
</div>
<Hint>TARGET_FILL_RATIO=0 </Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="根据笔画复杂度调整权重"
checked={params.strokeWeights}
onChange={v => onParamsChange({ strokeWeights: v })}
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
/>
<div className="form-row">
<NumberField
label="对数权重 LOG_WEIGHT_RATIO"
value={params.logWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ logWeightRatio: v ?? 0.72 })}
/>
<NumberField
label="排序权重 RANK_WEIGHT_RATIO"
value={params.rankWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ rankWeightRatio: v ?? 0.28 })}
/>
</div>
<Hint> 1.0 0.72 / 0.28</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="智能减少大字数量"
checked={params.enableSmartLargeFontReduction}
onChange={v => onParamsChange({ enableSmartLargeFontReduction: v })}
hint="填不满时自动减少大字,给小词腾空间"
/>
<BoolField
label="限制大字比例 LIMIT_LARGE_FONTS"
checked={params.limitLargeFonts}
onChange={v => onParamsChange({ limitLargeFonts: v })}
/>
<div className="form-row">
<NumberField
label="大字允许占比"
value={params.largeFontLimitRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontLimitRatio: v ?? 0.2 })}
/>
<NumberField
label="大字判定阈值"
value={params.largeFontThresholdRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontThresholdRatio: v ?? 0.8 })}
/>
</div>
<NumberField
label="大字压缩系数 LARGE_FONT_CAP_RATIO"
value={params.largeFontCapRatio}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontCapRatio: v ?? 0.6 })}
/>
<Hint> 20% 80% 60%</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
<NumberField
label="缩放下限 FONT_SCALE_MIN"
value={params.fontScaleMin}
min={0.05}
max={2}
step={0.05}
onChange={v => onParamsChange({ fontScaleMin: v ?? 0.5 })}
/>
<NumberField
label="缩放上限 FONT_SCALE_MAX"
value={params.fontScaleMax}
min={0.1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fontScaleMax: v ?? 1.2 })}
/>
</div>
<div className="form-row">
<NumberField
label="搜索步数"
value={params.scaleSearchSteps}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchSteps: Math.max(1, Math.round(v ?? 7)) })}
/>
<NumberField
label="搜索轮数"
value={params.scaleSearchRounds}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchRounds: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="衰减 SCALE_DECAY"
value={params.scaleDecay}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleDecay: v ?? 0.85 })}
/>
<NumberField
label="地板 SCALE_FLOOR"
value={params.scaleFloor}
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleFloor: v ?? 0.25 })}
/>
</div>
<div className="form-row">
<NumberField
label="自动收缩轮数"
value={params.autoShrinkRounds}
min={0}
step={1}
onChange={v => onParamsChange({ autoShrinkRounds: Math.max(0, Math.round(v ?? 4)) })}
/>
<div className="form-group" style={{ justifyContent: 'center' }}>
<BoolField
label="要求全部词语放入"
checked={params.requireAllWords}
onChange={v => onParamsChange({ requireAllWords: v })}
/>
</label>
<span className="text-xs text-muted">
"鑫""一"
</span>
</div>
</div>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="分层采样(边缘覆盖)"
checked={params.enableStratifiedSampling}
onChange={v => onParamsChange({ enableStratifiedSampling: v })}
/>
<NumberField
label="分层条带数 STRATIFIED_BANDS"
value={params.stratifiedBands}
min={1}
max={10}
step={1}
onChange={v => onParamsChange({ stratifiedBands: Math.max(1, Math.round(v ?? 3)) })}
/>
<div className="form-row">
<NumberField
label="最低接受填充率"
value={params.minAcceptFillRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ minAcceptFillRatio: v ?? 0.75 })}
/>
<NumberField
label="填充重试轮数"
value={params.fillRetryMaxRounds}
min={0}
step={1}
onChange={v => onParamsChange({ fillRetryMaxRounds: Math.max(0, Math.round(v ?? 3)) })}
/>
</div>
<div className="form-row">
<NumberField
label="重试最大缩放"
value={params.fillRetryMaxScale}
min={1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fillRetryMaxScale: v ?? 1.5 })}
/>
<NumberField
label="低填充增字号步长"
value={params.growFontStep}
min={1}
max={2}
step={0.01}
onChange={v => onParamsChange({ growFontStep: v ?? 1.05 })}
/>
</div>
<BoolField
label="填充重试时放宽大字限制"
checked={params.fillRetryRelaxLargeCap}
onChange={v => onParamsChange({ fillRetryRelaxLargeCap: v })}
/>
<BoolField
label="低填充时略增字号 GROW_FONT_ON_LOW_FILL"
checked={params.growFontOnLowFill}
onChange={v => onParamsChange({ growFontOnLowFill: v })}
hint="后端默认关闭"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="自动扩展画布 AUTO_EXPAND_CANVAS"
checked={params.autoExpandCanvas}
onChange={v => onParamsChange({ autoExpandCanvas: v })}
/>
<BoolField
label="螺旋填充扩展 EXPAND_FOR_SPIRAL"
checked={params.expandForSpiral}
onChange={v => onParamsChange({ expandForSpiral: v })}
/>
<div className="form-row">
<NumberField
label="扩展倍率 EXPAND_RATIO"
value={params.expandRatio}
min={1}
max={5}
step={0.1}
onChange={v => onParamsChange({ expandRatio: v ?? 2.5 })}
/>
<NumberField
label="最大尝试次数"
value={params.maxAttempts}
min={1}
step={1}
onChange={v => onParamsChange({ maxAttempts: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="画布重试轮数"
value={params.canvasRetryMaxRounds}
min={0}
step={1}
onChange={v => onParamsChange({ canvasRetryMaxRounds: Math.max(0, Math.round(v ?? 1)) })}
/>
<NumberField
label="画布重试增长"
value={params.canvasRetryGrowth}
min={1}
max={2}
step={0.01}
onChange={v => onParamsChange({ canvasRetryGrowth: v ?? 1.12 })}
/>
</div>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-group">
<label className="form-label"> LAYOUT_ORDER_MODE</label>
<select
className="form-input"
value={params.layoutOrderMode}
onChange={e =>
onParamsChange({ layoutOrderMode: e.target.value as JobParams['layoutOrderMode'] })
}
>
<option value="SORTED">SORTED</option>
<option value="INTERLEAVED_RANDOM">INTERLEAVED_RANDOM</option>
</select>
</div>
<NumberField
label="布局种子 LAYOUT_SEED"
value={params.layoutSeed}
min={0}
step={1}
placeholder="留空=跟随 SEED"
onChange={v => onParamsChange({ layoutSeed: v === null ? null : Math.round(v) })}
/>
<div className="section-divider" />
<p className="text-xs text-muted" style={{ lineHeight: 1.6 }}>
<code style={{ fontSize: 10, background: 'var(--bg)', padding: '0 3px', borderRadius: 2 }}>
config.json
</code>
README 4.8
<code style={{ fontSize: 10 }}>backend/core/config.py</code>
<strong>SIZE_RATIO</strong><strong></strong> {' '}
<strong></strong>
</p>
</div>
</>
+62 -12
View File
@@ -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
</button>
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={handleSaveAsSticker}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
{replaceTarget ? (
<>
<div className="settings-note" style={{ marginTop: 8 }}>
</div>
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={() => handleSaveAsSticker('replace')}
>
{isSavingSticker ? '替换中' : '替换画布词云'}
</button>
</>
) : (
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={() => handleSaveAsSticker('add')}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
)}
<div className="section-divider" />
+23 -8
View File
@@ -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<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -17,12 +19,14 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
const dropped = e.dataTransfer.files[0];
if (!dropped) return;
validateAndSet(dropped);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="form-group">
<div
className={`upload-zone${dragging ? ' drag-over' : ''}`}
onDragOver={e => { 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}
>
<input
ref={inputRef}
type="file"
accept={accept}
disabled={disabled}
onChange={handleChange}
onClick={e => e.stopPropagation()}
/>
@@ -64,13 +79,13 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
<div style={{ fontSize: 10, marginTop: 2 }}>{acceptHint}</div>
</div>
</div>
{(hasFile || error) && (
{showStatus && (
<div className={`status-bar ${statusClass}`}>
<span className="status-bar-filename">
{error ? error : file?.name}
{filenameText}
</span>
<span className="status-bar-label">
{error ? '导入失败' : '导入成功'}
{labelText}
</span>
</div>
)}
+36 -2
View File
@@ -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<HTMLInputElement>(null);
@@ -48,12 +54,40 @@ export default function ImportPanel({
{/* 底图导入 */}
<div>
<div className="section-title">mask_image</div>
{maskLocked ? (
<div className="settings-note" style={{ marginBottom: 8 }}>
</div>
) : null}
{maskLocked && maskPreviewUrl ? (
<div className="mask-locked-preview" style={{
marginBottom: 8,
border: '1px solid var(--border)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--surface-2, var(--bg-secondary))',
}}>
<img
src={maskPreviewUrl}
alt="原底图预览"
style={{
display: 'block',
width: '100%',
maxHeight: 160,
objectFit: 'contain',
background: '#fff',
}}
/>
</div>
) : null}
<FileUploader
label="拖动或点击上传底图"
label={maskLocked ? '底图已锁定' : '拖动或点击上传底图'}
accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png"
acceptHint="支持 svg · jpg · png"
acceptHint={maskLocked ? '同底图换名单模式' : '支持 svg · jpg · png'}
file={maskFile}
onFileChange={onMaskChange}
disabled={maskLocked}
statusText={maskStatusText}
/>
</div>
+24
View File
@@ -141,6 +141,30 @@ export async function addStickerAsset(
return sticker;
}
/** 服务端直接从 job 产物复制入库,避免浏览器下载/再上传大 SVG */
export async function addStickerAssetFromJob(
jobId: string,
name?: string,
): Promise<StickerAsset> {
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<void> {
await apiDeleteAsset(id);
removeTint(id);
+439 -20
View File
@@ -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<WordcloudStickerPayload['replaceTarget']>,
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({
<NumberField label="透明度" value={element.opacity} min={0} max={1} step={0.05} disabled={locked} onChange={value => onUpdate({ opacity: value })} />
</div>
{selectedIsWordcloud && (
{(selectedIsWordcloud || selectedCanReplaceNames) && (
<div className="wordcloud-spacing-panel">
<div className="section-divider" />
<div className="section-title"></div>
<div className="settings-note">
{selectedCanReplaceNames
? '保持当前底图与画布位置,仅更换名单重新生成。'
: '当前贴纸缺少同组底图,无法切换名单。'}
</div>
<button
className="btn btn-primary btn-sm btn-block"
disabled={locked || !onReplaceWordcloudNames || !selectedCanReplaceNames}
onClick={() => onReplaceWordcloudNames?.()}
style={{ marginBottom: 12 }}
>
</button>
{selectedIsWordcloud && (
<>
<div className="section-title">线</div>
<div className="form-row">
<NumberField
@@ -1518,6 +1730,8 @@ function PropertiesPanel({
))}
</div>
)}
</>
)}
</div>
)}
@@ -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<string, StickerAsset>,
) {
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<CanvasElement & { type: 'sticker' }>;
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<string, StickerAsset>,
): { 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<import('../types').WordcloudMaskSource> {
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<string>((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({
+275 -15
View File
@@ -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<File | null>(null);
const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null);
@@ -97,6 +159,10 @@ export default function TestWorkbench({
const [progress, setProgress] = useState<SSEProgress | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
const [replaceMaskError, setReplaceMaskError] = useState('');
const appliedReplaceSessionKeyRef = useRef<string | null>(null);
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
const [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
@@ -104,6 +170,79 @@ export default function TestWorkbench({
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const sseRef = useRef<EventSource | null>(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<HTMLDivElement>(null);
const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 });
const [workspaceSize, setWorkspaceSize] = useState<WorkspaceSize>({ 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<string, unknown> = {
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)}
/>
)}
</FloatingPanel>
@@ -614,6 +823,9 @@ export default function TestWorkbench({
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span>
{activeReplaceSession && (
<span className="navbar-mode-badge"></span>
)}
</div>
<div className="navbar-actions">
{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<string> {
const blob = dataUrlToBlob(dataUrl);
return blob.text();
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
+14
View File
@@ -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;
+87 -9
View File
@@ -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_INDEXnull 表示不传)
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;
}