Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.DS_Store
*.log
.claude
.vscode
.idea
+25
View File
@@ -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;"]
+13
View File
@@ -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>
+36
View File
@@ -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;
}
}
+1836
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+72
View File
@@ -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')}
/>
);
}
+107
View File
@@ -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>
</>
);
}
+87
View File
@@ -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,
}}
/>
);
}
+110
View File
@@ -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>
</>
);
}
+382
View File
@@ -0,0 +1,382 @@
import { useState } from 'react';
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
interface ExportPanelProps {
jobId: string | null;
apiBase: string;
svgUrl?: string;
imageUrl?: string;
onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
}
type Format = 'jpg' | 'png';
type FillMode = 'fill' | 'dot' | 'line' | 'ring';
export default function ExportPanel({
jobId,
apiBase,
svgUrl,
imageUrl,
onOpenCanvas,
maskSource,
onImportWordcloudSticker,
}: ExportPanelProps) {
const [bmpFormat, setBmpFormat] = useState<Format>('png');
const [exportW, setExportW] = useState('1920');
const [exportH, setExportH] = useState('1080');
const [isSavingSticker, setIsSavingSticker] = useState(false);
const [stroke, setStroke] = useState(false);
const [fillMode, setFillMode] = useState<FillMode>('fill');
const [dotSpacing, setDotSpacing] = useState(10);
const [dotRadius, setDotRadius] = useState(2);
const [lineSpacing, setLineSpacing] = useState(6);
const [lineWidth, setLineWidth] = useState(1);
const [lineAngle, setLineAngle] = useState(0);
const [ringRadius, setRingRadius] = useState(3);
const [ringWidth, setRingWidth] = useState(1);
const [ringSpacing, setRingSpacing] = useState(8);
const resolveUrl = (field: string | undefined, kind: string) => {
if (field) return field.startsWith('http') ? field : `${apiBase}${field}`;
if (jobId) return `${apiBase}/api/jobs/${jobId}/files/${kind}`;
return null;
};
const buildCustomSvgUrl = () => {
if (!jobId) return null;
const params = new URLSearchParams();
params.set('fill', fillMode);
params.set('stroke', stroke ? '1' : '0');
if (fillMode === 'dot') {
params.set('spacing', String(dotSpacing));
params.set('radius', String(dotRadius));
}
if (fillMode === 'line') {
params.set('line_spacing', String(lineSpacing));
params.set('line_width', String(lineWidth));
params.set('line_angle', String(lineAngle));
}
if (fillMode === 'ring') {
params.set('ring_radius', String(ringRadius));
params.set('ring_width', String(ringWidth));
params.set('ring_spacing', String(ringSpacing));
}
return `${apiBase}/api/jobs/${jobId}/custom.svg?${params}`;
};
const handleExportSvg = () => {
const url = buildCustomSvgUrl();
if (!url) return;
triggerDownload(url, 'wordcloud.svg');
};
const handleSaveAsSticker = async () => {
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
if (!url || isSavingSticker) return;
setIsSavingSticker(true);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`读取 SVG 失败 (${res.status})`);
const svg = await res.text();
if (onImportWordcloudSticker) {
onImportWordcloudSticker({ svg, mask: maskSource || undefined });
} else {
onOpenCanvas?.();
}
} catch (error) {
const message = error instanceof Error ? error.message : '保存失败';
alert(message);
} finally {
setIsSavingSticker(false);
}
};
const handleExportBitmap = () => {
const url = resolveUrl(imageUrl, 'png');
if (!url) return;
triggerDownload(url, `wordcloud.${bmpFormat}`);
};
const triggerDownload = (url: string, filename: string) => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const hasResult = !!jobId;
return (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body">
{/* ── SVG 导出 ── */}
<div className="export-col-title"> SVG</div>
{/* 1. 描边 */}
<div className="form-group">
<label className="radio-label" style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={stroke}
onChange={e => setStroke(e.target.checked)}
style={{ marginRight: 6 }}
/>
</label>
</div>
{/* 2. 填充模式 */}
<div className="form-group">
<div className="radio-group" style={{ flexDirection: 'column', gap: 2 }}>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'fill'}
onChange={() => setFillMode('fill')}
/>
fill
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'dot'}
onChange={() => setFillMode('dot')}
/>
dot
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'line'}
onChange={() => setFillMode('line')}
/>
线line
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'ring'}
onChange={() => setFillMode('ring')}
/>
ring
</label>
</div>
</div>
{/* 点阵参数 */}
{fillMode === 'dot' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={dotSpacing}
onChange={e => setDotSpacing(parseInt(e.target.value) || 10)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={dotRadius}
onChange={e => setDotRadius(parseInt(e.target.value) || 2)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
{/* 线条参数 */}
{fillMode === 'line' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={lineSpacing}
onChange={e => setLineSpacing(parseInt(e.target.value) || 6)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={lineWidth}
onChange={e => setLineWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={lineAngle}
onChange={e => setLineAngle(parseInt(e.target.value) || 0)}
min={0}
max={359}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">&deg;</span>
</div>
</div>
)}
{/* 空心圆参数 */}
{fillMode === 'ring' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={ringRadius}
onChange={e => setRingRadius(parseInt(e.target.value) || 3)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={ringWidth}
onChange={e => setRingWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={ringSpacing}
onChange={e => setRingSpacing(parseInt(e.target.value) || 8)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportSvg}
>
SVG
</button>
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={handleSaveAsSticker}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
<div className="section-divider" />
{/* ── 位图导出 ── */}
<div className="export-split">
<div className="export-col">
<div className="export-col-title"></div>
<div className="radio-group" style={{ flexDirection: 'column', gap: 4 }}>
{(['png', 'jpg'] as Format[]).map(f => (
<label key={f} className="radio-label">
<input
type="radio"
name="bmp-format"
checked={bmpFormat === f}
onChange={() => setBmpFormat(f)}
/>
{f}
</label>
))}
</div>
<div className="form-group">
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportW}
onChange={e => setExportW(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportH}
onChange={e => setExportH(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
<div className="note-text"></div>
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportBitmap}
>
{bmpFormat}
</button>
</div>
</div>
{!hasResult && (
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: 8 }}>
</p>
)}
</div>
</>
);
}
+79
View File
@@ -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>
);
}
+105
View File
@@ -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>
</>
);
}
+260
View File
@@ -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>
);
}
+201
View File
@@ -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_INDEX0-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 12</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>
</>
);
}
+48
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+74
View File
@@ -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 };
}
+138
View File
@@ -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);
}
+156
View File
@@ -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;
}
+147
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
+105
View File
@@ -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);
}
+124
View File
@@ -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;
}
+10
View File
@@ -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
+316
View File
@@ -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',
});
}
+586
View File
@@ -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
+193
View File
@@ -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_INDEXnull 表示不传)
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;
}
+20
View File
@@ -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"]
}
+16
View File
@@ -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,
}
}
}
});