Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
|
||
import { apiUrl, ensureOk } from '../lib/api';
|
||
|
||
interface ExportPanelProps {
|
||
jobId: string | null;
|
||
svgUrl?: string;
|
||
imageUrl?: string;
|
||
onOpenCanvas?: () => void;
|
||
maskSource?: WordcloudMaskSource | null;
|
||
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
|
||
embedded?: boolean;
|
||
}
|
||
|
||
type Format = 'jpg' | 'png';
|
||
type FillMode = 'fill' | 'dot' | 'line' | 'ring';
|
||
|
||
export default function ExportPanel({
|
||
jobId,
|
||
svgUrl,
|
||
imageUrl,
|
||
onOpenCanvas,
|
||
maskSource,
|
||
onImportWordcloudSticker,
|
||
embedded = false,
|
||
}: ExportPanelProps) {
|
||
const [bmpFormat, setBmpFormat] = useState<Format>('png');
|
||
const [exportW, setExportW] = useState('1920');
|
||
const [exportH, setExportH] = useState('1080');
|
||
const [isSavingSticker, setIsSavingSticker] = useState(false);
|
||
|
||
const [stroke, setStroke] = useState(false);
|
||
const [fillMode, setFillMode] = useState<FillMode>('fill');
|
||
const [dotSpacing, setDotSpacing] = useState(10);
|
||
const [dotRadius, setDotRadius] = useState(2);
|
||
const [lineSpacing, setLineSpacing] = useState(6);
|
||
const [lineWidth, setLineWidth] = useState(1);
|
||
const [lineAngle, setLineAngle] = useState(0);
|
||
const [ringRadius, setRingRadius] = useState(3);
|
||
const [ringWidth, setRingWidth] = useState(1);
|
||
const [ringSpacing, setRingSpacing] = useState(8);
|
||
|
||
const resolveUrl = (field: string | undefined, kind: string) => {
|
||
if (field) return apiUrl(field);
|
||
if (jobId) return apiUrl(`/api/jobs/${jobId}/files/${kind}`);
|
||
return null;
|
||
};
|
||
|
||
const buildCustomSvgUrl = () => {
|
||
if (!jobId) return null;
|
||
const params = new URLSearchParams();
|
||
params.set('fill', fillMode);
|
||
params.set('stroke', stroke ? '1' : '0');
|
||
if (fillMode === 'dot') {
|
||
params.set('spacing', String(dotSpacing));
|
||
params.set('radius', String(dotRadius));
|
||
}
|
||
if (fillMode === 'line') {
|
||
params.set('line_spacing', String(lineSpacing));
|
||
params.set('line_width', String(lineWidth));
|
||
params.set('line_angle', String(lineAngle));
|
||
}
|
||
if (fillMode === 'ring') {
|
||
params.set('ring_radius', String(ringRadius));
|
||
params.set('ring_width', String(ringWidth));
|
||
params.set('ring_spacing', String(ringSpacing));
|
||
}
|
||
return apiUrl(`/api/jobs/${jobId}/custom.svg?${params}`);
|
||
};
|
||
|
||
const handleExportSvg = () => {
|
||
const url = buildCustomSvgUrl();
|
||
if (!url) return;
|
||
triggerDownload(url, 'wordcloud.svg');
|
||
};
|
||
|
||
const handleSaveAsSticker = async () => {
|
||
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
|
||
if (!url || isSavingSticker) return;
|
||
|
||
setIsSavingSticker(true);
|
||
try {
|
||
const res = await ensureOk(await fetch(url), '读取 SVG 失败');
|
||
const svg = await res.text();
|
||
if (onImportWordcloudSticker) {
|
||
onImportWordcloudSticker({ svg, mask: maskSource || undefined });
|
||
} else {
|
||
onOpenCanvas?.();
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '保存失败';
|
||
alert(message);
|
||
} finally {
|
||
setIsSavingSticker(false);
|
||
}
|
||
};
|
||
|
||
const handleExportBitmap = () => {
|
||
const url = resolveUrl(imageUrl, 'png');
|
||
if (!url) return;
|
||
triggerDownload(url, `wordcloud.${bmpFormat}`);
|
||
};
|
||
|
||
const triggerDownload = (url: string, filename: string) => {
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
};
|
||
|
||
const hasResult = !!jobId;
|
||
|
||
return (
|
||
<>
|
||
{!embedded && (
|
||
<div className="panel-header">
|
||
<div className="panel-title">导出</div>
|
||
</div>
|
||
)}
|
||
<div className="panel-body">
|
||
|
||
{/* ── SVG 导出 ── */}
|
||
<div className="export-col-title">矢量 SVG</div>
|
||
|
||
{/* 1. 描边 */}
|
||
<div className="form-group">
|
||
<label className="radio-label" style={{ cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={stroke}
|
||
onChange={e => setStroke(e.target.checked)}
|
||
style={{ marginRight: 6 }}
|
||
/>
|
||
描边
|
||
</label>
|
||
</div>
|
||
|
||
{/* 2. 填充模式 */}
|
||
<div className="form-group">
|
||
<div className="radio-group" style={{ flexDirection: 'column', gap: 2 }}>
|
||
<label className="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="fill-mode"
|
||
checked={fillMode === 'fill'}
|
||
onChange={() => setFillMode('fill')}
|
||
/>
|
||
填充(fill)
|
||
</label>
|
||
<label className="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="fill-mode"
|
||
checked={fillMode === 'dot'}
|
||
onChange={() => setFillMode('dot')}
|
||
/>
|
||
点阵(dot)
|
||
</label>
|
||
<label className="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="fill-mode"
|
||
checked={fillMode === 'line'}
|
||
onChange={() => setFillMode('line')}
|
||
/>
|
||
横线(line)
|
||
</label>
|
||
<label className="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="fill-mode"
|
||
checked={fillMode === 'ring'}
|
||
onChange={() => setFillMode('ring')}
|
||
/>
|
||
空心圆(ring)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 点阵参数 */}
|
||
{fillMode === 'dot' && (
|
||
<div className="form-group" style={{ paddingLeft: 20 }}>
|
||
<div className="flex-row" style={{ gap: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={dotSpacing}
|
||
onChange={e => setDotSpacing(parseInt(e.target.value) || 10)}
|
||
min={2}
|
||
max={100}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>半径</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={dotRadius}
|
||
onChange={e => setDotRadius(parseInt(e.target.value) || 2)}
|
||
min={1}
|
||
max={20}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 线条参数 */}
|
||
{fillMode === 'line' && (
|
||
<div className="form-group" style={{ paddingLeft: 20 }}>
|
||
<div className="flex-row" style={{ gap: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={lineSpacing}
|
||
onChange={e => setLineSpacing(parseInt(e.target.value) || 6)}
|
||
min={2}
|
||
max={100}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>粗细</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={lineWidth}
|
||
onChange={e => setLineWidth(parseFloat(e.target.value) || 1)}
|
||
min={0.5}
|
||
max={10}
|
||
step={0.5}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>角度</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={lineAngle}
|
||
onChange={e => setLineAngle(parseInt(e.target.value) || 0)}
|
||
min={0}
|
||
max={359}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">°</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 空心圆参数 */}
|
||
{fillMode === 'ring' && (
|
||
<div className="form-group" style={{ paddingLeft: 20 }}>
|
||
<div className="flex-row" style={{ gap: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>半径</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={ringRadius}
|
||
onChange={e => setRingRadius(parseInt(e.target.value) || 3)}
|
||
min={1}
|
||
max={20}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}>粗细</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={ringWidth}
|
||
onChange={e => setRingWidth(parseFloat(e.target.value) || 1)}
|
||
min={0.5}
|
||
max={10}
|
||
step={0.5}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>间距</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={ringSpacing}
|
||
onChange={e => setRingSpacing(parseInt(e.target.value) || 8)}
|
||
min={2}
|
||
max={100}
|
||
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
className="btn btn-secondary btn-sm btn-block"
|
||
disabled={!hasResult}
|
||
onClick={handleExportSvg}
|
||
>
|
||
导出 SVG
|
||
</button>
|
||
<button
|
||
className="btn btn-primary btn-sm btn-block"
|
||
disabled={!hasResult || isSavingSticker}
|
||
onClick={handleSaveAsSticker}
|
||
>
|
||
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
|
||
</button>
|
||
|
||
<div className="section-divider" />
|
||
|
||
{/* ── 位图导出 ── */}
|
||
<div className="export-split">
|
||
<div className="export-col">
|
||
<div className="export-col-title">位图</div>
|
||
<div className="radio-group" style={{ flexDirection: 'column', gap: 4 }}>
|
||
{(['png', 'jpg'] as Format[]).map(f => (
|
||
<label key={f} className="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="bmp-format"
|
||
checked={bmpFormat === f}
|
||
onChange={() => setBmpFormat(f)}
|
||
/>
|
||
{f}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<div className="flex-row" style={{ gap: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>长=</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={exportW}
|
||
onChange={e => setExportW(e.target.value)}
|
||
min={1}
|
||
max={10000}
|
||
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
<div className="flex-row" style={{ gap: 4 }}>
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>宽=</span>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={exportH}
|
||
onChange={e => setExportH(e.target.value)}
|
||
min={1}
|
||
max={10000}
|
||
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
|
||
/>
|
||
<span className="text-xs text-muted">px</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="note-text">注:为等比放大取最小值</div>
|
||
|
||
<button
|
||
className="btn btn-secondary btn-sm btn-block"
|
||
disabled={!hasResult}
|
||
onClick={handleExportBitmap}
|
||
>
|
||
导出 {bmpFormat}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{!hasResult && (
|
||
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: 8 }}>
|
||
请先生成词云后再导出
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|