Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+382
View File
@@ -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">&deg;</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>
</>
);
}