Initial project baseline
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
.claude
|
||||
.vscode
|
||||
.idea
|
||||
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Stage 1: Build React app ───────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Serve with Nginx ──────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built static files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>词云生成工具</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☁️</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# allow large asset uploads (wordcloud SVG/PNG can be several MB)
|
||||
client_max_body_size 100M;
|
||||
|
||||
# gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Proxy all /api requests to the backend service
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE / long-running endpoints
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Serve static files, fallback to index.html for SPA routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1836
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "wordcloud-tool",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import CanvasStudio from './pages/CanvasStudio';
|
||||
import TemplateHome from './pages/TemplateHome';
|
||||
import TestWorkbench from './pages/TestWorkbench';
|
||||
import { CanvasDocument, WordcloudStickerPayload } from './types';
|
||||
import { createDefaultDocument } from './lib/canvasDocument';
|
||||
|
||||
type AppPage = 'home' | 'canvas' | 'wordcloud';
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const getStoredTheme = (): ThemeMode => {
|
||||
const stored = window.localStorage.getItem('wordcloud-theme');
|
||||
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
|
||||
};
|
||||
|
||||
const getSystemTheme = () =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<AppPage>('home');
|
||||
const [themeMode] = useState<ThemeMode>(getStoredTheme);
|
||||
const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme);
|
||||
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
|
||||
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.dataset.themeMode = themeMode;
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
}, [themeMode, systemTheme]);
|
||||
|
||||
if (page === 'wordcloud') {
|
||||
return (
|
||||
<TestWorkbench
|
||||
onOpenCanvas={() => setPage('canvas')}
|
||||
onImportWordcloudSticker={(payload) => {
|
||||
setPendingWordcloudSticker(payload);
|
||||
setPage('canvas');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (page === 'canvas') {
|
||||
return (
|
||||
<CanvasStudio
|
||||
onOpenHome={() => setPage('home')}
|
||||
onOpenWordcloud={() => setPage('wordcloud')}
|
||||
initialDocument={initialDocument}
|
||||
onConsumeInitialDocument={() => setInitialDocument(null)}
|
||||
pendingWordcloudSticker={pendingWordcloudSticker}
|
||||
onConsumeWordcloudSticker={() => setPendingWordcloudSticker(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateHome
|
||||
onCreateBlank={() => {
|
||||
setInitialDocument(createDefaultDocument());
|
||||
setPage('canvas');
|
||||
}}
|
||||
onUseTemplate={(template) => {
|
||||
setInitialDocument(template.document);
|
||||
setPage('canvas');
|
||||
}}
|
||||
onOpenCanvas={() => setPage('canvas')}
|
||||
onOpenWordcloud={() => setPage('wordcloud')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { JobParams } from '../types';
|
||||
|
||||
interface AdvancedPanelProps {
|
||||
params: JobParams;
|
||||
onParamsChange: (partial: Partial<JobParams>) => void;
|
||||
}
|
||||
|
||||
export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">高级参数</div>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
|
||||
{/* SEED */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">随机种子(SEED)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={params.seed ?? ''}
|
||||
placeholder="留空=不固定种子"
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim();
|
||||
onParamsChange({ seed: v === '' ? null : parseInt(v) });
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted">相同种子可完整复现布局结果</span>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* FONT_COLOR */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">字体颜色</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={params.fontColor || '#000000'}
|
||||
onChange={e => onParamsChange({ fontColor: e.target.value })}
|
||||
style={{ width: 36, height: 28, border: 'none', cursor: 'pointer' }}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={params.fontColor || '#000000'}
|
||||
placeholder="#000000"
|
||||
onChange={e => onParamsChange({ fontColor: e.target.value })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted">默认黑色,留空则使用调色板渐变</span>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* N_REPETITIONS */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">词语重复填充次数</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
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) });
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
词语较少时(如仅 10 个),可增大此值(如 5~10)使每个词语重复出现多次,提升填充率与观感。默认 1(不重复)。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* 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 })}
|
||||
/>
|
||||
根据笔画复杂度调整权重
|
||||
</label>
|
||||
<span className="text-xs text-muted">
|
||||
开启后,笔画复杂的字(如"鑫")会分配更大字号,笔画简单的字(如"一")字号较小。关闭则所有词语按均等权重分配字号。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 节。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { NameLocation, JobResult } from '../types';
|
||||
import { IconCloudy } from './Icons';
|
||||
|
||||
interface CanvasAreaProps {
|
||||
maskFile: File | null;
|
||||
jobResult: JobResult | null;
|
||||
apiBase: string;
|
||||
viewMode: '2d' | '3d';
|
||||
zoom: number;
|
||||
highlightLocation: NameLocation | null;
|
||||
}
|
||||
|
||||
export default function CanvasArea({
|
||||
maskFile, jobResult, apiBase, viewMode, zoom, highlightLocation
|
||||
}: CanvasAreaProps) {
|
||||
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!maskFile) { setMaskPreviewUrl(null); return; }
|
||||
const url = URL.createObjectURL(maskFile);
|
||||
setMaskPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [maskFile]);
|
||||
|
||||
// 图片 URL 处理:
|
||||
// - 若已是完整 http URL 直接使用
|
||||
// - 若是 /api/... 路径则拼接 apiBase
|
||||
// - 否则回退到 /api/jobs/{id}/files/png
|
||||
const resolveImageUrl = (): string | null => {
|
||||
if (!jobResult) return maskPreviewUrl;
|
||||
const raw = jobResult.image_url;
|
||||
if (!raw || !jobResult.job_id) return maskPreviewUrl;
|
||||
if (raw.startsWith('http')) return raw;
|
||||
return `${apiBase}${raw}`;
|
||||
};
|
||||
|
||||
const displayUrl = resolveImageUrl();
|
||||
const isEmpty = !displayUrl;
|
||||
|
||||
return (
|
||||
<div className="canvas-inner" ref={wrapperRef}>
|
||||
{isEmpty ? (
|
||||
<div className="canvas-placeholder">
|
||||
<div className="canvas-placeholder-icon"><IconCloudy /></div>
|
||||
<div className="canvas-placeholder-text">导入底图或名单后可预览词云</div>
|
||||
</div>
|
||||
) : viewMode === '3d' ? (
|
||||
<div className="view-3d-container">
|
||||
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-image-wrapper">
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="wordcloud"
|
||||
className="canvas-image"
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
/>
|
||||
{highlightLocation && jobResult && (
|
||||
<HighlightBox location={highlightLocation} zoom={zoom} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightBox({ location, zoom }: { location: NameLocation; zoom: number }) {
|
||||
const x = location.box_x ?? location.x;
|
||||
const y = location.box_y ?? location.y;
|
||||
const width = location.box_width ?? location.width ?? location.font_size ?? 24;
|
||||
const height = location.box_height ?? location.height ?? location.font_size ?? 24;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="highlight-box"
|
||||
style={{
|
||||
left: x * zoom,
|
||||
top: y * zoom,
|
||||
width: width * zoom,
|
||||
height: height * zoom,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { NameEntry } from '../types';
|
||||
import { IconFolder } from './Icons';
|
||||
|
||||
interface EditPanelProps {
|
||||
entries: NameEntry[];
|
||||
onEntriesChange: (entries: NameEntry[]) => void;
|
||||
}
|
||||
|
||||
export default function EditPanel({ entries, onEntriesChange }: EditPanelProps) {
|
||||
const [filterCol, setFilterCol] = useState('');
|
||||
const [filterVal, setFilterVal] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filterVal.trim()) return entries;
|
||||
const val = filterVal.trim().toLowerCase();
|
||||
const col = filterCol.trim().toLowerCase();
|
||||
return entries.filter(e => {
|
||||
if (!col || col === '1' || col === '编号' || col === '组') {
|
||||
if (e.group.toLowerCase().includes(val)) return true;
|
||||
}
|
||||
if (!col || col === '2' || col === '名字' || col === '姓名') {
|
||||
if (e.name.toLowerCase().includes(val)) return true;
|
||||
}
|
||||
if (!col || col === '3' || col === '权重') {
|
||||
if (String(e.weight).includes(val)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [entries, filterCol, filterVal]);
|
||||
|
||||
const updateEntry = (idx: number, field: keyof NameEntry, value: string | number) => {
|
||||
const realEntry = filtered[idx];
|
||||
const realIdx = entries.findIndex(e => e === realEntry);
|
||||
if (realIdx < 0) return;
|
||||
const updated = [...entries];
|
||||
updated[realIdx] = { ...updated[realIdx], [field]: value };
|
||||
onEntriesChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">修改名单</div>
|
||||
</div>
|
||||
<div className="panel-body" style={{ padding: '10px 10px 0' }}>
|
||||
{/* Filter bar */}
|
||||
<div className="flex-row" style={{ gap: 4, marginBottom: 8 }}>
|
||||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>查找:</span>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '0 0 56px', padding: '4px 6px', fontSize: 11 }}
|
||||
placeholder="列"
|
||||
value={filterCol}
|
||||
onChange={e => setFilterCol(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: 1, padding: '4px 6px', fontSize: 11 }}
|
||||
placeholder="值"
|
||||
value={filterVal}
|
||||
onChange={e => setFilterVal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="data-table-wrapper" style={{ flex: 1, minHeight: 0, marginBottom: 10 }}>
|
||||
<div className="data-table-header">
|
||||
<div className="data-table-head-cell">编号</div>
|
||||
<div className="data-table-head-cell">名字</div>
|
||||
<div className="data-table-head-cell">权重</div>
|
||||
</div>
|
||||
<div className="data-table-body">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="table-empty">
|
||||
<span style={{ fontSize: 24 }}><IconFolder /></span>
|
||||
<span>{entries.length === 0 ? '请先导入名单' : '无匹配结果'}</span>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((entry, idx) => (
|
||||
<div className="data-table-row" key={idx}>
|
||||
<div className="data-table-cell">
|
||||
<input
|
||||
value={entry.group}
|
||||
onChange={e => updateEntry(idx, 'group', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="data-table-cell">
|
||||
<input
|
||||
value={entry.name}
|
||||
onChange={e => updateEntry(idx, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="data-table-cell">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.weight}
|
||||
onChange={e => updateEntry(idx, 'weight', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useRef, useState, DragEvent, ChangeEvent } from 'react';
|
||||
import { IconFolder } from './Icons';
|
||||
|
||||
interface FileUploaderProps {
|
||||
label: string;
|
||||
accept: string;
|
||||
acceptHint: string;
|
||||
file: File | null;
|
||||
onFileChange: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export default function FileUploader({ label, accept, acceptHint, file, onFileChange }: FileUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (!dropped) return;
|
||||
validateAndSet(dropped);
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
validateAndSet(selected);
|
||||
};
|
||||
|
||||
const validateAndSet = (f: File) => {
|
||||
const ext = '.' + f.name.split('.').pop()?.toLowerCase();
|
||||
const acceptList = accept.split(',').map(a => a.trim().toLowerCase());
|
||||
if (!acceptList.includes(ext) && !acceptList.includes(f.type)) {
|
||||
setError(`不支持的文件格式:${ext}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onFileChange(f);
|
||||
};
|
||||
|
||||
const hasFile = file !== null;
|
||||
const statusClass = error ? 'error' : hasFile ? 'success' : '';
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div
|
||||
className={`upload-zone${dragging ? ' drag-over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="upload-zone-text">
|
||||
<span className="upload-zone-icon"><IconFolder /></span>
|
||||
<div>{label}</div>
|
||||
<div style={{ fontSize: 10, marginTop: 2 }}>{acceptHint}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(hasFile || error) && (
|
||||
<div className={`status-bar ${statusClass}`}>
|
||||
<span className="status-bar-filename">
|
||||
{error ? error : file?.name}
|
||||
</span>
|
||||
<span className="status-bar-label">
|
||||
{error ? '导入失败' : '导入成功'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { NameLocation } from '../types';
|
||||
|
||||
interface FindPanelProps {
|
||||
jobId: string | null;
|
||||
apiBase: string;
|
||||
onLocate: (location: NameLocation) => void;
|
||||
}
|
||||
|
||||
export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<NameLocation[]>([]);
|
||||
const [currentIdx, setCurrentIdx] = useState(-1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const handleFind = async () => {
|
||||
if (!jobId || !query.trim()) return;
|
||||
setLoading(true);
|
||||
setSearched(false);
|
||||
try {
|
||||
const url = `${apiBase}/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('请求失败');
|
||||
const data = await res.json();
|
||||
const matches: NameLocation[] = (data.matches ?? []).map((m: any) => ({
|
||||
...m,
|
||||
x: m.box_x ?? m.x,
|
||||
y: m.box_y ?? m.y,
|
||||
width: m.box_width ?? m.width ?? 0,
|
||||
height: m.box_height ?? m.height ?? 0,
|
||||
}));
|
||||
setResults(matches);
|
||||
setCurrentIdx(matches.length > 0 ? 0 : -1);
|
||||
setSearched(true);
|
||||
if (matches.length > 0) onLocate(matches[0]);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setCurrentIdx(-1);
|
||||
setSearched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (results.length === 0) return;
|
||||
const next = (currentIdx + 1) % results.length;
|
||||
setCurrentIdx(next);
|
||||
onLocate(results[next]);
|
||||
};
|
||||
|
||||
const hasNext = results.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">查找</div>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ fontWeight: 600, fontSize: 12 }}>名字</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="输入名字,如张三"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleFind()}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleFind}
|
||||
disabled={!jobId || !query.trim() || loading}
|
||||
>
|
||||
{loading ? <><span className="spinner" style={{ width: 10, height: 10 }} />查找中</> : '查找'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleNext}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
下一个
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searched && (
|
||||
<div className="find-result-text">
|
||||
{results.length > 0
|
||||
? `结果:共找到 ${results.length} 个结果,点击"下一个"浏览不同位置`
|
||||
: `未找到"${query}",请检查名字是否正确`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!jobId && (
|
||||
<p className="text-xs text-muted">请先生成词云后再查找</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
|
||||
function Icon({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconImport() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 2v8M4 8l4 4 4-4M2 14h12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconExport() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 2v8M4 6l4-4 4 4M2 14h12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEdit() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M11 2l3 3-9 9H2v-3l9-9z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFind() {
|
||||
return (
|
||||
<Icon>
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="M15 15l-4-4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSettings() {
|
||||
return (
|
||||
<Icon>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M1 8h3M12 8h3M8 1v3M8 12v3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCloud() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M6 10a4 4 0 0 1-.5-7.9 4 4 0 0 1 7.9.5A3.5 3.5 0 0 1 14.5 10H6z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGrid() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLayers() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 1l7 4-7 4-7-4 7-4z" />
|
||||
<path d="M1 8l7 4 7-4" />
|
||||
<path d="M1 12l7 4 7-4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSticker() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" />
|
||||
<path d="M4 13l2-2 2 2 3-3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconText() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M4 1h8M8 1v14m-3 0h6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconShape() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="2" y="2" width="12" height="12" rx="2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCanvas() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="1" y="1" width="14" height="14" rx="2" />
|
||||
<path d="M4 4h8M4 8h8M4 12h8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEyeOpen() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5S1 8 1 8z" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEyeClosed() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5S1 8 1 8z" />
|
||||
<path d="M4 4l8 8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLock() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="4" y="8" width="8" height="7" rx="1" />
|
||||
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUnlock() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="4" y="8" width="8" height="7" rx="1" />
|
||||
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
|
||||
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
|
||||
<path d="M4 4l8 8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrowUp() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 3v10M4 7l4-4 4 4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrowDown() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 13V3M4 9l4 4 4-4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M3 4h10M5 4v9a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V4M6 4V2h4v2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 1v14M1 8h14" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFolder() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M2 3h4l2 2h7a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheckmark() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M3 9l3 3 7-7" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCross() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear() {
|
||||
return (
|
||||
<Icon>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M1 8h3M12 8h3M8 1v3M8 12v3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCloudy() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M6 10a4 4 0 0 1-.5-7.9 4 4 0 0 1 7.9.5A3.5 3.5 0 0 1 14.5 10H6z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRefresh() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M14 8a6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6v0" />
|
||||
<path d="M10 2h4v4" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDownload() {
|
||||
return (
|
||||
<Icon>
|
||||
<path d="M8 1v8M4 9l4 4 4-4M2 14h12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useRef } from 'react';
|
||||
import FileUploader from './FileUploader';
|
||||
import { IconDownload } from './Icons';
|
||||
import { JobParams, Font } from '../types';
|
||||
|
||||
interface ImportPanelProps {
|
||||
maskFile: File | null;
|
||||
namesFile: File | null;
|
||||
params: JobParams;
|
||||
fonts: Font[];
|
||||
selectedFontId: string;
|
||||
onMaskChange: (file: File | null) => void;
|
||||
onNamesChange: (file: File | null) => void;
|
||||
onParamsChange: (partial: Partial<JobParams>) => void;
|
||||
onFontUpload: (file: File) => void;
|
||||
onFontDelete: (fontId: string) => void;
|
||||
onFontSelect: (fontId: string) => void;
|
||||
}
|
||||
|
||||
const TEMPLATE_URL = '#';
|
||||
|
||||
export default function ImportPanel({
|
||||
maskFile, namesFile, params, fonts, selectedFontId,
|
||||
onMaskChange, onNamesChange, onParamsChange,
|
||||
onFontUpload, onFontDelete, onFontSelect,
|
||||
}: ImportPanelProps) {
|
||||
const fontInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFontFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFontUpload(file);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">导入</div>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
|
||||
{/* 底图导入 */}
|
||||
<div>
|
||||
<div className="section-title">底图(mask_image)</div>
|
||||
<FileUploader
|
||||
label="拖动或点击上传底图"
|
||||
accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png"
|
||||
acceptHint="支持 svg · jpg · png"
|
||||
file={maskFile}
|
||||
onFileChange={onMaskChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* 字体选择 */}
|
||||
<div>
|
||||
<div className="section-title">字体</div>
|
||||
<div className="form-group">
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedFontId}
|
||||
onChange={e => onFontSelect(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{fonts.map(f => (
|
||||
<option key={f.font_id} value={f.font_id}>
|
||||
{f.name}{f.font_id === '__default__' ? '' : ` (${(f.file_size / 1024).toFixed(0)}KB)`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-row" style={{ gap: 6, marginTop: 4 }}>
|
||||
<button
|
||||
className="btn-generate"
|
||||
style={{ flex: 1, fontSize: 11, padding: '5px 0' }}
|
||||
onClick={() => fontInputRef.current?.click()}
|
||||
>
|
||||
上传新字体
|
||||
</button>
|
||||
{selectedFontId !== '__default__' && (
|
||||
<button
|
||||
className="btn-generate"
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
fontSize: 11,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--danger)',
|
||||
color: 'var(--on-danger)',
|
||||
}}
|
||||
onClick={() => onFontDelete(selectedFontId)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fontInputRef}
|
||||
type="file"
|
||||
accept=".ttf,.ttc,.otf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFontFileChange}
|
||||
/>
|
||||
<span className="text-xs text-muted" style={{ marginTop: 4, display: 'block' }}>
|
||||
支持 ttf · ttc · otf,上传后永久保留
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* 名单导入 */}
|
||||
<div>
|
||||
<div className="section-title">名单表格(name_list)</div>
|
||||
<FileUploader
|
||||
label="拖动或点击上传名单"
|
||||
accept=".xlsx"
|
||||
acceptHint="仅支持 .xlsx"
|
||||
file={namesFile}
|
||||
onFileChange={onNamesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* 表格配置 */}
|
||||
<div>
|
||||
<div className="section-title">表格配置</div>
|
||||
|
||||
{/* 名字列索引 DATA_COL_INDEX(0-based) */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">名字列索引(DATA_COL_INDEX)</label>
|
||||
<div className="flex-row">
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={0}
|
||||
style={{ width: 64, flexShrink: 0 }}
|
||||
value={params.dataColIndex}
|
||||
onChange={e => onParamsChange({ dataColIndex: parseInt(e.target.value) || 0 })}
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-xs text-muted">从 0 起,默认 1(第2列)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表头行号(前端预览用,不传后端) */}
|
||||
<div className="form-group" style={{ marginTop: 8 }}>
|
||||
<label className="form-label">表头行号(预览用)</label>
|
||||
<div className="flex-row">
|
||||
<span className="text-xs text-muted">第</span>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={1}
|
||||
style={{ width: 56, flexShrink: 0 }}
|
||||
value={params.headerRow}
|
||||
onChange={e => onParamsChange({ headerRow: parseInt(e.target.value) || 1 })}
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-xs text-muted">行为表头,数据从下一行读取</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 权重列索引 WEIGHT_COL_INDEX(可选,0-based) */}
|
||||
<div className="form-group" style={{ marginTop: 8 }}>
|
||||
<label className="form-label">权重列索引(可选)</label>
|
||||
<div className="flex-row">
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={0}
|
||||
style={{ width: 64, flexShrink: 0 }}
|
||||
value={params.weightColIndex ?? ''}
|
||||
placeholder="留空=自动权重"
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim();
|
||||
onParamsChange({ weightColIndex: v === '' ? null : parseInt(v) });
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted">留空时由高级设置决定是否使用笔画权重</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* 模板下载 */}
|
||||
<a
|
||||
href={TEMPLATE_URL}
|
||||
className="text-link"
|
||||
onClick={e => e.preventDefault()}
|
||||
download
|
||||
>
|
||||
<IconDownload /> 下载词云表模板
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SSEProgress } from '../types';
|
||||
import { IconCross, IconCheckmark, IconGear } from './Icons';
|
||||
|
||||
interface ProgressPanelProps {
|
||||
progress: SSEProgress | null;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressPanel({ progress, visible }: ProgressPanelProps) {
|
||||
if (!visible || !progress) return null;
|
||||
|
||||
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
|
||||
const isDone = progress.stage === '完成';
|
||||
|
||||
return (
|
||||
<div className="progress-panel" style={isFailed ? { borderColor: 'var(--danger)' } : {}}>
|
||||
<div className="progress-title">
|
||||
{isFailed ? <><IconCross /> 生成失败</> : isDone ? <><IconCheckmark /> 生成完成</> : <><IconGear /> 词云生成中…</>}
|
||||
</div>
|
||||
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
|
||||
{progress.stage}
|
||||
</div>
|
||||
{!isFailed && (
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress.percent))}%`,
|
||||
background: isDone ? 'var(--success)' : 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="progress-message"
|
||||
style={{
|
||||
color: isFailed ? 'var(--danger)' : 'var(--text-muted)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: 1.5,
|
||||
marginTop: isFailed ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{progress.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
interface ViewControlsProps {
|
||||
zoom: number;
|
||||
viewMode: '2d' | '3d';
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomReset: () => void;
|
||||
onToggleView: () => void;
|
||||
}
|
||||
|
||||
export default function ViewControls({
|
||||
zoom, viewMode, onZoomIn, onZoomOut, onZoomReset, onToggleView
|
||||
}: ViewControlsProps) {
|
||||
return (
|
||||
<div className="view-controls">
|
||||
<button className="view-btn" title="缩小" onClick={onZoomOut}>−</button>
|
||||
<button className="view-btn" title="重置缩放" onClick={onZoomReset} style={{ fontSize: 10, width: 'auto', padding: '0 4px' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button className="view-btn" title="放大" onClick={onZoomIn}>+</button>
|
||||
<div className="view-divider" />
|
||||
<button
|
||||
className={`view-btn${viewMode === '2d' ? ' active' : ''}`}
|
||||
title="2D 视图"
|
||||
onClick={() => viewMode !== '2d' && onToggleView()}
|
||||
>2D</button>
|
||||
<button
|
||||
className={`view-btn${viewMode === '3d' ? ' active' : ''}`}
|
||||
title="3D 视图"
|
||||
onClick={() => viewMode !== '3d' && onToggleView()}
|
||||
>3D</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export function useResizablePanel(
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
minWidth: number,
|
||||
maxWidth: number,
|
||||
direction: 'left' | 'right'
|
||||
) {
|
||||
const getInitialWidth = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return Math.min(Math.max(parsed, minWidth), maxWidth);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore storage errors */ }
|
||||
return defaultWidth;
|
||||
};
|
||||
|
||||
const [width, setWidth] = useState(getInitialWidth);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(defaultWidth);
|
||||
|
||||
useEffect(() => {
|
||||
widthRef.current = width;
|
||||
}, [width]);
|
||||
|
||||
const handlePointerMove = useCallback((event: PointerEvent) => {
|
||||
const delta = direction === 'left'
|
||||
? event.clientX - startXRef.current
|
||||
: startXRef.current - event.clientX;
|
||||
let nextWidth = startWidthRef.current + delta;
|
||||
nextWidth = Math.max(minWidth, Math.min(nextWidth, maxWidth));
|
||||
setWidth(nextWidth);
|
||||
}, [direction, minWidth, maxWidth]);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
document.body.classList.remove('resizing');
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(widthRef.current));
|
||||
} catch { /* ignore storage errors */ }
|
||||
}, [handlePointerMove, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = handleRef.current;
|
||||
if (!handle) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
event.preventDefault();
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = widthRef.current;
|
||||
setIsDragging(true);
|
||||
document.body.classList.add('resizing');
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', onPointerDown);
|
||||
return () => {
|
||||
handle.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
}, [handlePointerMove, handlePointerUp]);
|
||||
|
||||
return { width, isDragging, handleRef };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
CanvasDocument,
|
||||
CanvasElement,
|
||||
CanvasLayer,
|
||||
CanvasLayerFolder,
|
||||
} from '../types';
|
||||
|
||||
export const DPI = 96;
|
||||
export const MM_PER_INCH = 25.4;
|
||||
export const DEFAULT_LAYER_ID = 'layer-default';
|
||||
|
||||
export function mmToPx(mm: number) {
|
||||
return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI));
|
||||
}
|
||||
|
||||
export function pxToMm(px: number) {
|
||||
return (px / DPI) * MM_PER_INCH;
|
||||
}
|
||||
|
||||
export function formatMm(px: number) {
|
||||
return pxToMm(px).toFixed(1);
|
||||
}
|
||||
|
||||
export function makeId(prefix: string) {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function createDefaultDocument(): CanvasDocument {
|
||||
return {
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
background: '#ffffff',
|
||||
layers: [createDefaultLayer()],
|
||||
layerFolders: [],
|
||||
elements: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultLayer(): CanvasLayer {
|
||||
return {
|
||||
id: DEFAULT_LAYER_ID,
|
||||
name: '图层 1',
|
||||
visible: true,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDocument(input: CanvasDocument): CanvasDocument {
|
||||
const baseLayer = createDefaultLayer();
|
||||
const rawLayers = Array.isArray(input.layers) && input.layers.length > 0 ? input.layers : [baseLayer];
|
||||
const seen = new Set<string>();
|
||||
const layers: CanvasLayer[] = rawLayers
|
||||
.filter(layer => layer && typeof layer.id === 'string' && layer.id)
|
||||
.map((layer, index) => {
|
||||
const id = seen.has(layer.id) ? `${layer.id}-${index}` : layer.id;
|
||||
seen.add(id);
|
||||
return {
|
||||
id,
|
||||
name: layer.name || `图层 ${index + 1}`,
|
||||
visible: layer.visible !== false,
|
||||
locked: layer.locked === true,
|
||||
folderId: layer.folderId || (layer as CanvasLayer & { groupId?: string }).groupId || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (layers.length === 0) layers.push(baseLayer);
|
||||
if (!layers.some(layer => layer.id === DEFAULT_LAYER_ID)) {
|
||||
layers.unshift(baseLayer);
|
||||
}
|
||||
|
||||
const layerIds = new Set(layers.map(layer => layer.id));
|
||||
const fallbackLayerId = layers[0]?.id || DEFAULT_LAYER_ID;
|
||||
const elements: CanvasElement[] = (Array.isArray(input.elements) ? input.elements : []).map(element => ({
|
||||
...element,
|
||||
layerId: element.layerId && layerIds.has(element.layerId) ? element.layerId : fallbackLayerId,
|
||||
groupId: typeof element.groupId === 'string' && element.groupId.trim() ? element.groupId : undefined,
|
||||
}));
|
||||
|
||||
const layerFolders = normalizeFolders(
|
||||
input.layerFolders || (input as CanvasDocument & { layerGroups?: CanvasLayerFolder[] }).layerGroups || [],
|
||||
layers,
|
||||
);
|
||||
const folderIds = new Set(layerFolders.map(folder => folder.id));
|
||||
const nextLayers = layers.map(layer => {
|
||||
const { groupId: _legacyGroupId, ...cleanLayer } = layer as CanvasLayer & { groupId?: string };
|
||||
return {
|
||||
...cleanLayer,
|
||||
folderId: layer.folderId && folderIds.has(layer.folderId) ? layer.folderId : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
width: Number.isFinite(input.width) ? input.width : 1600,
|
||||
height: Number.isFinite(input.height) ? input.height : 1000,
|
||||
background: input.background || '#ffffff',
|
||||
layers: nextLayers,
|
||||
layerFolders,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFolders(folders: CanvasLayerFolder[], layers: CanvasLayer[]) {
|
||||
const layerIds = new Set(layers.map(layer => layer.id));
|
||||
return folders
|
||||
.filter(folder => folder && typeof folder.id === 'string' && folder.id)
|
||||
.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name || '未命名文件夹',
|
||||
layerIds: folder.layerIds.filter(layerId => layerIds.has(layerId)),
|
||||
collapsed: folder.collapsed === true,
|
||||
}));
|
||||
}
|
||||
|
||||
export function layerIsVisible(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
return !layer || layer.visible !== false;
|
||||
}
|
||||
|
||||
export function layerIsLocked(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
return layer?.locked === true;
|
||||
}
|
||||
|
||||
export function getLayerFolder(documentModel: CanvasDocument, layerId?: string) {
|
||||
const normalized = normalizeDocument(documentModel);
|
||||
const layer = normalized.layers?.find(item => item.id === layerId);
|
||||
if (!layer?.folderId) return null;
|
||||
return normalized.layerFolders?.find(folder => folder.id === layer.folderId) || null;
|
||||
}
|
||||
|
||||
export function cloneDocument(documentModel: CanvasDocument): CanvasDocument {
|
||||
return normalizeDocument(JSON.parse(JSON.stringify(documentModel)) as CanvasDocument);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { StickerAsset } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend-based sticker library
|
||||
// All sticker file content is stored on the backend via /api/assets.
|
||||
// Only tiny non-content metadata (tint) is kept in localStorage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STICKER_TINTS_KEY = 'wordcloud-sticker-tints';
|
||||
const STICKER_LIBRARY_EVENT = 'wordcloud-sticker-library-changed';
|
||||
|
||||
export const stickerLibraryEventName = STICKER_LIBRARY_EVENT;
|
||||
|
||||
// ── tint metadata helpers ──────────────────────────────────────────────────
|
||||
|
||||
function loadTints(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STICKER_TINTS_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveTints(tints: Record<string, string>) {
|
||||
localStorage.setItem(STICKER_TINTS_KEY, JSON.stringify(tints));
|
||||
}
|
||||
|
||||
function setTint(assetId: string, tint: string | undefined) {
|
||||
const tints = loadTints();
|
||||
if (tint) {
|
||||
tints[assetId] = tint;
|
||||
} else {
|
||||
delete tints[assetId];
|
||||
}
|
||||
saveTints(tints);
|
||||
}
|
||||
|
||||
function removeTint(assetId: string) {
|
||||
const tints = loadTints();
|
||||
delete tints[assetId];
|
||||
saveTints(tints);
|
||||
}
|
||||
|
||||
// ── content helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function svgToBlob(svg: string): Blob {
|
||||
return new Blob([svg], { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const [header, b64] = dataUrl.split(',');
|
||||
const mime = header.match(/:(.*?);/)?.[1] ?? 'application/octet-stream';
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
function extForType(type: 'svg' | 'image', source: string): string {
|
||||
if (type === 'svg') return '.svg';
|
||||
if (source.startsWith('data:image/png')) return '.png';
|
||||
if (source.startsWith('data:image/jpeg') || source.startsWith('data:image/jpg')) return '.jpg';
|
||||
return '.png';
|
||||
}
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
interface BackendAsset {
|
||||
asset_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mime_type: string;
|
||||
file_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function apiListAssets(): Promise<BackendAsset[]> {
|
||||
const res = await fetch('/api/assets?type=sticker');
|
||||
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiUploadAsset(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
name: string,
|
||||
): Promise<BackendAsset> {
|
||||
const form = new FormData();
|
||||
form.append('file', blob, filename);
|
||||
form.append('name', name);
|
||||
form.append('type', 'sticker');
|
||||
const res = await fetch('/api/assets', { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(`upload asset failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDeleteAsset(assetId: string): Promise<void> {
|
||||
const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadStickerLibrary(): Promise<StickerAsset[]> {
|
||||
const [assets, tints] = await Promise.all([
|
||||
apiListAssets(),
|
||||
Promise.resolve(loadTints()),
|
||||
]);
|
||||
return assets.map((a): StickerAsset => ({
|
||||
id: a.asset_id,
|
||||
name: a.name,
|
||||
type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image',
|
||||
source: a.file_url,
|
||||
createdAt: a.created_at,
|
||||
tint: tints[a.asset_id] as StickerAsset['tint'],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addStickerAsset(
|
||||
input: Omit<StickerAsset, 'id' | 'createdAt'>,
|
||||
): Promise<StickerAsset> {
|
||||
const ext = extForType(input.type, input.source);
|
||||
const blob =
|
||||
input.type === 'svg'
|
||||
? svgToBlob(input.source)
|
||||
: dataUrlToBlob(input.source);
|
||||
const filename = `sticker${ext}`;
|
||||
const asset = await apiUploadAsset(blob, filename, input.name);
|
||||
if (input.tint) setTint(asset.asset_id, input.tint);
|
||||
const sticker: StickerAsset = {
|
||||
id: asset.asset_id,
|
||||
name: asset.name,
|
||||
type: input.type,
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
tint: input.tint,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
}
|
||||
|
||||
export async function deleteStickerAsset(id: string): Promise<void> {
|
||||
await apiDeleteAsset(id);
|
||||
removeTint(id);
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function assetToDataUrl(asset: StickerAsset) {
|
||||
// source is now a backend URL; return it directly
|
||||
return asset.source;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument, pxToMm } from './canvasDocument';
|
||||
import { createZip } from './zip';
|
||||
|
||||
export interface SerializeOptions {
|
||||
layerIds?: string[];
|
||||
includeBackground?: boolean;
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveStickerHref(asset: StickerAsset): Promise<string> {
|
||||
// Legacy inline content (still supported for imported files / tests)
|
||||
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
|
||||
}
|
||||
if (asset.source.startsWith('data:')) {
|
||||
return asset.source;
|
||||
}
|
||||
// Backend URL: fetch and inline so the exported SVG is self-contained
|
||||
return fetchBlobAsDataUrl(asset.source);
|
||||
}
|
||||
|
||||
export async function serializeDocument(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
options: SerializeOptions = {},
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const layerFilter = options.layerIds ? new Set(options.layerIds) : null;
|
||||
const visibleLayers = new Set((doc.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id));
|
||||
const widthMm = pxToMm(doc.width).toFixed(1);
|
||||
const heightMm = pxToMm(doc.height).toFixed(1);
|
||||
const parts = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
||||
];
|
||||
|
||||
if (options.includeBackground !== false) {
|
||||
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
|
||||
}
|
||||
|
||||
for (const element of doc.elements) {
|
||||
const layerId = element.layerId || doc.layers?.[0]?.id;
|
||||
if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue;
|
||||
if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue;
|
||||
|
||||
const transform = `translate(${element.x} ${element.y}) rotate(${element.rotation} ${element.width / 2} ${element.height / 2})`;
|
||||
const opacity = Number.isFinite(element.opacity) ? element.opacity : 1;
|
||||
|
||||
if (element.type === 'sticker') {
|
||||
const asset = stickerById.get(element.assetId);
|
||||
if (!asset) continue;
|
||||
const href = await resolveStickerHref(asset);
|
||||
const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : '';
|
||||
parts.push(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'text') {
|
||||
parts.push(
|
||||
`<text x="0" y="${element.fontSize}" fill="${escapeXml(element.fill)}" font-size="${element.fontSize}" font-family="${escapeXml(element.fontFamily)}" font-weight="${escapeXml(element.fontWeight)}" opacity="${opacity}" transform="${transform}">${escapeXml(element.text)}</text>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'rect') {
|
||||
parts.push(`<rect x="0" y="0" width="${element.width}" height="${element.height}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === 'ellipse') {
|
||||
parts.push(`<ellipse cx="${element.width / 2}" cy="${element.height / 2}" rx="${element.width / 2}" ry="${element.height / 2}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
|
||||
}
|
||||
|
||||
parts.push('</svg>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export async function createLayerExportZip(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
selectedLayerIds: string[],
|
||||
selectedFolderIds: string[],
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const files: { name: string; content: string }[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
for (const layerId of selectedLayerIds) {
|
||||
const layer = doc.layers?.find(item => item.id === layerId);
|
||||
if (!layer) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(layer.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
|
||||
});
|
||||
}
|
||||
|
||||
for (const folderId of selectedFolderIds) {
|
||||
const folder = doc.layerFolders?.find(item => item.id === folderId);
|
||||
if (!folder) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(folder.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
|
||||
});
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: uniqueSvgName('总效果', used),
|
||||
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
|
||||
});
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
|
||||
function uniqueSvgName(name: string, used: Map<string, number>) {
|
||||
const base = sanitizeFileName(name || '未命名');
|
||||
const count = used.get(base) || 0;
|
||||
used.set(base, count + 1);
|
||||
return `${base}${count > 0 ? `-${count + 1}` : ''}.svg`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || '未命名';
|
||||
}
|
||||
|
||||
export function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
|
||||
import { cloneDocument, normalizeDocument } from './canvasDocument';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export function templateId(template: CanvasTemplate) {
|
||||
return template.template_id || template.id || '';
|
||||
}
|
||||
|
||||
export function templateCreatedAt(template: CanvasTemplate) {
|
||||
return template.created_at || template.createdAt || '';
|
||||
}
|
||||
|
||||
export function templateUpdatedAt(template: CanvasTemplate) {
|
||||
return template.updated_at || template.updatedAt || '';
|
||||
}
|
||||
|
||||
export function templateReferenceIds(template: CanvasTemplate) {
|
||||
return template.reference_asset_ids || template.referenceAssetIds || [];
|
||||
}
|
||||
|
||||
export function templateCoverId(template: CanvasTemplate) {
|
||||
return template.cover_asset_id || template.coverAssetId || templateReferenceIds(template)[0] || '';
|
||||
}
|
||||
|
||||
export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
|
||||
const res = await fetch(`${API_BASE}/api/design-templates`);
|
||||
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
|
||||
const items = (await res.json()) as CanvasTemplate[];
|
||||
return items.map(item => ({ ...item, document: normalizeDocument(item.document) }));
|
||||
}
|
||||
|
||||
export async function createCanvasTemplate(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
document: CanvasDocument;
|
||||
referenceAssetIds?: string[];
|
||||
coverAssetId?: string;
|
||||
}): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('name', input.name.trim() || '未命名模板');
|
||||
fd.append('description', input.description.trim());
|
||||
fd.append('document', JSON.stringify(normalizeDocument(input.document)));
|
||||
fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || []));
|
||||
fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || '');
|
||||
const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`保存模板失败 (${res.status})`);
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function updateCanvasTemplate(
|
||||
id: string,
|
||||
partial: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
document?: CanvasDocument;
|
||||
referenceAssetIds?: string[];
|
||||
coverAssetId?: string;
|
||||
},
|
||||
): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
if (partial.name !== undefined) fd.append('name', partial.name.trim() || '未命名模板');
|
||||
if (partial.description !== undefined) fd.append('description', partial.description.trim());
|
||||
if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document)));
|
||||
if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds));
|
||||
if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId);
|
||||
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'PATCH', body: fd });
|
||||
if (!res.ok) throw new Error(`更新模板失败 (${res.status})`);
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function deleteCanvasTemplate(id: string) {
|
||||
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`删除模板失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export async function listAssets(type = ''): Promise<BackendAsset[]> {
|
||||
const query = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/assets${query}`);
|
||||
if (!res.ok) throw new Error(`读取素材失败 (${res.status})`);
|
||||
return (await res.json()) as BackendAsset[];
|
||||
}
|
||||
|
||||
export async function uploadAsset(file: File, type = 'reference'): Promise<BackendAsset> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, ''));
|
||||
fd.append('type', type);
|
||||
const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`);
|
||||
return (await res.json()) as BackendAsset;
|
||||
}
|
||||
|
||||
export function assetUrl(assetOrPath?: BackendAsset | string) {
|
||||
if (!assetOrPath) return '';
|
||||
const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url;
|
||||
if (!raw) return '';
|
||||
return raw.startsWith('http') ? raw : `${API_BASE}${raw}`;
|
||||
}
|
||||
|
||||
export function duplicateDocument(document: CanvasDocument): CanvasDocument {
|
||||
return cloneDocument(document);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface ZipFileInput {
|
||||
name: string;
|
||||
content: string | Uint8Array;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function createZip(files: ZipFileInput[]): Blob {
|
||||
const localParts: Uint8Array[] = [];
|
||||
const centralParts: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
files.forEach(file => {
|
||||
const nameBytes = encoder.encode(file.name);
|
||||
const data = typeof file.content === 'string' ? encoder.encode(file.content) : file.content;
|
||||
const crc = crc32(data);
|
||||
const local = concatBytes([
|
||||
u32(0x04034b50),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(crc),
|
||||
u32(data.length),
|
||||
u32(data.length),
|
||||
u16(nameBytes.length),
|
||||
u16(0),
|
||||
nameBytes,
|
||||
data,
|
||||
]);
|
||||
localParts.push(local);
|
||||
|
||||
const central = concatBytes([
|
||||
u32(0x02014b50),
|
||||
u16(20),
|
||||
u16(20),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(crc),
|
||||
u32(data.length),
|
||||
u32(data.length),
|
||||
u16(nameBytes.length),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u32(0),
|
||||
u32(offset),
|
||||
nameBytes,
|
||||
]);
|
||||
centralParts.push(central);
|
||||
offset += local.length;
|
||||
});
|
||||
|
||||
const centralOffset = offset;
|
||||
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
|
||||
const end = concatBytes([
|
||||
u32(0x06054b50),
|
||||
u16(0),
|
||||
u16(0),
|
||||
u16(files.length),
|
||||
u16(files.length),
|
||||
u32(centralSize),
|
||||
u32(centralOffset),
|
||||
u16(0),
|
||||
]);
|
||||
|
||||
const bytes = concatBytes([...localParts, ...centralParts, end]);
|
||||
const buffer = new ArrayBuffer(bytes.length);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return new Blob([buffer], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
function u16(value: number) {
|
||||
const out = new Uint8Array(2);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint16(0, value, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function u32(value: number) {
|
||||
const out = new Uint8Array(4);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(0, value >>> 0, true);
|
||||
return out;
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]) {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let cursor = 0;
|
||||
parts.forEach(part => {
|
||||
out.set(part, cursor);
|
||||
cursor += part.length;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
let crcTable: Uint32Array | null = null;
|
||||
|
||||
function crc32(data: Uint8Array) {
|
||||
const table = crcTable || buildCrcTable();
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function buildCrcTable() {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[i] = c >>> 0;
|
||||
}
|
||||
crcTable = table;
|
||||
return table;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BackendAsset, CanvasTemplate } from '../types';
|
||||
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
|
||||
import {
|
||||
assetUrl,
|
||||
deleteCanvasTemplate,
|
||||
listAssets,
|
||||
listDesignTemplates,
|
||||
templateCoverId,
|
||||
templateId,
|
||||
templateReferenceIds,
|
||||
templateUpdatedAt,
|
||||
} from '../lib/templateLibrary';
|
||||
import { serializeDocument } from '../lib/svgExport';
|
||||
import { loadStickerLibrary } from '../lib/stickerLibrary';
|
||||
import {
|
||||
IconGrid,
|
||||
IconCloud,
|
||||
IconRefresh,
|
||||
IconCanvas,
|
||||
} from '../components/Icons';
|
||||
|
||||
interface TemplateHomeProps {
|
||||
onCreateBlank: () => void;
|
||||
onUseTemplate: (template: CanvasTemplate) => void;
|
||||
onOpenCanvas: () => void;
|
||||
onOpenWordcloud: () => void;
|
||||
}
|
||||
|
||||
export default function TemplateHome({
|
||||
onCreateBlank,
|
||||
onUseTemplate,
|
||||
onOpenCanvas,
|
||||
onOpenWordcloud,
|
||||
}: TemplateHomeProps) {
|
||||
const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
|
||||
const [assets, setAssets] = useState<BackendAsset[]>([]);
|
||||
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
|
||||
|
||||
useEffect(() => {
|
||||
loadStickerLibrary().then(items => {
|
||||
const map = new Map<string, import('../types').StickerAsset>();
|
||||
items.forEach(asset => map.set(asset.id, asset));
|
||||
setStickerById(map);
|
||||
});
|
||||
}, [templates]);
|
||||
|
||||
const assetById = useMemo(() => {
|
||||
const map = new Map<string, BackendAsset>();
|
||||
assets.forEach(asset => map.set(asset.asset_id, asset));
|
||||
return map;
|
||||
}, [assets]);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [templateItems, assetItems] = await Promise.all([
|
||||
listDesignTemplates(),
|
||||
listAssets(),
|
||||
]);
|
||||
setTemplates(templateItems);
|
||||
setAssets(assetItems);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '读取模板失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const removeSelected = async () => {
|
||||
if (!selected) return;
|
||||
await deleteCanvasTemplate(templateId(selected));
|
||||
setSelected(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="template-home">
|
||||
<nav className="navbar">
|
||||
<div className="navbar-brand">
|
||||
<div className="navbar-brand-icon"><IconGrid /></div>
|
||||
<span className="navbar-brand-name">模板库</span>
|
||||
</div>
|
||||
<div className="navbar-actions">
|
||||
<button className="nav-btn active" onClick={refresh}>
|
||||
<span className="nav-btn-icon"><IconRefresh /></span>
|
||||
<span className="nav-btn-label">刷新</span>
|
||||
</button>
|
||||
<button className="nav-btn" onClick={onOpenCanvas}>
|
||||
<span className="nav-btn-icon"><IconCanvas /></span>
|
||||
<span className="nav-btn-label">画布</span>
|
||||
</button>
|
||||
<button className="nav-btn" onClick={onOpenWordcloud}>
|
||||
<span className="nav-btn-icon"><IconCloud /></span>
|
||||
<span className="nav-btn-label">词云</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<button className="btn btn-primary btn-sm" onClick={onCreateBlank}>新建设计</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="template-home-main">
|
||||
{loading && <div className="table-empty">正在读取模板库</div>}
|
||||
{error && <div className="table-empty">{error}</div>}
|
||||
{!loading && templates.length === 0 && (
|
||||
<div className="template-empty">
|
||||
<div className="template-empty-title">还没有保存的模板</div>
|
||||
<button className="btn btn-primary" onClick={onCreateBlank}>创建第一个设计</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="template-masonry">
|
||||
{templates.map(template => (
|
||||
<TemplateMasonryCard
|
||||
key={templateId(template)}
|
||||
template={template}
|
||||
cover={assetById.get(templateCoverId(template))}
|
||||
stickerById={stickerById}
|
||||
onClick={() => setSelected(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{selected && (
|
||||
<TemplateModal
|
||||
template={selected}
|
||||
assetById={assetById}
|
||||
stickerById={stickerById}
|
||||
onUse={() => onUseTemplate(selected)}
|
||||
onClose={() => setSelected(null)}
|
||||
onDelete={removeSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
template,
|
||||
assetById,
|
||||
stickerById,
|
||||
onUse,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
template: CanvasTemplate;
|
||||
assetById: Map<string, BackendAsset>;
|
||||
stickerById: Map<string, import('../types').StickerAsset>;
|
||||
onUse: () => void;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
// Build slide list: cover first, then reference images
|
||||
const slides = useMemo(() => {
|
||||
const coverId = templateCoverId(template);
|
||||
const cover = assetById.get(coverId);
|
||||
const refs = templateReferenceIds(template)
|
||||
.filter(id => id !== coverId)
|
||||
.map(id => assetById.get(id))
|
||||
.filter((a): a is BackendAsset => !!a);
|
||||
return cover ? [cover, ...refs] : refs;
|
||||
}, [template, assetById]);
|
||||
|
||||
const [idx, setIdx] = useState(0);
|
||||
const safeIdx = Math.min(idx, Math.max(0, slides.length - 1));
|
||||
|
||||
const prev = () => setIdx(i => Math.max(0, i - 1));
|
||||
const next = () => setIdx(i => Math.min(slides.length - 1, i + 1));
|
||||
|
||||
const [largeUrl, setLargeUrl] = useState<string>(slides.length > 0 ? assetUrl(slides[safeIdx]) : '');
|
||||
useEffect(() => {
|
||||
if (slides.length > 0) {
|
||||
setLargeUrl(assetUrl(slides[safeIdx]));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
serializeDocument(normalizeDocument(template.document), stickerById, { includeBackground: true }).then(svg => {
|
||||
if (!cancelled) setLargeUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [slides, safeIdx, template, stickerById]);
|
||||
|
||||
return (
|
||||
<div className="template-modal-backdrop" onClick={onClose}>
|
||||
<div className="template-modal" onClick={e => e.stopPropagation()}>
|
||||
{/* Large preview with prev/next arrows */}
|
||||
<div className="template-preview large" style={{ position: 'relative' }}>
|
||||
<img src={largeUrl} alt={template.name} />
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="slide-arrow slide-arrow-prev"
|
||||
onClick={prev}
|
||||
disabled={safeIdx === 0}
|
||||
>‹</button>
|
||||
<button
|
||||
className="slide-arrow slide-arrow-next"
|
||||
onClick={next}
|
||||
disabled={safeIdx === slides.length - 1}
|
||||
>›</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="template-modal-body">
|
||||
<div className="template-title large">{template.name}</div>
|
||||
{template.description && <p className="template-description">{template.description}</p>}
|
||||
<div className="template-meta modal-meta">
|
||||
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
|
||||
<span>{template.document.elements.length} 个元素</span>
|
||||
<span>{formatDate(templateUpdatedAt(template))}</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{slides.length > 1 && (
|
||||
<div className="reference-strip">
|
||||
{slides.map((asset, i) => (
|
||||
<img
|
||||
key={asset.asset_id}
|
||||
src={assetUrl(asset)}
|
||||
alt={asset.name}
|
||||
className={i === safeIdx ? 'active' : ''}
|
||||
onClick={() => setIdx(i)}
|
||||
style={{ cursor: 'pointer', outline: i === safeIdx ? '2px solid var(--color-primary, #6c63ff)' : 'none', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-primary" onClick={onUse}>使用模板</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>关闭</button>
|
||||
<button className="btn btn-danger" onClick={onDelete}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateMasonryCard({
|
||||
template,
|
||||
cover,
|
||||
stickerById,
|
||||
onClick,
|
||||
}: {
|
||||
template: CanvasTemplate;
|
||||
cover?: BackendAsset;
|
||||
stickerById: Map<string, import('../types').StickerAsset>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="template-masonry-card" onClick={onClick}>
|
||||
<TemplatePreview template={template} cover={cover} stickerById={stickerById} />
|
||||
<div className="template-masonry-info">
|
||||
<div className="template-title">{template.name}</div>
|
||||
{template.description && <div className="template-description">{template.description}</div>}
|
||||
<div className="template-meta">
|
||||
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
|
||||
<span>{formatDate(templateUpdatedAt(template))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreview({
|
||||
template,
|
||||
cover,
|
||||
stickerById,
|
||||
large = false,
|
||||
}: {
|
||||
template: CanvasTemplate;
|
||||
cover?: BackendAsset;
|
||||
stickerById: Map<string, import('../types').StickerAsset>;
|
||||
large?: boolean;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string>(cover ? assetUrl(cover) : '');
|
||||
useEffect(() => {
|
||||
if (cover) {
|
||||
setPreviewUrl(assetUrl(cover));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
serializeDocument(normalizeDocument(template.document), stickerById, { includeBackground: true }).then(svg => {
|
||||
if (!cancelled) setPreviewUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [cover, stickerById, template]);
|
||||
|
||||
return (
|
||||
<div className={`template-preview${large ? ' large' : ''}`}>
|
||||
<img src={previewUrl} alt={template.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '未知时间';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
NameEntry,
|
||||
JobParams,
|
||||
JobResult,
|
||||
SSEProgress,
|
||||
PanelType,
|
||||
NameLocation,
|
||||
Font,
|
||||
WordcloudMaskSource,
|
||||
WordcloudStickerPayload,
|
||||
} from '../types';
|
||||
import ImportPanel from '../components/ImportPanel';
|
||||
import ExportPanel from '../components/ExportPanel';
|
||||
import EditPanel from '../components/EditPanel';
|
||||
import FindPanel from '../components/FindPanel';
|
||||
import AdvancedPanel from '../components/AdvancedPanel';
|
||||
import ProgressPanel from '../components/ProgressPanel';
|
||||
import CanvasArea from '../components/CanvasArea';
|
||||
import ViewControls from '../components/ViewControls';
|
||||
import { useResizablePanel } from '../hooks/useResizablePanel';
|
||||
import {
|
||||
IconImport,
|
||||
IconExport,
|
||||
IconEdit,
|
||||
IconFind,
|
||||
IconSettings,
|
||||
IconCloud,
|
||||
} from '../components/Icons';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
const DEFAULT_PARAMS: JobParams = {
|
||||
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, // 根据笔画复杂度调整权重
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
id: NonNullable<PanelType>;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'import', label: '导入', icon: <IconImport /> },
|
||||
{ id: 'export', label: '导出', icon: <IconExport /> },
|
||||
{ id: 'edit', label: '修改', icon: <IconEdit /> },
|
||||
{ id: 'find', label: '查找', icon: <IconFind /> },
|
||||
{ id: 'advanced', label: '高级', icon: <IconSettings /> },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: { id: ThemeMode; label: string }[] = [
|
||||
{ id: 'light', label: '浅色' },
|
||||
{ id: 'dark', label: '深色' },
|
||||
{ id: 'system', label: '系统' },
|
||||
];
|
||||
|
||||
interface TestWorkbenchProps {
|
||||
onOpenCanvas?: () => void;
|
||||
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
|
||||
}
|
||||
|
||||
const getStoredTheme = (): ThemeMode => {
|
||||
const stored = window.localStorage.getItem('wordcloud-theme');
|
||||
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
|
||||
};
|
||||
|
||||
const getSystemTheme = () =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }: TestWorkbenchProps) {
|
||||
const { width: panelWidth, handleRef } = useResizablePanel('wb-panel-width', 240, 180, 400, 'left');
|
||||
const [maskFile, setMaskFile] = useState<File | null>(null);
|
||||
const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null);
|
||||
const [maskSource, setMaskSource] = useState<WordcloudMaskSource | null>(null);
|
||||
const [namesFile, setNamesFile] = useState<File | null>(null);
|
||||
const [nameEntries, setNameEntries] = useState<NameEntry[]>([]);
|
||||
const [params, setParams] = useState<JobParams>(DEFAULT_PARAMS);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobResult, setJobResult] = useState<JobResult | null>(null);
|
||||
const [progress, setProgress] = useState<SSEProgress | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [activePanel, setActivePanel] = useState<PanelType>('import');
|
||||
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
|
||||
const [fonts, setFonts] = useState<Font[]>([]);
|
||||
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.dataset.themeMode = themeMode;
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
window.localStorage.setItem('wordcloud-theme', themeMode);
|
||||
}, [themeMode, systemTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setSystemTheme(event.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
setSystemTheme(media.matches ? 'dark' : 'light');
|
||||
media.addEventListener('change', handleChange);
|
||||
return () => media.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
// ─── 字体列表加载 ─────────────────────────────────────────────────────────
|
||||
const fetchFonts = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/fonts`);
|
||||
if (res.ok) {
|
||||
const data: Font[] = await res.json();
|
||||
setFonts(data);
|
||||
}
|
||||
} catch (e) { console.error('fetchFonts error:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchFonts(); }, [fetchFonts]);
|
||||
|
||||
const handleFontUpload = useCallback(async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('name', file.name.replace(/\.(ttf|ttc|otf)$/i, ''));
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/fonts`, { method: 'POST', body: fd });
|
||||
if (res.ok) {
|
||||
const font: Font = await res.json();
|
||||
setFonts(prev => [font, ...prev]);
|
||||
setSelectedFontId(font.font_id);
|
||||
}
|
||||
} catch (e) { console.error('font upload error:', e); }
|
||||
}, []);
|
||||
|
||||
const handleFontDelete = useCallback(async (fontId: string) => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/fonts/${fontId}`, { method: 'DELETE' });
|
||||
setFonts(prev => prev.filter(f => f.font_id !== fontId));
|
||||
setSelectedFontId('__default__');
|
||||
} catch (e) { console.error('font delete error:', e); }
|
||||
}, []);
|
||||
|
||||
// ─── Excel 本地预览解析 ───────────────────────────────────────────────────
|
||||
// 仅用于"修改"面板展示;实际生成时后端直接读文件,以后端解析为准。
|
||||
const parseExcel = useCallback(async (
|
||||
file: File,
|
||||
dataColIndex: number, // 0-based
|
||||
headerRow: number, // 1-based,数据从 headerRow 之后一行开始
|
||||
) => {
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1 });
|
||||
|
||||
// 数据从 headerRow 行(0-based = headerRow)开始
|
||||
const startIdx = Math.max(0, headerRow);
|
||||
const colIdx = dataColIndex;
|
||||
|
||||
const entries: NameEntry[] = (rows as unknown[][])
|
||||
.slice(startIdx)
|
||||
.filter(row => row[colIdx] !== undefined && String(row[colIdx]).trim() !== '')
|
||||
.map(row => ({
|
||||
group: String(row[0] ?? ''),
|
||||
name: String(row[colIdx] ?? ''),
|
||||
weight: parseInt(String(row[colIdx + 1] ?? '1')) || 1,
|
||||
}));
|
||||
|
||||
setNameEntries(entries);
|
||||
} catch (err) {
|
||||
console.error('Excel parse error:', err);
|
||||
setNameEntries([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMaskChange = useCallback(async (file: File | null) => {
|
||||
setMaskFile(file);
|
||||
setMaskSubmitFile(null);
|
||||
setMaskSource(null);
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const isSvg = file.type === 'image/svg+xml' || /\.svg$/i.test(file.name);
|
||||
if (isSvg) {
|
||||
const svg = await file.text();
|
||||
const png = await svgToPngFile(svg, file.name);
|
||||
setMaskSubmitFile(png);
|
||||
setMaskSource({
|
||||
name: file.name.replace(/\.svg$/i, ''),
|
||||
type: 'svg',
|
||||
source: svg,
|
||||
});
|
||||
} else {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
setMaskSubmitFile(file);
|
||||
setMaskSource({
|
||||
name: file.name.replace(/\.(png|jpe?g)$/i, ''),
|
||||
type: 'image',
|
||||
source: dataUrl,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error instanceof Error ? error.message : '底图处理失败');
|
||||
setMaskFile(null);
|
||||
setMaskSubmitFile(null);
|
||||
setMaskSource(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNamesChange = useCallback(async (file: File | null) => {
|
||||
setNamesFile(file);
|
||||
if (!file) { setNameEntries([]); return; }
|
||||
await parseExcel(file, params.dataColIndex, params.headerRow);
|
||||
}, [params.dataColIndex, params.headerRow, parseExcel]);
|
||||
|
||||
// 参数变更时如果文件已存在则重新预览解析
|
||||
const handleParamsChange = useCallback((partial: Partial<JobParams>) => {
|
||||
setParams(prev => {
|
||||
const next = { ...prev, ...partial };
|
||||
if (
|
||||
namesFile &&
|
||||
(partial.dataColIndex !== undefined || partial.headerRow !== undefined)
|
||||
) {
|
||||
parseExcel(namesFile, next.dataColIndex, next.headerRow);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [namesFile, parseExcel]);
|
||||
|
||||
const handleNavClick = (panel: PanelType) => {
|
||||
setActivePanel(prev => prev === panel ? null : panel);
|
||||
};
|
||||
|
||||
// ─── 生成任务提交 ─────────────────────────────────────────────────────────
|
||||
const handleGenerate = async () => {
|
||||
if (isGenerating) return;
|
||||
if (!namesFile) {
|
||||
alert('请先导入名单(.xlsx)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
|
||||
setJobResult(null);
|
||||
setHighlightLocation(null);
|
||||
|
||||
try {
|
||||
// ── 组装 params JSON(对应后端 config 别名键)──────────────────────
|
||||
// 参见 README 4.8.3 / 4.8.10
|
||||
const paramsObj: Record<string, unknown> = {
|
||||
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
|
||||
DATA_COL_INDEX: params.dataColIndex,
|
||||
};
|
||||
if (params.seed !== null) {
|
||||
paramsObj.SEED = params.seed; // 全局随机种子
|
||||
}
|
||||
if (params.weightColIndex !== null) {
|
||||
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列
|
||||
}
|
||||
if (params.fontColor) {
|
||||
paramsObj.FONT_COLOR = params.fontColor;
|
||||
}
|
||||
if (params.nRepetitions > 1) {
|
||||
paramsObj.N_REPETITIONS = params.nRepetitions; // 词语重复填充次数
|
||||
}
|
||||
if (!params.strokeWeights) {
|
||||
paramsObj.ENABLE_STROKE_WEIGHTS = false; // 关闭笔画权重
|
||||
}
|
||||
|
||||
// ── FormData(字段名严格按 README 6.5)────────────────────────────
|
||||
// name_list : 必填 xlsx
|
||||
// mask_image : IMAGE 模式必填
|
||||
// font_file : 可选自定义字体
|
||||
// params : JSON 字符串
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name_list', namesFile);
|
||||
if (maskSubmitFile) {
|
||||
formData.append('mask_image', maskSubmitFile);
|
||||
}
|
||||
if (selectedFontId) {
|
||||
formData.append('font_id', selectedFontId);
|
||||
}
|
||||
formData.append('params', JSON.stringify(paramsObj));
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/jobs`, { method: 'POST', body: formData });
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new Error(`提交失败 (${res.status}): ${errText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// README 6.5 响应包含 job_id
|
||||
const id: string = data.job_id ?? data.id;
|
||||
if (!id) throw new Error('后端未返回 job_id,请检查接口响应');
|
||||
setJobId(id);
|
||||
|
||||
// ── SSE 监听进度 ───────────────────────────────────────────────────
|
||||
// GET /api/jobs/{job_id}/events
|
||||
sseRef.current?.close();
|
||||
const sse = new EventSource(`${API_BASE}/api/jobs/${id}/events`);
|
||||
sseRef.current = sse;
|
||||
|
||||
sse.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
setProgress({
|
||||
stage: msg.stage ?? '',
|
||||
percent: msg.progress_percent ?? 0,
|
||||
message: msg.message ?? '',
|
||||
});
|
||||
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
|
||||
sse.close();
|
||||
fetchResult(id);
|
||||
}
|
||||
} catch { /* ignore SSE parse errors */ }
|
||||
};
|
||||
|
||||
sse.onerror = () => {
|
||||
sse.close();
|
||||
fetchResult(id);
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const msg = err instanceof Error ? err.message : '未知错误';
|
||||
setProgress({ stage: '错误', percent: 0, message: msg });
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 获取结果 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/jobs/{job_id}/result
|
||||
const fetchResult = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/jobs/${id}/result`);
|
||||
if (!res.ok) throw new Error(`获取结果失败 (${res.status})`);
|
||||
const data: JobResult = await res.json();
|
||||
|
||||
if (data.status === 'failed') {
|
||||
// 尝试从 /detail 拿更详细的错误信息
|
||||
let detail = '';
|
||||
try {
|
||||
const dr = await fetch(`${API_BASE}/api/jobs/${id}/detail`);
|
||||
if (dr.ok) {
|
||||
const dd = await dr.json();
|
||||
detail = dd.error ?? dd.message ?? '';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setProgress({
|
||||
stage: '生成失败',
|
||||
percent: 0,
|
||||
message: `任务失败${detail ? ':' + detail : ''}。可用 docker logs 查看后端堆栈。`,
|
||||
});
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片 URL:优先用 result 里的字段,降级到 files/png 路由
|
||||
// README 6.5 列出了 /files/{kind},kind 对应输出文件类型
|
||||
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
|
||||
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
|
||||
|
||||
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
|
||||
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
|
||||
setTimeout(() => setProgress(null), 3000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '未知错误';
|
||||
setProgress({ stage: '错误', percent: 0, message: msg });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocate = (loc: NameLocation) => {
|
||||
setHighlightLocation(loc);
|
||||
setViewMode('2d');
|
||||
setTimeout(() => setHighlightLocation(null), 3000);
|
||||
};
|
||||
|
||||
const panelOpen = activePanel !== null;
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* ===== NAVBAR ===== */}
|
||||
<nav className="navbar">
|
||||
<div className="navbar-brand">
|
||||
<div className="navbar-brand-icon"><IconCloud /></div>
|
||||
<span className="navbar-brand-name">词云工具</span>
|
||||
</div>
|
||||
<div className="navbar-actions">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`nav-btn${activePanel === item.id ? ' active' : ''}`}
|
||||
onClick={() => handleNavClick(item.id as PanelType)}
|
||||
>
|
||||
<span className="nav-btn-icon">{item.icon}</span>
|
||||
<span className="nav-btn-label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
{onOpenCanvas && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={onOpenCanvas}>返回画布</button>
|
||||
)}
|
||||
<div className="theme-switch" aria-label="主题模式">
|
||||
{THEME_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`theme-btn${themeMode === option.id ? ' active' : ''}`}
|
||||
title={
|
||||
option.id === 'system'
|
||||
? `跟随系统(当前${systemTheme === 'dark' ? '深色' : '浅色'})`
|
||||
: `${option.label}模式`
|
||||
}
|
||||
aria-pressed={themeMode === option.id}
|
||||
onClick={() => setThemeMode(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ===== MAIN ===== */}
|
||||
<div className="main-content">
|
||||
{/* ===== SIDE PANEL ===== */}
|
||||
<aside className={`side-panel${panelOpen ? '' : ' collapsed'}`} style={panelOpen ? { width: panelWidth } : undefined}>
|
||||
<div className="side-panel-inner">
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{activePanel === 'import' && (
|
||||
<ImportPanel
|
||||
maskFile={maskFile}
|
||||
namesFile={namesFile}
|
||||
params={params}
|
||||
fonts={fonts}
|
||||
selectedFontId={selectedFontId}
|
||||
onMaskChange={handleMaskChange}
|
||||
onNamesChange={handleNamesChange}
|
||||
onParamsChange={handleParamsChange}
|
||||
onFontUpload={handleFontUpload}
|
||||
onFontDelete={handleFontDelete}
|
||||
onFontSelect={setSelectedFontId}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'export' && (
|
||||
<ExportPanel
|
||||
jobId={jobId}
|
||||
apiBase={API_BASE}
|
||||
svgUrl={jobResult?.svg_url}
|
||||
imageUrl={jobResult?.image_url}
|
||||
onOpenCanvas={onOpenCanvas}
|
||||
maskSource={maskSource}
|
||||
onImportWordcloudSticker={onImportWordcloudSticker}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'edit' && (
|
||||
<EditPanel
|
||||
entries={nameEntries}
|
||||
onEntriesChange={setNameEntries}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'find' && (
|
||||
<FindPanel
|
||||
jobId={jobId}
|
||||
apiBase={API_BASE}
|
||||
onLocate={handleLocate}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'advanced' && (
|
||||
<AdvancedPanel
|
||||
params={params}
|
||||
onParamsChange={handleParamsChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成按钮固定在面板底部 */}
|
||||
<div className="panel-footer">
|
||||
<button
|
||||
className="btn-generate"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating
|
||||
? <><span className="spinner" />处理中</>
|
||||
: '生成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-resize-handle" ref={handleRef} />
|
||||
</aside>
|
||||
|
||||
{/* ===== CANVAS ===== */}
|
||||
<main className="canvas-area">
|
||||
<CanvasArea
|
||||
maskFile={maskFile}
|
||||
jobResult={jobResult}
|
||||
apiBase={API_BASE}
|
||||
viewMode={viewMode}
|
||||
zoom={zoom}
|
||||
highlightLocation={highlightLocation}
|
||||
/>
|
||||
|
||||
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
|
||||
|
||||
<ViewControls
|
||||
zoom={zoom}
|
||||
viewMode={viewMode}
|
||||
onZoomIn={() => setZoom(z => Math.min(5, +(z + 0.1).toFixed(1)))}
|
||||
onZoomOut={() => setZoom(z => Math.max(0.1, +(z - 0.1).toFixed(1)))}
|
||||
onZoomReset={() => setZoom(1)}
|
||||
onToggleView={() => setViewMode(v => v === '2d' ? '3d' : '2d')}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('读取底图失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function svgToPngFile(svg: string, originalName: string) {
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const width = img.naturalWidth || parseSvgNumber(svg, 'width') || 1024;
|
||||
const height = img.naturalHeight || parseSvgNumber(svg, 'height') || 1024;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('无法创建 SVG 转换画布'));
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(result => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (!result) {
|
||||
reject(new Error('SVG 转 PNG 失败'));
|
||||
return;
|
||||
}
|
||||
resolve(new File([result], originalName.replace(/\.svg$/i, '.png'), { type: 'image/png' }));
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('SVG 底图无法解析'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function parseSvgNumber(svg: string, attr: 'width' | 'height') {
|
||||
const match = svg.match(new RegExp(`${attr}=["']([0-9.]+)`));
|
||||
return match ? Math.max(1, Math.round(parseFloat(match[1]))) : 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
export interface NameEntry {
|
||||
group: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
// 前端持有的参数状态
|
||||
// dataColIndex: 0-based,对应后端 DATA_COL_INDEX
|
||||
// headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用)
|
||||
// seed: 对应后端 SEED
|
||||
// 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
|
||||
}
|
||||
|
||||
// 后端 /api/jobs/{id}/result 返回结构(根据 README 6.5 + 输出说明推断)
|
||||
// 文件通过 /api/jobs/{id}/files/{kind} 访问,kind: png | svg | db | metrics
|
||||
export interface JobResult {
|
||||
job_id: string;
|
||||
status: string;
|
||||
image_url: string;
|
||||
svg_url: string;
|
||||
svg_stroke_url: string;
|
||||
db_url: string;
|
||||
metrics_url: string;
|
||||
}
|
||||
|
||||
export interface NameLocation {
|
||||
id?: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
font_size?: number;
|
||||
color?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
box_x?: number;
|
||||
box_y?: number;
|
||||
box_width?: number;
|
||||
box_height?: number;
|
||||
// 前端兼容旧字段
|
||||
width?: number;
|
||||
height?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface SSEProgress {
|
||||
stage: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
|
||||
|
||||
export interface ExportConfig {
|
||||
type: 'bitmap' | 'vector';
|
||||
format: 'jpg' | 'png';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FindResult {
|
||||
locations: NameLocation[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
export interface Font {
|
||||
font_id: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type StickerAssetType = 'svg' | 'image';
|
||||
|
||||
export interface StickerAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
type: StickerAssetType;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
tint?: string;
|
||||
}
|
||||
|
||||
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';
|
||||
|
||||
export interface CanvasElementBase {
|
||||
id: string;
|
||||
type: CanvasElementType;
|
||||
layerId?: string;
|
||||
groupId?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface StickerCanvasElement extends CanvasElementBase {
|
||||
type: 'sticker';
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
export interface TextCanvasElement extends CanvasElementBase {
|
||||
type: 'text';
|
||||
text: string;
|
||||
fill: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string;
|
||||
}
|
||||
|
||||
export interface ShapeCanvasElement extends CanvasElementBase {
|
||||
type: 'rect' | 'ellipse' | 'line';
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export type CanvasElement = StickerCanvasElement | TextCanvasElement | ShapeCanvasElement;
|
||||
|
||||
export interface CanvasLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export interface CanvasLayerFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
layerIds: string[];
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasDocument {
|
||||
width: number;
|
||||
height: number;
|
||||
background: string;
|
||||
elements: CanvasElement[];
|
||||
layers?: CanvasLayer[];
|
||||
layerFolders?: CanvasLayerFolder[];
|
||||
}
|
||||
|
||||
export interface CanvasTemplate {
|
||||
id?: string;
|
||||
template_id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document: CanvasDocument;
|
||||
referenceAssetIds?: string[];
|
||||
reference_asset_ids?: string[];
|
||||
coverAssetId?: string;
|
||||
cover_asset_id?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface BackendAsset {
|
||||
asset_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mime_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
file_size: number;
|
||||
file_url: string;
|
||||
job_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WordcloudMaskSource {
|
||||
name: string;
|
||||
type: 'svg' | 'image';
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface WordcloudStickerPayload {
|
||||
svg: string;
|
||||
mask?: WordcloudMaskSource;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user