Initial project baseline
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { useState } from 'react';
|
||||
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
|
||||
|
||||
interface ExportPanelProps {
|
||||
jobId: string | null;
|
||||
apiBase: string;
|
||||
svgUrl?: string;
|
||||
imageUrl?: string;
|
||||
onOpenCanvas?: () => void;
|
||||
maskSource?: WordcloudMaskSource | null;
|
||||
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
|
||||
}
|
||||
|
||||
type Format = 'jpg' | 'png';
|
||||
type FillMode = 'fill' | 'dot' | 'line' | 'ring';
|
||||
|
||||
export default function ExportPanel({
|
||||
jobId,
|
||||
apiBase,
|
||||
svgUrl,
|
||||
imageUrl,
|
||||
onOpenCanvas,
|
||||
maskSource,
|
||||
onImportWordcloudSticker,
|
||||
}: 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 field.startsWith('http') ? field : `${apiBase}${field}`;
|
||||
if (jobId) return `${apiBase}/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 `${apiBase}/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 fetch(url);
|
||||
if (!res.ok) throw new Error(`读取 SVG 失败 (${res.status})`);
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user