Add advanced canvas editing and Ubuntu deployment
This commit is contained in:
+13
-2
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user