Add advanced canvas editing and Ubuntu deployment

This commit is contained in:
2026-07-14 20:32:42 +08:00
parent f8a907e7c5
commit bf2b138007
19 changed files with 2026 additions and 255 deletions
+136 -121
View File
@@ -1,184 +1,199 @@
# WordCloud — Docker Compose 部署指南 # WordCloud — Docker Compose 部署指南
本项目提供完整的 Docker Compose 配置,可在任意 Ubuntu / Linux 服务器上通过 `docker-compose up` 一键启动前后端服务。 本项目提供完整的 Docker Compose 配置,可在任意 Ubuntu / Linux 服务器上一键启动前后端服务。
--- ---
## 1. 目录结构 ## 1. 推荐:Release 包一键部署(Ubuntu
### 1.1 上传并解压
```bash
# 把 release.tar.gz 传到服务器后:
tar -xzf release.tar.gz
cd wordcloud
```
### 1.2 一键安装 Docker 并启动
```bash
chmod +x install-ubuntu.sh
./install-ubuntu.sh
```
脚本会:
1. 检测 Docker;若缺失则在 Ubuntu 上自动安装 Docker Engine + Compose 插件
2. 执行 `docker compose up -d --build`
3. 构建后端(含 C++ 扩展)与前端静态资源
### 1.3 访问
- 前端:`http://<服务器IP>:3000`
- 后端 API 文档:`http://<服务器IP>:8000/docs`
- 健康检查:`http://<服务器IP>:8000/api/health`
> 前端 Nginx 已将 `/api/*` 反向代理到后端,浏览器通常只需访问 3000 端口。
---
## 2. 目录结构
``` ```
wordcloud/ wordcloud/
├── docker-compose.yml # 编排 frontend + backend ├── docker-compose.yml
├── Makefile # 常用命令封装 ├── Makefile
├── install-ubuntu.sh # Ubuntu 一键安装/启动
├── DOCKER.md
├── frontend/ ├── frontend/
│ ├── Dockerfile # Node 构建 + Nginx 服务 │ ├── Dockerfile
│ ├── nginx.conf # 静态资源 + /api 反向代理 │ ├── nginx.conf
│ └── .dockerignore │ └── ...
└── backend/ └── backend/
├── Dockerfile # Python + C++ 扩展构建 ├── Dockerfile
├── requirements.txt ├── requirements.txt
└── .dockerignore └── ...
``` ```
--- ---
## 2. 环境要求 ## 3. 环境要求
- Ubuntu 20.04+(推荐)或其他 Linux
- Docker Engine >= 20.10 - Docker Engine >= 20.10
- Docker Compose >= 1.29`docker compose` plugin - Docker Compose V2 插件(`docker compose``docker-compose` >= 1.29
- 服务器开放端口:`3000`(前端)`8000`(后端,可选暴露 - 开放端口:`3000`(前端)`8000`(后端,可选)
若使用 `install-ubuntu.sh`Docker 可自动安装。
--- ---
## 3. 快速开始 ## 4. 手动命令
### 3.1 克隆/上传代码到 Ubuntu 服务器
```bash ```bash
cd /opt # 构建
# 方式 Agit clone
git clone <你的仓库地址> wordcloud
cd wordcloud
# 方式 B:直接上传整个项目目录后进入
cd /path/to/wordcloud
```
### 3.2 构建并启动
```bash
# 使用 Makefile(推荐)
make build make build
make up # 或
docker compose build --no-cache
# 或者直接使用 docker-compose # 启动
docker-compose up -d --build make up
# 或
docker compose up -d
# 一键构建并启动
docker compose up -d --build
``` ```
首次构建会: 首次构建会:
1. 后端:安装 Python 依赖并编译 `EfficientWordCloud` C++ 扩展。
2. 前端:执行 `npm ci``npm run build`,生成静态文件。
根据服务器性能,首次构建通常需要 3–10 分钟。 1. 后端安装 Python 依赖并编译 `EfficientWordCloud` C++ 扩展
2. 前端执行 `npm ci``npm run build`
### 3.3 访问服务 视机器性能,首次约 310 分钟。
- 前端页面:http://`<服务器IP>`:3000
- 后端 API 文档:http://`<服务器IP>`:8000/docs
- 后端健康检查:http://`<服务器IP>`:8000/api/health
> 前端 Nginx 已将所有 `/api/*` 请求反向代理到后端容器,因此浏览器只需访问 3000 端口。
--- ---
## 4. 常用命令 ## 5. 常用命令
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `./install-ubuntu.sh` | Ubuntu 一键安装 Docker 并启动 |
| `make build` | 重新构建镜像 | | `make build` | 重新构建镜像 |
| `make up` | 后台启动服务 | | `make up` | 后台启动 |
| `make down` | 停止并移除容器 | | `make down` | 停止并移除容器 |
| `make restart` | 重启服务 | | `make restart` | 重启 |
| `make logs` | 查看实时日志 | | `make logs` | 实时日志 |
| `make logs-backend` | 只看后端日志 | | `make logs-backend` | 后端日志 |
| `make logs-frontend` | 只看前端日志 | | `make logs-frontend` | 前端日志 |
| `make clean` | 停止并删除容器 + 镜像 + 卷(谨慎) | | `make status` | 查看容器状态 |
| `make shell-backend` | 进入后端容器调试 | | `make clean` | 停止并删除容器 + 镜像 + 卷(慎用) |
| `make shell-backend` | 进入后端容器 |
--- ---
## 5. 数据持久化 ## 6. 数据持久化
Docker Compose 已声明以下命名卷,数据会保存在 Docker 宿主机上,容器重建不会丢失 Compose 使用命名卷,容器重建不丢数据
| 卷名 | 容器内路径 | 用途 | | 卷名 | 容器内路径 | 用途 |
|------|-----------|------| |------|------------|------|
| `wordcloud_workspace` | `/app/service_workspace` | 词云任务工作目录 | | `wordcloud_workspace` | `/app/service_workspace` | 任务输入输出 |
| `wordcloud_assets` | `/app/service_assets` | 贴纸资源文件 | | `wordcloud_assets` | `/app/service_assets` | 贴纸/素材 |
| `wordcloud_projects` | `/app/service_projects` | 保存的项目 | | `wordcloud_projects` | `/app/service_projects` | 项目 |
| `wordcloud_design_templates` | `/app/service_design_templates` | 设计模板 | | `wordcloud_design_templates` | `/app/service_design_templates` | 设计模板 |
| `wordcloud_fonts` | `/app/service_fonts` | 上传字体文件 | | `wordcloud_fonts` | `/app/service_fonts` | 上传字体 |
如需查看本地卷位置 查看卷
```bash ```bash
docker volume ls | grep wordcloud
docker volume inspect wordcloud_workspace docker volume inspect wordcloud_workspace
``` ```
--- ---
## 6. 端口与网络 ## 7. 端口修改
- `frontend` 容器监听 `3000:80` 编辑 `docker-compose.yml``ports`,例如前端改为 `8080:80`
- `backend` 容器监听 `8000:8000`
- 两个服务通过默认 Docker bridge 网络通信,`frontend` 的 Nginx 通过服务名 `backend:8000` 访问后端。
如需修改端口,编辑 `docker-compose.yml` 中的 `ports` 映射即可,例如将前端改为 `8080:80`
---
## 7. 生产环境建议
1. **使用反向代理(Nginx / Caddy / Traefik**
-`3000` 端口通过域名 + HTTPS 暴露。
- 关闭后端 `8000` 端口的外部访问,仅保留内部通信。
2. **设置环境变量**
- 后端 `UVICORN_WORKERS`:可通过环境变量增加工作进程数。
- 如需自定义后端日志级别,可挂载 `.env` 文件。
3. **备份数据卷**
- 定期备份 `wordcloud_assets``wordcloud_projects` 等卷,避免服务器故障丢失用户数据。
4. **更新部署**
```bash
git pull
make build
make restart
```
---
## 8. 常见问题
### Q1: 前端页面空白或 502
检查后端是否健康:
```bash
make logs-backend
```
确认 `/api/health` 返回 `{"status":"ok"}`。
### Q2: 构建 C++ 扩展失败
确保 base 镜像能联网安装 `build-essential` 与 `g++`。如在中国大陆服务器,可配置 Docker 镜像加速。
### Q3: 贴纸/字体上传后丢失
检查卷是否正确挂载:
```bash
docker exec -it wordcloud-backend ls -la /app/service_assets
```
### Q4: 端口被占用
修改 `docker-compose.yml` 中的端口映射,例如:
```yaml ```yaml
ports: ports:
- "8080:80" - "8080:80"
``` ```
--- 然后:
## 9. 本地开发(非 Docker
如需本地开发,可分别运行:
```bash ```bash
# 后端 make up
cd backend
./start-dev.sh
# 前端
cd frontend
npm install
npm run dev
``` ```
本地开发时前端通过 Vite 代理 `/api` 到 `http://localhost:8000`。 ---
## 8. 常见问题
### 8.1 构建 C++ 扩展失败
确保构建时能访问 Debian/Ubuntu 软件源以安装 `build-essential``g++`。国内服务器可配置 Docker 镜像加速。
### 8.2 前端能开但接口 502
```bash
make logs-backend
curl -f http://127.0.0.1:8000/api/health
```
确认 backend 健康检查通过后再访问前端。
### 8.3 权限问题
```bash
sudo usermod -aG docker $USER
# 重新登录后再执行 ./install-ubuntu.sh
```
### 8.4 清理后重装
```bash
make clean
./install-ubuntu.sh
```
注意:`make clean` 会删除命名卷,用户数据会丢失。
---
## 9. 本地重新打包 Release
在开发机项目根目录:
```bash
make release
# 或
./scripts/pack-release.sh
```
会覆盖:
- `release/`
- `release.tar.gz`
+14 -2
View File
@@ -1,6 +1,7 @@
.PHONY: build up down restart logs logs-backend logs-frontend clean shell-backend status prune .PHONY: build up down restart logs logs-backend logs-frontend clean shell-backend status prune release help install
COMPOSE := docker-compose # Prefer Docker Compose V2 plugin, fallback to docker-compose
COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; else echo "docker-compose"; fi)
# ── Build / Run ───────────────────────────────────────────── # ── Build / Run ─────────────────────────────────────────────
build: build:
@@ -38,3 +39,14 @@ clean:
prune: prune:
docker system prune -f docker system prune -f
# ── Ubuntu one-click ────────────────────────────────────────
install:
./install-ubuntu.sh
# ── Pack release/ + release.tar.gz ──────────────────────────
release:
./scripts/pack-release.sh
help:
@echo "make build | up | down | restart | logs | status | clean | install | release"
-1
View File
@@ -13,7 +13,6 @@ __pycache__
service_workspace service_workspace
service_assets service_assets
service_projects service_projects
service_design_templates
service_fonts service_fonts
*.egg-info *.egg-info
build build
+7 -1
View File
@@ -22,15 +22,21 @@ COPY EfficientWordCloud ./EfficientWordCloud
COPY assets ./assets COPY assets ./assets
COPY start-dev.sh ./ COPY start-dev.sh ./
COPY wordcloud_generate_hybrid.py ./ COPY wordcloud_generate_hybrid.py ./
COPY docker-entrypoint.sh ./
# Optional design-template seeds (copied into volume on first boot)
COPY service_design_templates ./service_design_templates_seed
# Build the C++ extension in-place # Build the C++ extension in-place
RUN cd EfficientWordCloud && python setup.py build_ext --inplace RUN cd EfficientWordCloud && python setup.py build_ext --inplace
# Runtime data directories (will be mounted as volumes) # Runtime data directories (will be mounted as volumes)
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts \
&& chmod +x /app/docker-entrypoint.sh
EXPOSE 8000 EXPOSE 8000
ENV PYTHONPATH=/app/EfficientWordCloud ENV PYTHONPATH=/app/EfficientWordCloud
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"]
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Seed design templates into the mounted volume on first boot.
SEED_DIR="/app/service_design_templates_seed"
TARGET_DIR="/app/service_design_templates"
if [[ -d "$SEED_DIR" ]]; then
mkdir -p "$TARGET_DIR"
# only copy when target has no template.json yet
if ! find "$TARGET_DIR" -type f -name 'template.json' 2>/dev/null | grep -q .; then
echo "[entrypoint] seeding design templates into volume..."
cp -a "$SEED_DIR"/. "$TARGET_DIR"/
fi
fi
# Ensure runtime dirs exist
mkdir -p \
/app/service_workspace \
/app/service_assets \
/app/service_projects \
/app/service_fonts \
/app/service_design_templates
exec "$@"
+68 -17
View File
@@ -885,27 +885,76 @@ async def upload_asset(
return Asset(**meta) return Asset(**meta)
@app.post("/api/assets/from-job/{job_id}", response_model=Asset)
async def import_asset_from_job( def _svg_with_transparent_background(svg_bytes: bytes) -> bytes:
job_id: str, """Convert full-canvas opaque backgrounds to transparent for sticker use.
name: str = Form(""),
) -> Asset: Job pipeline SVG (`to_svg`) embeds a white full-rect so standalone preview
# 尝试从内存获取产物路径;若服务已重启则从磁盘回退 looks solid. Canvas stickers must stay transparent so the mask layer shows
through. Only the full-canvas background rect is touched.
"""
try:
text = svg_bytes.decode("utf-8")
except UnicodeDecodeError:
text = svg_bytes.decode("utf-8", errors="ignore")
patterns = [
# <rect width="100%" height="100%" fill="white"/>
(
r'(<rect\b[^>]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*?/?>)',
r'\1none\2',
),
# attribute order: fill before width/height
(
r'(<rect\b[^>]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*?/?>)',
r'\1none\2',
),
]
for pattern, repl in patterns:
updated, n = re.subn(pattern, repl, text, count=1, flags=re.IGNORECASE)
if n:
return updated.encode("utf-8")
return svg_bytes.encode("utf-8") if isinstance(svg_bytes, str) else svg_bytes
def _find_job_svg(job_id: str) -> Path | None:
"""Locate the main (non-stroke) SVG for a job.
Prefer in-memory artifact path when valid; always fall back to the job
workspace on disk so empty/stale memory state cannot hide existing files.
"""
svg_path: Path | None = None svg_path: Path | None = None
if manager.exists(job_id): if manager.exists(job_id):
status = manager.get_status(job_id) status = manager.get_status(job_id)
svg_path_str = status.artifacts.get("svg", "") svg_path_str = status.artifacts.get("svg", "")
if svg_path_str: if svg_path_str:
svg_path = Path(svg_path_str) candidate = Path(svg_path_str)
else: if candidate.exists():
# 服务重启后内存丢失,直接从工作区目录查找
fallback_dir = storage.base_dir / job_id / "output"
if fallback_dir.exists():
for candidate in fallback_dir.glob("*.svg"):
svg_path = candidate svg_path = candidate
break
if svg_path is not None:
return svg_path
fallback_dir = storage.base_dir / job_id / "output"
if not fallback_dir.exists():
return None
candidates = sorted(fallback_dir.glob("*.svg"))
for candidate in candidates:
if candidate.name.endswith("_stroke.svg"):
continue
return candidate
return candidates[0] if candidates else None
@app.post("/api/assets/from-job/{job_id}", response_model=Asset)
async def import_asset_from_job(
job_id: str,
name: str = Form(""),
type: str = Form("wordcloud"),
) -> Asset:
svg_path = _find_job_svg(job_id)
if svg_path is None or not svg_path.exists(): if svg_path is None or not svg_path.exists():
raise HTTPException(status_code=404, detail="svg not found") raise HTTPException(status_code=404, detail="svg not found")
@@ -914,16 +963,18 @@ async def import_asset_from_job(
asset_path.mkdir(parents=True, exist_ok=True) asset_path.mkdir(parents=True, exist_ok=True)
dest = asset_path / "asset.svg" dest = asset_path / "asset.svg"
shutil.copy2(svg_path, dest) raw = svg_path.read_bytes()
# 画布贴纸需要透明底;任务主 SVG 默认带白底 rect
content = dest.read_bytes() content = _svg_with_transparent_background(raw)
dest.write_bytes(content)
width, height = _parse_svg_viewbox(dest) width, height = _parse_svg_viewbox(dest)
asset_type = (type or "wordcloud").strip() or "wordcloud"
display_name = name or f"词云_{job_id[:8]}" display_name = name or f"词云_{job_id[:8]}"
meta = { meta = {
"asset_id": asset_id, "asset_id": asset_id,
"name": display_name, "name": display_name,
"type": "wordcloud", "type": asset_type,
"mime_type": "image/svg+xml", "mime_type": "image/svg+xml",
"width": width, "width": width,
"height": height, "height": height,
+6 -1
View File
@@ -96,7 +96,12 @@ class JobRunner:
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed) log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
png = next(paths.output_dir.glob("*.png"), None) png = next(paths.output_dir.glob("*.png"), None)
svg = next(paths.output_dir.glob("*[!_stroke].svg"), None) # NOTE: do NOT use "*[!_stroke].svg" — in glob, [!...] is a character class,
# so filenames ending with "e.svg" (e.g. AutoResize.svg) are incorrectly skipped.
svg = next(
(p for p in sorted(paths.output_dir.glob("*.svg")) if not p.name.endswith("_stroke.svg")),
None,
)
svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None) svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None)
db = next(paths.output_dir.glob("*.db"), None) db = next(paths.output_dir.glob("*.db"), None)
metrics = next(paths.output_dir.glob("*metrics*.json"), None) metrics = next(paths.output_dir.glob("*metrics*.json"), None)
+13 -2
View File
@@ -4,7 +4,7 @@ import CanvasStudio from './pages/CanvasStudio';
import HelpPage from './pages/HelpPage'; import HelpPage from './pages/HelpPage';
import TemplateHome from './pages/TemplateHome'; import TemplateHome from './pages/TemplateHome';
import TestWorkbench from './pages/TestWorkbench'; import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudStickerPayload } from './types'; import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument'; import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help'; type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help';
@@ -23,6 +23,7 @@ export default function App() {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null); const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null); const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
const [pendingReplaceSession, setPendingReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const openHelp = () => setPage('help'); const openHelp = () => setPage('help');
@@ -53,8 +54,11 @@ export default function App() {
onThemeModeChange={setThemeMode} onThemeModeChange={setThemeMode}
onOpenCanvas={() => setPage('canvas')} onOpenCanvas={() => setPage('canvas')}
onOpenHelp={openHelp} onOpenHelp={openHelp}
replaceSession={pendingReplaceSession}
onConsumeReplaceSession={() => setPendingReplaceSession(null)}
onImportWordcloudSticker={(payload) => { onImportWordcloudSticker={(payload) => {
setPendingWordcloudSticker(payload); setPendingWordcloudSticker(payload);
setPendingReplaceSession(null);
setPage('canvas'); setPage('canvas');
}} }}
/> />
@@ -68,8 +72,15 @@ export default function App() {
systemTheme={systemTheme} systemTheme={systemTheme}
onThemeModeChange={setThemeMode} onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')} onOpenHome={() => setPage('home')}
onOpenWordcloud={() => setPage('wordcloud')} onOpenWordcloud={() => {
setPendingReplaceSession(null);
setPage('wordcloud');
}}
onOpenHelp={openHelp} onOpenHelp={openHelp}
onReplaceWordcloudNames={(session) => {
setPendingReplaceSession(session);
setPage('wordcloud');
}}
initialDocument={initialDocument} initialDocument={initialDocument}
onConsumeInitialDocument={() => setInitialDocument(null)} onConsumeInitialDocument={() => setInitialDocument(null)}
pendingWordcloudSticker={pendingWordcloudSticker} pendingWordcloudSticker={pendingWordcloudSticker}
+474 -44
View File
@@ -1,12 +1,110 @@
import type { ReactNode } from 'react';
import { JobParams } from '../types'; import { JobParams } from '../types';
interface AdvancedPanelProps { interface AdvancedPanelProps {
params: JobParams; params: JobParams;
onParamsChange: (partial: Partial<JobParams>) => void; onParamsChange: (partial: Partial<JobParams>) => void;
embedded?: boolean; embedded?: boolean;
onResetDefaults?: () => void;
} }
export default function AdvancedPanel({ params, onParamsChange, embedded = false }: AdvancedPanelProps) { function parseOptionalInt(raw: string): number | null {
const v = raw.trim();
if (v === '') return null;
const n = parseInt(v, 10);
return Number.isNaN(n) ? null : n;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<div
className="form-label"
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-primary)',
marginTop: 4,
marginBottom: 2,
}}
>
{children}
</div>
);
}
function Hint({ children }: { children: ReactNode }) {
return <span className="text-xs text-muted">{children}</span>;
}
function NumberField({
label,
value,
onChange,
min,
max,
step,
placeholder,
}: {
label: string;
value: number | null;
onChange: (v: number | null) => void;
min?: number;
max?: number;
step?: number | string;
placeholder?: string;
}) {
return (
<div className="form-group">
<label className="form-label">{label}</label>
<input
className="form-input"
type="number"
min={min}
max={max}
step={step}
placeholder={placeholder}
value={value ?? ''}
onChange={e => {
if (e.target.value.trim() === '') {
onChange(null);
return;
}
const n = Number(e.target.value);
onChange(Number.isNaN(n) ? null : n);
}}
/>
</div>
);
}
function BoolField({
label,
checked,
onChange,
hint,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
hint?: string;
}) {
return (
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
{label}
</label>
{hint ? <Hint>{hint}</Hint> : null}
</div>
);
}
export default function AdvancedPanel({
params,
onParamsChange,
embedded = false,
onResetDefaults,
}: AdvancedPanelProps) {
return ( return (
<> <>
{!embedded && ( {!embedded && (
@@ -15,8 +113,15 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
</div> </div>
)} )}
<div className="panel-body"> <div className="panel-body">
{onResetDefaults && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
<button type="button" className="btn btn-secondary btn-sm" onClick={onResetDefaults}>
</button>
</div>
)}
{/* SEED */} <SectionTitle></SectionTitle>
<div className="form-group"> <div className="form-group">
<label className="form-label">SEED</label> <label className="form-label">SEED</label>
<input <input
@@ -25,17 +130,11 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
min={0} min={0}
value={params.seed ?? ''} value={params.seed ?? ''}
placeholder="留空=不固定种子" placeholder="留空=不固定种子"
onChange={e => { onChange={e => onParamsChange({ seed: parseOptionalInt(e.target.value) })}
const v = e.target.value.trim();
onParamsChange({ seed: v === '' ? null : parseInt(v) });
}}
/> />
<span className="text-xs text-muted"></span> <Hint> 42</Hint>
</div> </div>
<div className="section-divider" />
{/* FONT_COLOR */}
<div className="form-group"> <div className="form-group">
<label className="form-label"></label> <label className="form-label"></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -54,55 +153,386 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
</div> </div>
<span className="text-xs text-muted">使</span> <Hint></Hint>
</div> </div>
<div className="section-divider" /> <div className="form-row">
<NumberField
{/* N_REPETITIONS */} label="词语重复次数"
<div className="form-group"> value={params.nRepetitions}
<label className="form-label"></label>
<input
className="form-input"
type="number"
min={1} min={1}
max={20} max={20}
value={params.nRepetitions} step={1}
onChange={e => { onChange={v => onParamsChange({ nRepetitions: Math.max(1, Math.min(20, Math.round(v ?? 1))) })}
const v = parseInt(e.target.value);
onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) });
}}
/> />
<span className="text-xs text-muted"> <div className="form-group">
10 5~10使 1 <label className="form-label"> FILL_ON</label>
</span> <select
className="form-input"
value={params.fillOn}
onChange={e => onParamsChange({ fillOn: e.target.value as JobParams['fillOn'] })}
>
<option value="BLACK">BLACK</option>
<option value="WHITE">WHITE</option>
</select>
</div>
</div> </div>
<Hint> 5~10</Hint>
<div className="section-divider" /> <div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
<NumberField
label="最大/最小字号比 SIZE_RATIO"
value={params.sizeRatio}
min={1}
max={20}
step={0.1}
onChange={v => onParamsChange({ sizeRatio: v ?? 2 })}
/>
<NumberField
label="打包效率 PACKING_EFFICIENCY"
value={params.packingEfficiency}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.85 })}
/>
</div>
<Hint>SIZE_RATIO 2.0</Hint>
{/* STROKE_WEIGHTS */} <div className="form-row">
<div className="form-group"> <NumberField
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> label="用户最小字号"
<input value={params.userMinFontSize}
type="checkbox" min={1}
checked={params.strokeWeights} step={1}
onChange={e => onParamsChange({ strokeWeights: e.target.checked })} placeholder="留空=自动"
onChange={v => onParamsChange({ userMinFontSize: v === null ? null : Math.round(v) })}
/>
<NumberField
label="用户最大字号"
value={params.userMaxFontSize}
min={1}
step={1}
placeholder="留空=自动"
onChange={v => onParamsChange({ userMaxFontSize: v === null ? null : Math.round(v) })}
/>
</div>
<div className="form-row">
<NumberField
label="字号下限 MIN_FONT_FLOOR"
value={params.minFontFloor}
min={1}
step={1}
onChange={v => onParamsChange({ minFontFloor: Math.max(1, Math.round(v ?? 2)) })}
/>
<NumberField
label="可读高度(px)"
value={params.minReadableHeightPx}
min={1}
step={1}
onChange={v => onParamsChange({ minReadableHeightPx: Math.max(1, Math.round(v ?? 25)) })}
/>
</div>
<div className="form-row">
<NumberField
label="工作缩放 WORK_SCALE"
value={params.workScale}
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ workScale: v ?? 0.25 })}
/>
<NumberField
label="目标填充率 TARGET_FILL"
value={params.targetFillRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ targetFillRatio: v ?? 0 })}
/>
</div>
<Hint>TARGET_FILL_RATIO=0 </Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="根据笔画复杂度调整权重"
checked={params.strokeWeights}
onChange={v => onParamsChange({ strokeWeights: v })}
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
/>
<div className="form-row">
<NumberField
label="对数权重 LOG_WEIGHT_RATIO"
value={params.logWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ logWeightRatio: v ?? 0.72 })}
/>
<NumberField
label="排序权重 RANK_WEIGHT_RATIO"
value={params.rankWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ rankWeightRatio: v ?? 0.28 })}
/>
</div>
<Hint> 1.0 0.72 / 0.28</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="智能减少大字数量"
checked={params.enableSmartLargeFontReduction}
onChange={v => onParamsChange({ enableSmartLargeFontReduction: v })}
hint="填不满时自动减少大字,给小词腾空间"
/>
<BoolField
label="限制大字比例 LIMIT_LARGE_FONTS"
checked={params.limitLargeFonts}
onChange={v => onParamsChange({ limitLargeFonts: v })}
/>
<div className="form-row">
<NumberField
label="大字允许占比"
value={params.largeFontLimitRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontLimitRatio: v ?? 0.2 })}
/>
<NumberField
label="大字判定阈值"
value={params.largeFontThresholdRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontThresholdRatio: v ?? 0.8 })}
/>
</div>
<NumberField
label="大字压缩系数 LARGE_FONT_CAP_RATIO"
value={params.largeFontCapRatio}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontCapRatio: v ?? 0.6 })}
/>
<Hint> 20% 80% 60%</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
<NumberField
label="缩放下限 FONT_SCALE_MIN"
value={params.fontScaleMin}
min={0.05}
max={2}
step={0.05}
onChange={v => onParamsChange({ fontScaleMin: v ?? 0.5 })}
/>
<NumberField
label="缩放上限 FONT_SCALE_MAX"
value={params.fontScaleMax}
min={0.1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fontScaleMax: v ?? 1.2 })}
/>
</div>
<div className="form-row">
<NumberField
label="搜索步数"
value={params.scaleSearchSteps}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchSteps: Math.max(1, Math.round(v ?? 7)) })}
/>
<NumberField
label="搜索轮数"
value={params.scaleSearchRounds}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchRounds: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="衰减 SCALE_DECAY"
value={params.scaleDecay}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleDecay: v ?? 0.85 })}
/>
<NumberField
label="地板 SCALE_FLOOR"
value={params.scaleFloor}
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleFloor: v ?? 0.25 })}
/>
</div>
<div className="form-row">
<NumberField
label="自动收缩轮数"
value={params.autoShrinkRounds}
min={0}
step={1}
onChange={v => onParamsChange({ autoShrinkRounds: Math.max(0, Math.round(v ?? 4)) })}
/>
<div className="form-group" style={{ justifyContent: 'center' }}>
<BoolField
label="要求全部词语放入"
checked={params.requireAllWords}
onChange={v => onParamsChange({ requireAllWords: v })}
/> />
</div>
</label>
<span className="text-xs text-muted">
"鑫""一"
</span>
</div> </div>
<div className="section-divider" /> <div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="分层采样(边缘覆盖)"
checked={params.enableStratifiedSampling}
onChange={v => onParamsChange({ enableStratifiedSampling: v })}
/>
<NumberField
label="分层条带数 STRATIFIED_BANDS"
value={params.stratifiedBands}
min={1}
max={10}
step={1}
onChange={v => onParamsChange({ stratifiedBands: Math.max(1, Math.round(v ?? 3)) })}
/>
<div className="form-row">
<NumberField
label="最低接受填充率"
value={params.minAcceptFillRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ minAcceptFillRatio: v ?? 0.75 })}
/>
<NumberField
label="填充重试轮数"
value={params.fillRetryMaxRounds}
min={0}
step={1}
onChange={v => onParamsChange({ fillRetryMaxRounds: Math.max(0, Math.round(v ?? 3)) })}
/>
</div>
<div className="form-row">
<NumberField
label="重试最大缩放"
value={params.fillRetryMaxScale}
min={1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fillRetryMaxScale: v ?? 1.5 })}
/>
<NumberField
label="低填充增字号步长"
value={params.growFontStep}
min={1}
max={2}
step={0.01}
onChange={v => onParamsChange({ growFontStep: v ?? 1.05 })}
/>
</div>
<BoolField
label="填充重试时放宽大字限制"
checked={params.fillRetryRelaxLargeCap}
onChange={v => onParamsChange({ fillRetryRelaxLargeCap: v })}
/>
<BoolField
label="低填充时略增字号 GROW_FONT_ON_LOW_FILL"
checked={params.growFontOnLowFill}
onChange={v => onParamsChange({ growFontOnLowFill: v })}
hint="后端默认关闭"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="自动扩展画布 AUTO_EXPAND_CANVAS"
checked={params.autoExpandCanvas}
onChange={v => onParamsChange({ autoExpandCanvas: v })}
/>
<BoolField
label="螺旋填充扩展 EXPAND_FOR_SPIRAL"
checked={params.expandForSpiral}
onChange={v => onParamsChange({ expandForSpiral: v })}
/>
<div className="form-row">
<NumberField
label="扩展倍率 EXPAND_RATIO"
value={params.expandRatio}
min={1}
max={5}
step={0.1}
onChange={v => onParamsChange({ expandRatio: v ?? 2.5 })}
/>
<NumberField
label="最大尝试次数"
value={params.maxAttempts}
min={1}
step={1}
onChange={v => onParamsChange({ maxAttempts: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="画布重试轮数"
value={params.canvasRetryMaxRounds}
min={0}
step={1}
onChange={v => onParamsChange({ canvasRetryMaxRounds: Math.max(0, Math.round(v ?? 1)) })}
/>
<NumberField
label="画布重试增长"
value={params.canvasRetryGrowth}
min={1}
max={2}
step={0.01}
onChange={v => onParamsChange({ canvasRetryGrowth: v ?? 1.12 })}
/>
</div>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-group">
<label className="form-label"> LAYOUT_ORDER_MODE</label>
<select
className="form-input"
value={params.layoutOrderMode}
onChange={e =>
onParamsChange({ layoutOrderMode: e.target.value as JobParams['layoutOrderMode'] })
}
>
<option value="SORTED">SORTED</option>
<option value="INTERLEAVED_RANDOM">INTERLEAVED_RANDOM</option>
</select>
</div>
<NumberField
label="布局种子 LAYOUT_SEED"
value={params.layoutSeed}
min={0}
step={1}
placeholder="留空=跟随 SEED"
onChange={v => onParamsChange({ layoutSeed: v === null ? null : Math.round(v) })}
/>
<div className="section-divider" />
<p className="text-xs text-muted" style={{ lineHeight: 1.6 }}> <p className="text-xs text-muted" style={{ lineHeight: 1.6 }}>
<code style={{ fontSize: 10 }}>backend/core/config.py</code>
<code style={{ fontSize: 10, background: 'var(--bg)', padding: '0 3px', borderRadius: 2 }}> <strong>SIZE_RATIO</strong><strong></strong> {' '}
config.json <strong></strong>
</code>
README 4.8
</p> </p>
</div> </div>
</> </>
+62 -12
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types'; import { WordcloudMaskSource, WordcloudReplaceTarget, WordcloudStickerPayload } from '../types';
import { apiUrl, ensureOk } from '../lib/api'; import { apiUrl, ensureOk } from '../lib/api';
import { addStickerAssetFromJob } from '../lib/stickerLibrary';
interface ExportPanelProps { interface ExportPanelProps {
jobId: string | null; jobId: string | null;
@@ -8,6 +9,7 @@ interface ExportPanelProps {
imageUrl?: string; imageUrl?: string;
onOpenCanvas?: () => void; onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null; maskSource?: WordcloudMaskSource | null;
replaceTarget?: WordcloudReplaceTarget | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void; onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
embedded?: boolean; embedded?: boolean;
} }
@@ -21,6 +23,7 @@ export default function ExportPanel({
imageUrl, imageUrl,
onOpenCanvas, onOpenCanvas,
maskSource, maskSource,
replaceTarget = null,
onImportWordcloudSticker, onImportWordcloudSticker,
embedded = false, embedded = false,
}: ExportPanelProps) { }: ExportPanelProps) {
@@ -74,16 +77,48 @@ export default function ExportPanel({
triggerDownload(url, 'wordcloud.svg'); triggerDownload(url, 'wordcloud.svg');
}; };
const handleSaveAsSticker = async () => { const handleSaveAsSticker = async (mode: 'add' | 'replace' = 'add') => {
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg'); if (isSavingSticker) return;
if (!url || isSavingSticker) return; if (mode === 'replace' && !replaceTarget) {
alert('当前不是同底图换名单模式');
return;
}
if (!jobId) {
alert('请先生成词云');
return;
}
setIsSavingSticker(true); setIsSavingSticker(true);
try { try {
// 快路径:服务端直接把 job SVG 复制入库,浏览器不下载/再上传大文件
if (mode === 'replace') {
const asset = await addStickerAssetFromJob(
jobId,
`词云 ${new Date().toLocaleString('zh-CN')}`,
);
if (onImportWordcloudSticker) {
onImportWordcloudSticker({
assetId: asset.id,
width: replaceTarget?.width,
height: replaceTarget?.height,
replaceTarget: replaceTarget || undefined,
});
} else {
onOpenCanvas?.();
}
return;
}
// 普通“作为贴纸导入画布”仍支持导出面板的描边/填充样式
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
if (!url) throw new Error('没有可导出的 SVG');
const res = await ensureOk(await fetch(url), '读取 SVG 失败'); const res = await ensureOk(await fetch(url), '读取 SVG 失败');
const svg = await res.text(); const svg = await res.text();
if (onImportWordcloudSticker) { if (onImportWordcloudSticker) {
onImportWordcloudSticker({ svg, mask: maskSource || undefined }); onImportWordcloudSticker({
svg,
mask: maskSource || undefined,
});
} else { } else {
onOpenCanvas?.(); onOpenCanvas?.();
} }
@@ -304,13 +339,28 @@ export default function ExportPanel({
> >
SVG SVG
</button> </button>
<button {replaceTarget ? (
className="btn btn-primary btn-sm btn-block" <>
disabled={!hasResult || isSavingSticker} <div className="settings-note" style={{ marginTop: 8 }}>
onClick={handleSaveAsSticker}
> </div>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'} <button
</button> className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={() => handleSaveAsSticker('replace')}
>
{isSavingSticker ? '替换中' : '替换画布词云'}
</button>
</>
) : (
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={() => handleSaveAsSticker('add')}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
)}
<div className="section-divider" /> <div className="section-divider" />
+23 -8
View File
@@ -7,9 +7,11 @@ interface FileUploaderProps {
acceptHint: string; acceptHint: string;
file: File | null; file: File | null;
onFileChange: (file: File | null) => void; onFileChange: (file: File | null) => void;
disabled?: boolean;
statusText?: string;
} }
export default function FileUploader({ label, accept, acceptHint, file, onFileChange }: FileUploaderProps) { export default function FileUploader({ label, accept, acceptHint, file, onFileChange, disabled = false, statusText }: FileUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -17,12 +19,14 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
setDragging(false); setDragging(false);
if (disabled) return;
const dropped = e.dataTransfer.files[0]; const dropped = e.dataTransfer.files[0];
if (!dropped) return; if (!dropped) return;
validateAndSet(dropped); validateAndSet(dropped);
}; };
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const selected = e.target.files?.[0]; const selected = e.target.files?.[0];
if (!selected) return; if (!selected) return;
validateAndSet(selected); validateAndSet(selected);
@@ -40,21 +44,32 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
}; };
const hasFile = file !== null; const hasFile = file !== null;
const statusClass = error ? 'error' : hasFile ? 'success' : ''; const statusClass = error ? 'error' : (hasFile || (!!statusText && !statusText.includes('失败') && !statusText.includes('载入…') && !statusText.includes('载入中') && !statusText.includes('正在'))) ? 'success' : statusText ? '' : '';
const showStatus = !!(error || hasFile || statusText);
const filenameText = error ? error : (statusText || file?.name || '');
const labelText = error
? '导入失败'
: statusText
? (statusText.includes('失败') ? '载入失败' : statusText.includes('正在') || statusText.includes('载入…') ? '载入中' : '已锁定')
: hasFile
? '导入成功'
: '';
return ( return (
<div className="form-group"> <div className="form-group">
<div <div
className={`upload-zone${dragging ? ' drag-over' : ''}`} className={`upload-zone${dragging ? ' drag-over' : ''}${disabled ? ' disabled' : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true); }} onDragOver={e => { e.preventDefault(); if (!disabled) setDragging(true); }}
onDragLeave={() => setDragging(false)} onDragLeave={() => setDragging(false)}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => inputRef.current?.click()} onClick={() => { if (!disabled) inputRef.current?.click(); }}
style={disabled ? { opacity: 0.65, cursor: 'not-allowed' } : undefined}
> >
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept={accept} accept={accept}
disabled={disabled}
onChange={handleChange} onChange={handleChange}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
/> />
@@ -64,13 +79,13 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
<div style={{ fontSize: 10, marginTop: 2 }}>{acceptHint}</div> <div style={{ fontSize: 10, marginTop: 2 }}>{acceptHint}</div>
</div> </div>
</div> </div>
{(hasFile || error) && ( {showStatus && (
<div className={`status-bar ${statusClass}`}> <div className={`status-bar ${statusClass}`}>
<span className="status-bar-filename"> <span className="status-bar-filename">
{error ? error : file?.name} {filenameText}
</span> </span>
<span className="status-bar-label"> <span className="status-bar-label">
{error ? '导入失败' : '导入成功'} {labelText}
</span> </span>
</div> </div>
)} )}
+36 -2
View File
@@ -16,6 +16,9 @@ interface ImportPanelProps {
onFontDelete: (fontId: string) => void; onFontDelete: (fontId: string) => void;
onFontSelect: (fontId: string) => void; onFontSelect: (fontId: string) => void;
embedded?: boolean; embedded?: boolean;
maskLocked?: boolean;
maskPreviewUrl?: string | null;
maskStatusText?: string;
} }
const TEMPLATE_URL = '#'; const TEMPLATE_URL = '#';
@@ -25,6 +28,9 @@ export default function ImportPanel({
onMaskChange, onNamesChange, onParamsChange, onMaskChange, onNamesChange, onParamsChange,
onFontUpload, onFontDelete, onFontSelect, onFontUpload, onFontDelete, onFontSelect,
embedded = false, embedded = false,
maskLocked = false,
maskPreviewUrl = null,
maskStatusText,
}: ImportPanelProps) { }: ImportPanelProps) {
const fontInputRef = useRef<HTMLInputElement>(null); const fontInputRef = useRef<HTMLInputElement>(null);
@@ -48,12 +54,40 @@ export default function ImportPanel({
{/* 底图导入 */} {/* 底图导入 */}
<div> <div>
<div className="section-title">mask_image</div> <div className="section-title">mask_image</div>
{maskLocked ? (
<div className="settings-note" style={{ marginBottom: 8 }}>
</div>
) : null}
{maskLocked && maskPreviewUrl ? (
<div className="mask-locked-preview" style={{
marginBottom: 8,
border: '1px solid var(--border)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--surface-2, var(--bg-secondary))',
}}>
<img
src={maskPreviewUrl}
alt="原底图预览"
style={{
display: 'block',
width: '100%',
maxHeight: 160,
objectFit: 'contain',
background: '#fff',
}}
/>
</div>
) : null}
<FileUploader <FileUploader
label="拖动或点击上传底图" label={maskLocked ? '底图已锁定' : '拖动或点击上传底图'}
accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png" accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png"
acceptHint="支持 svg · jpg · png" acceptHint={maskLocked ? '同底图换名单模式' : '支持 svg · jpg · png'}
file={maskFile} file={maskFile}
onFileChange={onMaskChange} onFileChange={onMaskChange}
disabled={maskLocked}
statusText={maskStatusText}
/> />
</div> </div>
+24
View File
@@ -141,6 +141,30 @@ export async function addStickerAsset(
return sticker; return sticker;
} }
/** 服务端直接从 job 产物复制入库,避免浏览器下载/再上传大 SVG */
export async function addStickerAssetFromJob(
jobId: string,
name?: string,
): Promise<StickerAsset> {
const form = new FormData();
form.append('name', name || `词云 ${new Date().toLocaleString('zh-CN')}`);
form.append('type', 'sticker');
const res = await ensureOk(
await fetch(apiUrl(`/api/assets/from-job/${jobId}`), { method: 'POST', body: form }),
'从任务导入贴纸失败',
);
const asset = await res.json() as BackendAsset;
const sticker: StickerAsset = {
id: asset.asset_id,
name: asset.name,
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: asset.file_url,
createdAt: asset.created_at,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
return sticker;
}
export async function deleteStickerAsset(id: string): Promise<void> { export async function deleteStickerAsset(id: string): Promise<void> {
await apiDeleteAsset(id); await apiDeleteAsset(id);
removeTint(id); removeTint(id);
+439 -20
View File
@@ -9,6 +9,7 @@ import {
LineSpacingAnalysisSummary, LineSpacingAnalysisSummary,
ShapeCanvasElement, ShapeCanvasElement,
StickerAsset, StickerAsset,
WordcloudReplaceSession,
WordcloudStickerPayload, WordcloudStickerPayload,
} from '../types'; } from '../types';
import { import {
@@ -62,6 +63,7 @@ interface CanvasStudioProps {
onOpenHome: () => void; onOpenHome: () => void;
onOpenWordcloud: () => void; onOpenWordcloud: () => void;
onOpenHelp?: () => void; onOpenHelp?: () => void;
onReplaceWordcloudNames?: (session: WordcloudReplaceSession) => void;
initialDocument?: CanvasDocument | null; initialDocument?: CanvasDocument | null;
onConsumeInitialDocument?: () => void; onConsumeInitialDocument?: () => void;
pendingWordcloudSticker?: WordcloudStickerPayload | null; pendingWordcloudSticker?: WordcloudStickerPayload | null;
@@ -110,6 +112,7 @@ export default function CanvasStudio({
onOpenHome, onOpenHome,
onOpenWordcloud, onOpenWordcloud,
onOpenHelp, onOpenHelp,
onReplaceWordcloudNames,
initialDocument, initialDocument,
onConsumeInitialDocument, onConsumeInitialDocument,
pendingWordcloudSticker, pendingWordcloudSticker,
@@ -471,7 +474,165 @@ export default function CanvasStudio({
downloadBlob(blob, 'canvas-layers.zip'); downloadBlob(blob, 'canvas-layers.zip');
}; };
const applyWordcloudAssetReplace = (
replaceTarget: NonNullable<WordcloudStickerPayload['replaceTarget']>,
nextAssetId: string,
) => {
const targetId = replaceTarget.elementId;
const maskElementId = replaceTarget.maskElementId;
const maskAssetId = replaceTarget.maskAssetId;
// 安全约束:遮罩元素绝不能成为替换目标
if (maskElementId && targetId === maskElementId) {
alert('内部错误:替换目标不能是原图遮罩');
return;
}
const target = normalizedDocument.elements.find(item => item.id === targetId);
if (!target || target.type !== 'sticker') {
alert('未找到要替换的词云元素');
return;
}
// 若误把遮罩当成了目标,直接拒绝
const targetAsset = stickerById.get(target.assetId);
if (isMaskStickerAsset(targetAsset) || looksLikeMaskName(layerNameOf(target, normalizedDocument))) {
alert('当前目标是原图遮罩,已取消替换。请选择词云图层。');
return;
}
// 替换前快照遮罩元素:后续强制写回,确保“原图遮罩”图层内容不变
const maskSnapshot = maskElementId
? normalizedDocument.elements.find(item => item.id === maskElementId)
: undefined;
if (maskElementId && !maskSnapshot) {
alert('未找到原图遮罩元素,已取消替换,避免破坏组结构');
return;
}
// 库事件通常已触发刷新;这里异步兜底,确保新 asset 可渲染
void loadStickerLibrary().then(setStickers);
setDocumentModel(prev => {
let replacedCloud = false;
let nextElements = prev.elements.map(item => {
// 遮罩元素:完全保留原对象(assetId / opacity / 位置 / 图层 / 组)
if (maskElementId && item.id === maskElementId) {
return maskSnapshot || item;
}
if (item.id !== targetId || item.type !== 'sticker') {
return item;
}
replacedCloud = true;
// 只替换词云贴纸的资源引用
return {
...item,
assetId: nextAssetId,
lineSpacingAnalysis: undefined,
};
});
// 若遮罩元素在文档里丢了,强制插回(放在词云前面,保持在下层)
if (maskSnapshot && !nextElements.some(el => el.id === maskSnapshot.id)) {
const cloudIndex = nextElements.findIndex(el => el.id === targetId);
if (cloudIndex >= 0) {
nextElements = [
...nextElements.slice(0, cloudIndex),
maskSnapshot,
...nextElements.slice(cloudIndex),
];
} else {
nextElements = [...nextElements, maskSnapshot];
}
}
// 再次强制:遮罩 assetId 必须是原来的
if (maskSnapshot && maskSnapshot.type === 'sticker') {
nextElements = nextElements.map(el => {
if (el.id !== maskSnapshot.id || el.type !== 'sticker') return el;
return {
...el,
assetId: maskAssetId || maskSnapshot.assetId,
opacity: maskSnapshot.opacity,
x: maskSnapshot.x,
y: maskSnapshot.y,
width: maskSnapshot.width,
height: maskSnapshot.height,
rotation: maskSnapshot.rotation,
layerId: maskSnapshot.layerId,
groupId: maskSnapshot.groupId,
};
});
}
// 确保遮罩层、词云层都可见
const nextLayers = (prev.layers || []).map(layer => {
if (maskSnapshot && layer.id === maskSnapshot.layerId) {
return { ...layer, visible: true };
}
if (layer.id === target.layerId) {
return { ...layer, visible: true };
}
return layer;
});
if (!replacedCloud) {
console.warn('[replace] cloud element was not updated', targetId);
}
return {
...prev,
layers: nextLayers,
elements: nextElements,
};
});
setSelectedId(targetId);
setActiveLayerId(target.layerId || activeLayerId);
setOpenPanels(prev => {
const next = new Set(prev);
next.add('properties');
next.add('layers');
return Array.from(next);
});
};
const importWordcloudSticker = async (payload: WordcloudStickerPayload) => { const importWordcloudSticker = async (payload: WordcloudStickerPayload) => {
// 快路径:调用方已完成入库,这里只换词云层 assetId,遮罩层完全不动
if (payload.replaceTarget && payload.assetId) {
applyWordcloudAssetReplace(
payload.replaceTarget,
payload.assetId,
);
return;
}
if (!payload.svg) {
alert('缺少词云 SVG,无法导入');
return;
}
let cloudAsset: StickerAsset;
try {
cloudAsset = await addStickerAsset({
name: `词云 ${new Date().toLocaleString('zh-CN')}`,
type: 'svg',
source: payload.svg,
});
} catch (e) {
alert(e instanceof Error ? e.message : '贴纸保存失败');
return;
}
setStickers(await loadStickerLibrary());
// 兼容旧路径:若带 replaceTarget 但没 assetId,仍可上传后替换
if (payload.replaceTarget) {
applyWordcloudAssetReplace(payload.replaceTarget, cloudAsset.id);
return;
}
const folderId = makeId('folder'); const folderId = makeId('folder');
const elementGroupId = makeId('element-group'); const elementGroupId = makeId('element-group');
const maskLayerId = makeId('layer'); const maskLayerId = makeId('layer');
@@ -481,25 +642,20 @@ export default function CanvasStudio({
const x = Math.round((normalizedDocument.width - width) / 2); const x = Math.round((normalizedDocument.width - width) / 2);
const y = Math.round((normalizedDocument.height - height) / 2); const y = Math.round((normalizedDocument.height - height) / 2);
let cloudAsset: StickerAsset;
let maskAsset: StickerAsset | null = null; let maskAsset: StickerAsset | null = null;
try { if (payload.mask) {
cloudAsset = await addStickerAsset({ try {
name: `词云 ${new Date().toLocaleString('zh-CN')}`,
type: 'svg',
source: payload.svg,
});
if (payload.mask) {
maskAsset = await addStickerAsset({ maskAsset = await addStickerAsset({
name: `遮罩 ${payload.mask.name}`, name: `遮罩 ${payload.mask.name}`,
type: payload.mask.type === 'svg' ? 'svg' : 'image', type: payload.mask.type === 'svg' ? 'svg' : 'image',
source: payload.mask.source, source: payload.mask.source,
tint: 'gray', tint: 'gray',
}); });
} catch (e) {
alert(e instanceof Error ? e.message : '遮罩贴纸保存失败');
return;
} }
} catch (e) { setStickers(await loadStickerLibrary());
alert(e instanceof Error ? e.message : '贴纸保存失败');
return;
} }
const nextLayers: CanvasLayer[] = [ const nextLayers: CanvasLayer[] = [
@@ -513,7 +669,6 @@ export default function CanvasStudio({
layerIds: maskAsset ? [maskLayerId, cloudLayerId] : [cloudLayerId], layerIds: maskAsset ? [maskLayerId, cloudLayerId] : [cloudLayerId],
}; };
setStickers(await loadStickerLibrary());
setDocumentModel(prev => ({ setDocumentModel(prev => ({
...prev, ...prev,
layers: nextLayers, layers: nextLayers,
@@ -554,6 +709,43 @@ export default function CanvasStudio({
setOpenPanels(prev => prev.includes('layers') ? prev : [...prev, 'layers']); setOpenPanels(prev => prev.includes('layers') ? prev : [...prev, 'layers']);
}; };
const startReplaceWordcloudNames = async (element: CanvasElement) => {
if (!onReplaceWordcloudNames) {
alert('当前环境不支持切换名单');
return;
}
if (element.type !== 'sticker') {
alert('请选择词云贴纸');
return;
}
try {
const pair = resolveWordcloudPair(element, normalizedDocument, stickerById);
if (!pair) {
alert('未识别到词云组。请选择正常导入/模板中的词云贴纸(同组含底图遮罩)。');
return;
}
const maskAsset = stickerById.get(pair.maskElement.assetId);
if (!maskAsset) {
alert('底图资源不存在,无法切换名单');
return;
}
const maskSource = await stickerAssetToMaskSource(maskAsset);
onReplaceWordcloudNames({
mask: maskSource,
target: {
elementId: pair.cloudElement.id,
maskElementId: pair.maskElement.id,
maskAssetId: pair.maskElement.assetId,
width: pair.cloudElement.width,
height: pair.cloudElement.height,
},
});
} catch (error) {
alert(error instanceof Error ? error.message : '准备切换名单失败');
}
};
const resetDesign = () => { const resetDesign = () => {
setDocumentModel(createDefaultDocument()); setDocumentModel(createDefaultDocument());
setSelectedId(null); setSelectedId(null);
@@ -715,6 +907,7 @@ export default function CanvasStudio({
onDelete={deleteSelected} onDelete={deleteSelected}
onBringForward={bringForward} onBringForward={bringForward}
onSendBackward={sendBackward} onSendBackward={sendBackward}
onReplaceWordcloudNames={selectedElement ? () => { void startReplaceWordcloudNames(selectedElement); } : undefined}
/> />
); );
default: default:
@@ -1294,6 +1487,7 @@ function PropertiesPanel({
onDelete, onDelete,
onBringForward, onBringForward,
onSendBackward, onSendBackward,
onReplaceWordcloudNames,
}: { }: {
element: CanvasElement | null; element: CanvasElement | null;
elements: CanvasElement[]; elements: CanvasElement[];
@@ -1306,6 +1500,7 @@ function PropertiesPanel({
onDelete: () => void; onDelete: () => void;
onBringForward: () => void; onBringForward: () => void;
onSendBackward: () => void; onSendBackward: () => void;
onReplaceWordcloudNames?: () => void;
}) { }) {
const [spacingPercentile, setSpacingPercentile] = useState('3'); const [spacingPercentile, setSpacingPercentile] = useState('3');
const [spacingBusy, setSpacingBusy] = useState(false); const [spacingBusy, setSpacingBusy] = useState(false);
@@ -1315,9 +1510,10 @@ function PropertiesPanel({
const percentile = parseSpacingPercentile(spacingPercentile); const percentile = parseSpacingPercentile(spacingPercentile);
const selectedAsset = element?.type === 'sticker' ? stickerById.get(element.assetId) : undefined; const selectedAsset = element?.type === 'sticker' ? stickerById.get(element.assetId) : undefined;
const selectedIsWordcloud = !!(element && isWordcloudStickerElement(element, selectedAsset)); const selectedIsWordcloud = !!(element && isWordcloudStickerElement(element, selectedAsset, documentModel, stickerById));
const selectedCanReplaceNames = !!(element && resolveWordcloudPair(element, documentModel, stickerById));
const wordcloudElements = elements.filter(item => ( const wordcloudElements = elements.filter(item => (
item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId)) item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId), documentModel, stickerById)
)); ));
useEffect(() => { useEffect(() => {
@@ -1461,9 +1657,25 @@ function PropertiesPanel({
<NumberField label="透明度" value={element.opacity} min={0} max={1} step={0.05} disabled={locked} onChange={value => onUpdate({ opacity: value })} /> <NumberField label="透明度" value={element.opacity} min={0} max={1} step={0.05} disabled={locked} onChange={value => onUpdate({ opacity: value })} />
</div> </div>
{selectedIsWordcloud && ( {(selectedIsWordcloud || selectedCanReplaceNames) && (
<div className="wordcloud-spacing-panel"> <div className="wordcloud-spacing-panel">
<div className="section-divider" /> <div className="section-divider" />
<div className="section-title"></div>
<div className="settings-note">
{selectedCanReplaceNames
? '保持当前底图与画布位置,仅更换名单重新生成。'
: '当前贴纸缺少同组底图,无法切换名单。'}
</div>
<button
className="btn btn-primary btn-sm btn-block"
disabled={locked || !onReplaceWordcloudNames || !selectedCanReplaceNames}
onClick={() => onReplaceWordcloudNames?.()}
style={{ marginBottom: 12 }}
>
</button>
{selectedIsWordcloud && (
<>
<div className="section-title">线</div> <div className="section-title">线</div>
<div className="form-row"> <div className="form-row">
<NumberField <NumberField
@@ -1518,6 +1730,8 @@ function PropertiesPanel({
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
)} )}
@@ -1676,11 +1890,216 @@ function parseSpacingPercentile(raw: string) {
return Math.max(0, Math.min(100, value)); return Math.max(0, Math.min(100, value));
} }
function isWordcloudStickerElement(element: CanvasElement, asset?: StickerAsset) { function layerNameOf(element: CanvasElement, documentModel: CanvasDocument) {
return element.type === 'sticker' if (!element.layerId) return '';
&& asset?.type === 'svg' return (documentModel.layers || []).find(layer => layer.id === element.layerId)?.name?.trim() || '';
&& asset.tint !== 'gray' }
&& /^词云(?:\s|$)/.test(asset.name.trim());
function looksLikeMaskName(name: string) {
return /遮罩|底图|mask/i.test(name);
}
function looksLikeWordcloudName(name: string) {
return /词云|word\s*cloud/i.test(name);
}
function isMaskStickerAsset(asset?: StickerAsset) {
if (!asset) return false;
if (asset.tint === 'gray') return true;
return looksLikeMaskName(asset.name);
}
function isWordcloudStickerElement(
element: CanvasElement,
asset?: StickerAsset,
documentModel?: CanvasDocument,
stickerById?: Map<string, StickerAsset>,
) {
if (element.type !== 'sticker' || !asset) return false;
if (asset.tint === 'gray') return false;
const layerName = documentModel ? layerNameOf(element, documentModel) : '';
if (looksLikeWordcloudName(asset.name) || looksLikeWordcloudName(layerName)) {
return asset.type === 'svg';
}
// 模板/正常导入词云组:同组有底图遮罩时,非遮罩 SVG 贴纸也视为词云
if (documentModel && stickerById) {
const pair = resolveWordcloudPair(element, documentModel, stickerById);
return !!pair && pair.cloudElement.id === element.id;
}
return asset.type === 'svg' && /^词云(?:\s|$)/.test(asset.name.trim());
}
function stickerSiblings(
element: CanvasElement,
documentModel: CanvasDocument,
) {
const elements = documentModel.elements.filter(item => item.type === 'sticker' && item.id !== element.id) as Array<CanvasElement & { type: 'sticker' }>;
if (element.groupId) {
const sameGroup = elements.filter(item => item.groupId === element.groupId);
if (sameGroup.length > 0) return sameGroup;
}
const layer = (documentModel.layers || []).find(item => item.id === element.layerId);
const folderId = layer?.folderId;
if (folderId) {
const folderLayerIds = new Set(
(documentModel.layers || [])
.filter(item => item.folderId === folderId)
.map(item => item.id),
);
const sameFolder = elements.filter(item => item.layerId && folderLayerIds.has(item.layerId));
if (sameFolder.length > 0) return sameFolder;
}
return elements;
}
function almostSameBox(a: CanvasElement, b: CanvasElement) {
return Math.abs(a.x - b.x) <= 2
&& Math.abs(a.y - b.y) <= 2
&& Math.abs(a.width - b.width) <= 2
&& Math.abs(a.height - b.height) <= 2;
}
function scoreMaskCandidate(
element: CanvasElement,
asset: StickerAsset | undefined,
documentModel: CanvasDocument,
pairHint?: CanvasElement,
) {
if (!asset) return -100;
let score = 0;
const layerName = layerNameOf(element, documentModel);
if (asset.tint === 'gray') score += 5;
if (looksLikeMaskName(asset.name)) score += 4;
if (looksLikeMaskName(layerName)) score += 5;
if (element.opacity > 0 && element.opacity < 0.9) score += 3;
if (asset.type === 'image') score += 1;
if (pairHint && almostSameBox(element, pairHint)) score += 2;
if (pairHint && element.groupId && element.groupId === pairHint.groupId) score += 2;
if (looksLikeWordcloudName(asset.name) || looksLikeWordcloudName(layerName)) score -= 6;
return score;
}
function scoreCloudCandidate(
element: CanvasElement,
asset: StickerAsset | undefined,
documentModel: CanvasDocument,
pairHint?: CanvasElement,
) {
if (!asset || asset.type !== 'svg') return -100;
if (asset.tint === 'gray') return -100;
let score = 1;
const layerName = layerNameOf(element, documentModel);
if (looksLikeWordcloudName(asset.name)) score += 5;
if (looksLikeWordcloudName(layerName)) score += 5;
if (element.opacity >= 0.9) score += 2;
if (pairHint && almostSameBox(element, pairHint)) score += 2;
if (pairHint && element.groupId && element.groupId === pairHint.groupId) score += 2;
if (looksLikeMaskName(asset.name) || looksLikeMaskName(layerName)) score -= 6;
return score;
}
function resolveWordcloudPair(
element: CanvasElement,
documentModel: CanvasDocument,
stickerById: Map<string, StickerAsset>,
): { cloudElement: CanvasElement & { type: 'sticker' }; maskElement: CanvasElement & { type: 'sticker' } } | null {
if (element.type !== 'sticker') return null;
const selected = element as CanvasElement & { type: 'sticker' };
const selectedAsset = stickerById.get(selected.assetId);
const siblings = stickerSiblings(selected, documentModel);
if (siblings.length === 0) return null;
// 优先同位置/同尺寸的贴纸(正常导入与模板词云组通常重叠)
const rankedSiblings = [...siblings].sort((a, b) => {
const aScore = (almostSameBox(selected, a) ? 10 : 0) + (a.groupId && a.groupId === selected.groupId ? 5 : 0);
const bScore = (almostSameBox(selected, b) ? 10 : 0) + (b.groupId && b.groupId === selected.groupId ? 5 : 0);
return bScore - aScore;
});
const selectedMaskScore = scoreMaskCandidate(selected, selectedAsset, documentModel);
const selectedCloudScore = scoreCloudCandidate(selected, selectedAsset, documentModel);
// 选中遮罩时,在同组找词云
if (selectedMaskScore >= 2 && selectedMaskScore >= selectedCloudScore) {
let best: { item: CanvasElement & { type: 'sticker' }; score: number } | null = null;
for (const item of rankedSiblings) {
if (item.type !== 'sticker') continue;
const score = scoreCloudCandidate(item, stickerById.get(item.assetId), documentModel, selected);
if (!best || score > best.score) best = { item: item as CanvasElement & { type: 'sticker' }, score };
}
if (best && best.score >= 1) {
return { cloudElement: best.item, maskElement: selected };
}
}
// 选中词云(或疑似词云)时,在同组找遮罩
let bestMask: { item: CanvasElement & { type: 'sticker' }; score: number } | null = null;
for (const item of rankedSiblings) {
if (item.type !== 'sticker') continue;
const score = scoreMaskCandidate(item, stickerById.get(item.assetId), documentModel, selected);
if (!bestMask || score > bestMask.score) bestMask = { item: item as CanvasElement & { type: 'sticker' }, score };
}
if (bestMask && bestMask.score >= 2 && selectedCloudScore >= 1) {
return { cloudElement: selected, maskElement: bestMask.item };
}
// 兜底:同组/同框恰好两个贴纸,按分数分配
if (rankedSiblings.length >= 1) {
const other = rankedSiblings[0];
if (other.type === 'sticker' && (selected.groupId === other.groupId || almostSameBox(selected, other))) {
const otherAsset = stickerById.get(other.assetId);
const aMask = scoreMaskCandidate(selected, selectedAsset, documentModel, other);
const aCloud = scoreCloudCandidate(selected, selectedAsset, documentModel, other);
const bMask = scoreMaskCandidate(other, otherAsset, documentModel, selected);
const bCloud = scoreCloudCandidate(other, otherAsset, documentModel, selected);
if (aCloud >= 1 && bMask >= 2) return { cloudElement: selected, maskElement: other as CanvasElement & { type: 'sticker' } };
if (bCloud >= 1 && aMask >= 2) return { cloudElement: other as CanvasElement & { type: 'sticker' }, maskElement: selected };
}
}
return null;
}
async function stickerAssetToMaskSource(asset: StickerAsset): Promise<import('../types').WordcloudMaskSource> {
const url = assetToDataUrl(asset);
const res = await fetch(url);
if (!res.ok) {
throw new Error('读取底图资源失败');
}
const cleanName = asset.name.replace(/^(遮罩|底图)\s*/i, '').trim() || asset.name;
const contentType = res.headers.get('content-type') || '';
const isSvg = asset.type === 'svg'
|| contentType.includes('svg')
|| /\.svg(?:$|\?)/i.test(asset.source || '');
if (isSvg) {
const source = await res.text();
return {
name: cleanName,
type: 'svg',
source,
};
}
const blob = await res.blob();
// 统一转 data URL,避免跨页后相对路径失效
const source = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('读取底图失败'));
reader.readAsDataURL(blob);
});
return {
name: cleanName,
type: 'image',
source,
};
} }
function CanvasElementView({ function CanvasElementView({
+275 -15
View File
@@ -10,6 +10,7 @@ import {
NameLocation, NameLocation,
Font, Font,
WordcloudMaskSource, WordcloudMaskSource,
WordcloudReplaceSession,
WordcloudStickerPayload, WordcloudStickerPayload,
} from '../types'; } from '../types';
import ImportPanel from '../components/ImportPanel'; import ImportPanel from '../components/ImportPanel';
@@ -36,13 +37,70 @@ import {
} from '../components/Icons'; } from '../components/Icons';
const DEFAULT_PARAMS: JobParams = { const DEFAULT_PARAMS: JobParams = {
// 导入/基础(seed 前端预置 42;其余对齐 backend/core/config.py
seed: 42, seed: 42,
dataColIndex: 1, // 0-based,默认第2列(B列) dataColIndex: 1,
headerRow: 1, // 1-based,默认第1行为表头,数据从第2行开始 headerRow: 1,
weightColIndex: null, // 不指定权重列,由 ENABLE_STROKE_WEIGHTS 决定是否启用笔画权重 weightColIndex: null,
fontColor: '#000000', // 字体颜色,默认黑色 fontColor: '#000000',
nRepetitions: 1, // 词语重复填充次数,词语较少时可增大以提升填充率 nRepetitions: 1,
strokeWeights: true, // 根据笔画复杂度调整权重 strokeWeights: true,
// 字号与填充
sizeRatio: 2.0,
packingEfficiency: 0.85,
targetFillRatio: 0.0,
userMinFontSize: null,
userMaxFontSize: null,
minFontFloor: 2,
minReadableHeightPx: 25,
workScale: 0.25,
// 字号搜索
fontScaleMin: 0.5,
fontScaleMax: 1.2,
scaleSearchSteps: 7,
scaleSearchRounds: 5,
scaleDecay: 0.85,
scaleFloor: 0.25,
autoShrinkRounds: 4,
requireAllWords: true,
// 权重混合
logWeightRatio: 0.72,
rankWeightRatio: 0.28,
// 大字限制
enableSmartLargeFontReduction: true,
limitLargeFonts: true,
largeFontLimitRatio: 0.2,
largeFontThresholdRatio: 0.8,
largeFontCapRatio: 0.6,
// 分层采样
enableStratifiedSampling: true,
stratifiedBands: 3,
// 填充重试
minAcceptFillRatio: 0.75,
fillRetryRelaxLargeCap: true,
fillRetryMaxRounds: 3,
fillRetryMaxScale: 1.5,
growFontOnLowFill: false,
growFontStep: 1.05,
// 画布
autoExpandCanvas: true,
expandForSpiral: true,
expandRatio: 2.5,
fillOn: 'BLACK',
canvasRetryMaxRounds: 1,
canvasRetryGrowth: 1.12,
maxAttempts: 5,
// 布局可复现
layoutOrderMode: 'SORTED',
layoutSeed: null,
}; };
type NavItem = { type NavItem = {
@@ -76,6 +134,8 @@ interface TestWorkbenchProps {
onOpenCanvas?: () => void; onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void; onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
onOpenHelp?: () => void; onOpenHelp?: () => void;
replaceSession?: WordcloudReplaceSession | null;
onConsumeReplaceSession?: () => void;
} }
export default function TestWorkbench({ export default function TestWorkbench({
@@ -85,6 +145,8 @@ export default function TestWorkbench({
onOpenCanvas, onOpenCanvas,
onImportWordcloudSticker, onImportWordcloudSticker,
onOpenHelp, onOpenHelp,
replaceSession,
onConsumeReplaceSession,
}: TestWorkbenchProps) { }: TestWorkbenchProps) {
const [maskFile, setMaskFile] = useState<File | null>(null); const [maskFile, setMaskFile] = useState<File | null>(null);
const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null); const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null);
@@ -97,6 +159,10 @@ export default function TestWorkbench({
const [progress, setProgress] = useState<SSEProgress | null>(null); const [progress, setProgress] = useState<SSEProgress | null>(null);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']); const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
const [replaceMaskError, setReplaceMaskError] = useState('');
const appliedReplaceSessionKeyRef = useRef<string | null>(null);
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d'); const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null); const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
@@ -104,6 +170,79 @@ export default function TestWorkbench({
const [selectedFontId, setSelectedFontId] = useState<string>('__default__'); const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const sseRef = useRef<EventSource | null>(null); const sseRef = useRef<EventSource | null>(null);
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT); const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
const focusWorkbenchPanel = floatingPanels.focusPanel;
// 从画布“切换名单”进入:锁定底图,清空旧任务,等待用户只导入新名单
useEffect(() => {
if (!replaceSession) return;
const sessionKey = [
replaceSession.target.elementId,
replaceSession.target.maskElementId || '',
replaceSession.mask.name,
replaceSession.mask.type,
String(replaceSession.mask.source.length),
].join('|');
let cancelled = false;
let finished = false;
const session = replaceSession;
// 同步先把会话和底图源挂上,导入区立刻能预览原底图
setActiveReplaceSession(session);
setMaskSource(session.mask);
setReplaceMaskReady(false);
setReplaceMaskError('');
setNamesFile(null);
setNameEntries([]);
setJobId(null);
setJobResult(null);
setProgress(null);
setIsGenerating(false);
setOpenPanels(prev => {
const next = new Set(prev);
next.add('import');
next.add('export');
return Array.from(next);
});
focusWorkbenchPanel('import');
(async () => {
try {
const { maskFile: nextMaskFile, maskSubmitFile: nextMaskSubmitFile } =
await maskSourceToFiles(session.mask);
if (cancelled) return;
setMaskFile(nextMaskFile);
setMaskSubmitFile(nextMaskSubmitFile);
setMaskSource(session.mask);
setReplaceMaskReady(true);
setReplaceMaskError('');
appliedReplaceSessionKeyRef.current = sessionKey;
finished = true;
onConsumeReplaceSession?.();
} catch (error) {
console.error(error);
if (cancelled) return;
const message = error instanceof Error ? error.message : '载入底图失败';
setReplaceMaskReady(false);
setReplaceMaskError(message);
setMaskFile(null);
setMaskSubmitFile(null);
// 保留 maskSource 供预览;生成前仍会拦截
finished = true;
onConsumeReplaceSession?.();
alert(message);
}
})();
return () => {
cancelled = true;
// StrictMode 首次 effect 会被清理;若尚未完成则允许重跑
if (!finished && appliedReplaceSessionKeyRef.current !== sessionKey) {
// no-op: key 未标记完成,下一次 mount 会重新应用
}
};
}, [replaceSession, onConsumeReplaceSession, focusWorkbenchPanel]);
const workspaceRef = useRef<HTMLDivElement>(null); const workspaceRef = useRef<HTMLDivElement>(null);
const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 }); const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 });
const [workspaceSize, setWorkspaceSize] = useState<WorkspaceSize>({ width: 1200, height: 700 }); const [workspaceSize, setWorkspaceSize] = useState<WorkspaceSize>({ width: 1200, height: 700 });
@@ -298,6 +437,10 @@ export default function TestWorkbench({
// ─── 生成任务提交 ───────────────────────────────────────────────────────── // ─── 生成任务提交 ─────────────────────────────────────────────────────────
const handleGenerate = async () => { const handleGenerate = async () => {
if (isGenerating) return; if (isGenerating) return;
if (activeReplaceSession && (!replaceMaskReady || !maskSubmitFile)) {
alert(replaceMaskError || '原底图尚未载入完成,请稍候再生成');
return;
}
if (!namesFile) { if (!namesFile) {
alert('请先导入名单(.xlsx'); alert('请先导入名单(.xlsx');
return; return;
@@ -319,23 +462,64 @@ export default function TestWorkbench({
const paramsObj: Record<string, unknown> = { const paramsObj: Record<string, unknown> = {
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT', MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
DATA_COL_INDEX: usingEditedEntries ? 1 : params.dataColIndex, DATA_COL_INDEX: usingEditedEntries ? 1 : params.dataColIndex,
// 高级参数:始终传当前值,默认与 backend/core/config.py 对齐
FONT_COLOR: params.fontColor || '#000000',
N_REPETITIONS: params.nRepetitions,
ENABLE_STROKE_WEIGHTS: params.strokeWeights,
SIZE_RATIO: params.sizeRatio,
PACKING_EFFICIENCY: params.packingEfficiency,
TARGET_FILL_RATIO: params.targetFillRatio,
MIN_FONT_FLOOR: params.minFontFloor,
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
WORK_SCALE: params.workScale,
FONT_SCALE_MIN: params.fontScaleMin,
FONT_SCALE_MAX: params.fontScaleMax,
SCALE_SEARCH_STEPS: params.scaleSearchSteps,
SCALE_SEARCH_ROUNDS: params.scaleSearchRounds,
SCALE_DECAY: params.scaleDecay,
SCALE_FLOOR: params.scaleFloor,
AUTO_SHRINK_ROUNDS: params.autoShrinkRounds,
REQUIRE_ALL_WORDS: params.requireAllWords,
LOG_WEIGHT_RATIO: params.logWeightRatio,
RANK_WEIGHT_RATIO: params.rankWeightRatio,
ENABLE_SMART_LARGE_FONT_REDUCTION: params.enableSmartLargeFontReduction,
LIMIT_LARGE_FONTS: params.limitLargeFonts,
LARGE_FONT_LIMIT_RATIO: params.largeFontLimitRatio,
LARGE_FONT_THRESHOLD_RATIO: params.largeFontThresholdRatio,
LARGE_FONT_CAP_RATIO: params.largeFontCapRatio,
ENABLE_STRATIFIED_SAMPLING: params.enableStratifiedSampling,
STRATIFIED_BANDS: params.stratifiedBands,
MIN_ACCEPT_FILL_RATIO: params.minAcceptFillRatio,
FILL_RETRY_RELAX_LARGE_CAP: params.fillRetryRelaxLargeCap,
FILL_RETRY_MAX_ROUNDS: params.fillRetryMaxRounds,
FILL_RETRY_MAX_SCALE: params.fillRetryMaxScale,
GROW_FONT_ON_LOW_FILL: params.growFontOnLowFill,
GROW_FONT_STEP: params.growFontStep,
AUTO_EXPAND_CANVAS: params.autoExpandCanvas,
EXPAND_FOR_SPIRAL: params.expandForSpiral,
EXPAND_RATIO: params.expandRatio,
FILL_ON: params.fillOn,
CANVAS_RETRY_MAX_ROUNDS: params.canvasRetryMaxRounds,
CANVAS_RETRY_GROWTH: params.canvasRetryGrowth,
MAX_ATTEMPTS: params.maxAttempts,
LAYOUT_ORDER_MODE: params.layoutOrderMode,
}; };
if (params.seed !== null) { if (params.seed !== null) {
paramsObj.SEED = params.seed; // 全局随机种子 paramsObj.SEED = params.seed;
} }
if (usingEditedEntries) { if (usingEditedEntries) {
paramsObj.WEIGHT_COL_INDEX = 2; paramsObj.WEIGHT_COL_INDEX = 2;
} else if (params.weightColIndex !== null) { } else if (params.weightColIndex !== null) {
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列 paramsObj.WEIGHT_COL_INDEX = params.weightColIndex;
} }
if (params.fontColor) { if (params.userMinFontSize !== null) {
paramsObj.FONT_COLOR = params.fontColor; paramsObj.USER_MIN_FONT_SIZE = params.userMinFontSize;
} }
if (params.nRepetitions > 1) { if (params.userMaxFontSize !== null) {
paramsObj.N_REPETITIONS = params.nRepetitions; // 词语重复填充次数 paramsObj.USER_MAX_FONT_SIZE = params.userMaxFontSize;
} }
if (!params.strokeWeights) { if (params.layoutSeed !== null) {
paramsObj.ENABLE_STROKE_WEIGHTS = false; // 关闭笔画权重 paramsObj.LAYOUT_SEED = params.layoutSeed;
} }
// ── FormData(字段名严格按 README 6.5)──────────────────────────── // ── FormData(字段名严格按 README 6.5)────────────────────────────
@@ -563,6 +747,21 @@ export default function TestWorkbench({
params={params} params={params}
fonts={fonts} fonts={fonts}
selectedFontId={selectedFontId} selectedFontId={selectedFontId}
maskLocked={!!activeReplaceSession}
maskPreviewUrl={activeReplaceSession ? (
activeReplaceSession.mask.type === 'image'
? activeReplaceSession.mask.source
: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(activeReplaceSession.mask.source)}`
) : null}
maskStatusText={
activeReplaceSession
? (replaceMaskError
? `底图载入失败:${replaceMaskError}`
: replaceMaskReady
? `已载入原底图:${maskFile?.name || activeReplaceSession.mask.name}`
: '正在载入原底图…')
: undefined
}
onMaskChange={handleMaskChange} onMaskChange={handleMaskChange}
onNamesChange={handleNamesChange} onNamesChange={handleNamesChange}
onParamsChange={handleParamsChange} onParamsChange={handleParamsChange}
@@ -579,7 +778,16 @@ export default function TestWorkbench({
imageUrl={jobResult?.image_url} imageUrl={jobResult?.image_url}
onOpenCanvas={onOpenCanvas} onOpenCanvas={onOpenCanvas}
maskSource={maskSource} maskSource={maskSource}
onImportWordcloudSticker={onImportWordcloudSticker} replaceTarget={activeReplaceSession?.target}
onImportWordcloudSticker={(payload) => {
onImportWordcloudSticker?.(payload);
if (payload.replaceTarget) {
setActiveReplaceSession(null);
setReplaceMaskReady(false);
setReplaceMaskError('');
appliedReplaceSessionKeyRef.current = null;
}
}}
/> />
)} )}
{panel === 'edit' && ( {panel === 'edit' && (
@@ -601,6 +809,7 @@ export default function TestWorkbench({
embedded embedded
params={params} params={params}
onParamsChange={handleParamsChange} onParamsChange={handleParamsChange}
onResetDefaults={() => setParams(DEFAULT_PARAMS)}
/> />
)} )}
</FloatingPanel> </FloatingPanel>
@@ -614,6 +823,9 @@ export default function TestWorkbench({
<div className="navbar-brand"> <div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div> <div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span> <span className="navbar-brand-name"></span>
{activeReplaceSession && (
<span className="navbar-mode-badge"></span>
)}
</div> </div>
<div className="navbar-actions"> <div className="navbar-actions">
{NAV_ITEMS.map(item => ( {NAV_ITEMS.map(item => (
@@ -705,6 +917,54 @@ export default function TestWorkbench({
); );
} }
async function maskSourceToFiles(mask: WordcloudMaskSource) {
const baseName = (mask.name || 'mask').replace(/[\\/:*?"<>|]/g, '-').trim() || 'mask';
if (mask.type === 'svg') {
const svgText = mask.source.startsWith('data:image/svg+xml')
? await dataUrlToText(mask.source)
: mask.source;
const maskFile = new File([svgText], `${baseName}.svg`, { type: 'image/svg+xml' });
const maskSubmitFile = await svgToPngFile(svgText, maskFile.name);
return { maskFile, maskSubmitFile };
}
// image: data URL / blob URL / http(s) / relative API path
let blob: Blob;
if (mask.source.startsWith('data:')) {
blob = dataUrlToBlob(mask.source);
} else {
const response = await fetch(mask.source);
if (!response.ok) {
throw new Error(`读取底图失败(HTTP ${response.status}`);
}
blob = await response.blob();
}
const isJpeg = blob.type.includes('jpeg') || blob.type.includes('jpg') || /\.jpe?g(?:$|\?)/i.test(mask.source);
const ext = isJpeg ? 'jpg' : 'png';
const mime = blob.type || (isJpeg ? 'image/jpeg' : 'image/png');
const maskFile = new File([blob], `${baseName}.${ext}`, { type: mime });
return { maskFile, maskSubmitFile: maskFile };
}
function dataUrlToBlob(dataUrl: string): Blob {
const [header, payload = ''] = dataUrl.split(',', 2);
const mime = header.match(/data:([^;]+)/)?.[1] || 'application/octet-stream';
if (header.includes(';base64')) {
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
}
return new Blob([decodeURIComponent(payload)], { type: mime });
}
async function dataUrlToText(dataUrl: string): Promise<string> {
const blob = dataUrlToBlob(dataUrl);
return blob.text();
}
function fileToDataUrl(file: File) { function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
+14
View File
@@ -131,6 +131,20 @@ body {
letter-spacing: 0; letter-spacing: 0;
} }
.navbar-mode-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.navbar-actions { .navbar-actions {
display: flex; display: flex;
flex: 1; flex: 1;
+87 -9
View File
@@ -4,19 +4,76 @@ export interface NameEntry {
weight: number; weight: number;
} }
// 前端持有的参数状态 // 前端持有的参数状态(字段默认值与 backend/core/config.py 对齐)
// dataColIndex: 0-based,对应后端 DATA_COL_INDEX // dataColIndex: 0-based,对应后端 DATA_COL_INDEX
// headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用) // headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用)
// seed: 对应后端 SEED // seed: 对应后端 SEED(前端预置 42 便于复现;后端默认 null)
// weightColIndex: 0-based,对应后端 WEIGHT_COL_INDEXnull 表示不传) // weightColIndex: 0-based,对应后端 WEIGHT_COL_INDEXnull 表示不传)
export interface JobParams { export interface JobParams {
// 导入/基础
seed: number | null; seed: number | null;
dataColIndex: number; // 0-based,名字列索引 dataColIndex: number;
headerRow: number; // 1-based,表头行号;数据从下一行开始 headerRow: number;
weightColIndex: number | null; // 0-based,权重列索引;null=不传 weightColIndex: number | null;
fontColor: string; // 字体颜色,默认 #000000 fontColor: string;
nRepetitions: number; // 词语重复填充次数,默认 1;词语较少时可增大以提升填充率 nRepetitions: number;
strokeWeights: boolean; // 是否根据笔画复杂度调整权重,默认 true strokeWeights: boolean;
// 字号与填充
sizeRatio: number;
packingEfficiency: number;
targetFillRatio: number;
userMinFontSize: number | null;
userMaxFontSize: number | null;
minFontFloor: number;
minReadableHeightPx: number;
workScale: number;
// 字号搜索
fontScaleMin: number;
fontScaleMax: number;
scaleSearchSteps: number;
scaleSearchRounds: number;
scaleDecay: number;
scaleFloor: number;
autoShrinkRounds: number;
requireAllWords: boolean;
// 权重混合
logWeightRatio: number;
rankWeightRatio: number;
// 大字限制
enableSmartLargeFontReduction: boolean;
limitLargeFonts: boolean;
largeFontLimitRatio: number;
largeFontThresholdRatio: number;
largeFontCapRatio: number;
// 分层采样
enableStratifiedSampling: boolean;
stratifiedBands: number;
// 填充重试
minAcceptFillRatio: number;
fillRetryRelaxLargeCap: boolean;
fillRetryMaxRounds: number;
fillRetryMaxScale: number;
growFontOnLowFill: boolean;
growFontStep: number;
// 画布
autoExpandCanvas: boolean;
expandForSpiral: boolean;
expandRatio: number;
fillOn: 'BLACK' | 'WHITE';
canvasRetryMaxRounds: number;
canvasRetryGrowth: number;
maxAttempts: number;
// 布局可复现
layoutOrderMode: 'SORTED' | 'INTERLEAVED_RANDOM';
layoutSeed: number | null;
} }
// 后端 /api/jobs/{id}/result 返回结构(根据 README 6.5 + 输出说明推断) // 后端 /api/jobs/{id}/result 返回结构(根据 README 6.5 + 输出说明推断)
@@ -209,9 +266,30 @@ export interface WordcloudMaskSource {
source: string; source: string;
} }
export interface WordcloudReplaceTarget {
/** 要替换的词云元素 id(绝不是遮罩) */
elementId: string;
/** 同组原图遮罩元素 id,替换时必须原样保留 */
maskElementId?: string;
/** 原图遮罩当前 assetId,用于完整性校验 */
maskAssetId?: string;
width: number;
height: number;
}
export interface WordcloudReplaceSession {
mask: WordcloudMaskSource;
target: WordcloudReplaceTarget;
}
export interface WordcloudStickerPayload { export interface WordcloudStickerPayload {
svg: string; /** 新增导入时使用的 SVG 文本;快路径替换可省略 */
svg?: string;
mask?: WordcloudMaskSource; mask?: WordcloudMaskSource;
width?: number; width?: number;
height?: number; height?: number;
/** 已入库贴纸 ID:画布侧只换 assetId,不再二次上传 */
assetId?: string;
/** 存在时表示替换画布上已有词云,而不是新增贴纸 */
replaceTarget?: WordcloudReplaceTarget;
} }
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# WordCloud — Ubuntu 一键安装 / 启动脚本
# 用法:
# chmod +x install-ubuntu.sh
# ./install-ubuntu.sh
#
# 可选环境变量:
# SKIP_DOCKER_INSTALL=1 跳过 Docker 安装检测
# NO_BUILD=1 不强制 --build(沿用已有镜像)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
log() { echo "[INFO] $*"; }
ok() { echo "[OK] $*"; }
warn() { echo "[WARN] $*"; }
err() { echo "[ERROR] $*" >&2; }
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
elif need_cmd docker-compose; then
docker-compose "$@"
else
err "未找到 docker compose / docker-compose"
exit 1
fi
}
require_root_for_docker_install() {
if [[ "${EUID}" -ne 0 ]]; then
if need_cmd sudo; then
SUDO="sudo"
else
err "需要 root 权限安装 Docker,请用 root 运行或安装 sudo"
exit 1
fi
else
SUDO=""
fi
}
install_docker_ubuntu() {
require_root_for_docker_install
log "检测到未安装 Docker,开始在 Ubuntu 上安装..."
$SUDO apt-get update -y
$SUDO apt-get install -y ca-certificates curl gnupg lsb-release
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg
$SUDO chmod a+r /etc/apt/keyrings/docker.gpg
fi
local codename
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$UBUNTU_CODENAME}")"
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable" \
| $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update -y
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
$SUDO systemctl enable --now docker
ok "Docker 安装完成"
}
ensure_docker() {
if need_cmd docker && docker info >/dev/null 2>&1; then
ok "Docker 可用"
return
fi
if [[ "${SKIP_DOCKER_INSTALL:-0}" == "1" ]]; then
err "Docker 不可用,且设置了 SKIP_DOCKER_INSTALL=1"
exit 1
fi
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
if [[ "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *"ubuntu"* || "${ID_LIKE:-}" == *"debian"* ]]; then
install_docker_ubuntu
else
err "当前系统不是 Ubuntu/Debian,请先手动安装 Docker 后再运行本脚本"
exit 1
fi
else
err "无法识别操作系统,请先手动安装 Docker"
exit 1
fi
if ! docker info >/dev/null 2>&1; then
warn "当前用户可能无 Docker 权限,尝试使用 sudo..."
if need_cmd sudo && sudo docker info >/dev/null 2>&1; then
DOCKER_PREFIX="sudo"
else
err "Docker 已安装但当前用户无权限。请执行: sudo usermod -aG docker \$USER 后重新登录"
exit 1
fi
fi
}
DOCKER_PREFIX=""
run_compose() {
if [[ -n "${DOCKER_PREFIX}" ]]; then
$DOCKER_PREFIX docker compose "$@" 2>/dev/null || $DOCKER_PREFIX docker-compose "$@"
else
compose "$@"
fi
}
main() {
echo "========================================="
echo " WordCloud Ubuntu 一键部署"
echo " 目录: $ROOT_DIR"
echo "========================================="
if [[ ! -f "$ROOT_DIR/docker-compose.yml" ]]; then
err "未找到 docker-compose.yml,请在 release 包根目录执行本脚本"
exit 1
fi
ensure_docker
log "构建并启动服务(首次会编译 C++ 扩展与前端,可能需要几分钟)..."
if [[ "${NO_BUILD:-0}" == "1" ]]; then
run_compose up -d
else
run_compose up -d --build
fi
ok "服务已启动"
echo ""
echo "访问地址:"
echo " 前端: http://<服务器IP>:3000"
echo " 后端文档: http://<服务器IP>:8000/docs"
echo " 健康检查: http://<服务器IP>:8000/api/health"
echo ""
echo "常用命令:"
echo " make logs # 查看日志"
echo " make down # 停止服务"
echo " make restart # 重启服务"
echo " ./install-ubuntu.sh # 再次执行可重建并启动"
}
main "$@"
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# 重新生成 release/ 与 release.tar.gz,供 Ubuntu Docker Compose 一键部署。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
RELEASE_DIR="$ROOT_DIR/release"
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wordcloud-release.XXXXXX")"
ARCHIVE="$ROOT_DIR/release.tar.gz"
PKG_NAME="wordcloud"
cleanup() {
rm -rf "$STAGING_DIR"
}
trap cleanup EXIT
log() { echo "[release] $*"; }
log "staging at $STAGING_DIR"
mkdir -p "$STAGING_DIR/$PKG_NAME"
# ── root deploy files ───────────────────────────────────────
cp -a "$ROOT_DIR/docker-compose.yml" "$STAGING_DIR/$PKG_NAME/"
cp -a "$ROOT_DIR/Makefile" "$STAGING_DIR/$PKG_NAME/"
cp -a "$ROOT_DIR/DOCKER.md" "$STAGING_DIR/$PKG_NAME/"
cp -a "$ROOT_DIR/install-ubuntu.sh" "$STAGING_DIR/$PKG_NAME/"
cp -a "$ROOT_DIR/README.md" "$STAGING_DIR/$PKG_NAME/" 2>/dev/null || true
cp -a "$ROOT_DIR/CHANGELOG.md" "$STAGING_DIR/$PKG_NAME/" 2>/dev/null || true
cat > "$STAGING_DIR/$PKG_NAME/.gitignore" <<'GI'
.DS_Store
.idea/
.claude/
__pycache__/
*.py[cod]
.venv/
node_modules/
frontend/dist/
backend/service_workspace/
backend/service_assets/
backend/service_projects/
backend/service_fonts/
backend/EfficientWordCloud/build/
backend/EfficientWordCloud/dist/
backend/EfficientWordCloud/**/*.so
*.log
*.db
GI
# ── backend (source only) ───────────────────────────────────
mkdir -p "$STAGING_DIR/$PKG_NAME/backend"
rsync -a \
--exclude '.venv/' \
--exclude '.runtime/' \
--exclude '.matplotlib/' \
--exclude '.idea/' \
--exclude '.claude/' \
--exclude '.DS_Store' \
--exclude '__pycache__/' \
--exclude '*.py[cod]' \
--exclude '*.so' \
--exclude '*.o' \
--exclude '*.egg-info/' \
--exclude 'service_workspace/' \
--exclude 'service_assets/' \
--exclude 'service_projects/' \
--exclude 'service_fonts/' \
--exclude 'service_design_templates/' \
--exclude 'output/' \
--exclude 'output_cli_test/' \
--exclude 'ref/' \
--exclude 'docs/' \
--exclude 'EfficientWordCloud/build/' \
--exclude 'EfficientWordCloud/dist/' \
--exclude 'EfficientWordCloud/efficient_wordcloud.egg-info/' \
--exclude 'EfficientWordCloud/**/__pycache__/' \
--exclude 'EfficientWordCloud/**/*.so' \
--exclude 'EfficientWordCloud/**/*.o' \
"$ROOT_DIR/backend/" "$STAGING_DIR/$PKG_NAME/backend/"
# design template seeds (json only)
if [[ -d "$ROOT_DIR/backend/service_design_templates" ]]; then
mkdir -p "$STAGING_DIR/$PKG_NAME/backend/service_design_templates"
rsync -a \
--include '*/' \
--include 'template.json' \
--exclude '*' \
"$ROOT_DIR/backend/service_design_templates/" \
"$STAGING_DIR/$PKG_NAME/backend/service_design_templates/" || true
fi
# ensure runtime dirs exist as empty placeholders (volumes will mount over them)
mkdir -p \
"$STAGING_DIR/$PKG_NAME/backend/service_workspace" \
"$STAGING_DIR/$PKG_NAME/backend/service_assets" \
"$STAGING_DIR/$PKG_NAME/backend/service_projects" \
"$STAGING_DIR/$PKG_NAME/backend/service_fonts"
# keep design templates if seeded; otherwise empty
mkdir -p "$STAGING_DIR/$PKG_NAME/backend/service_design_templates"
# ── frontend ────────────────────────────────────────────────
mkdir -p "$STAGING_DIR/$PKG_NAME/frontend"
rsync -a \
--exclude 'node_modules/' \
--exclude 'dist/' \
--exclude '.DS_Store' \
--exclude '.claude/' \
--exclude '.idea/' \
"$ROOT_DIR/frontend/" "$STAGING_DIR/$PKG_NAME/frontend/"
# ── docs ────────────────────────────────────────────────────
if [[ -d "$ROOT_DIR/docs" ]]; then
mkdir -p "$STAGING_DIR/$PKG_NAME/docs"
rsync -a \
--exclude '.DS_Store' \
"$ROOT_DIR/docs/" "$STAGING_DIR/$PKG_NAME/docs/" || true
fi
chmod +x "$STAGING_DIR/$PKG_NAME/install-ubuntu.sh"
chmod +x "$STAGING_DIR/$PKG_NAME/backend/start-dev.sh" 2>/dev/null || true
# sanity: refuse if venv/workspace leaked in
if [[ -e "$STAGING_DIR/$PKG_NAME/backend/.venv" ]]; then
echo "[release] ERROR: .venv leaked into staging" >&2
exit 1
fi
if find "$STAGING_DIR/$PKG_NAME/backend/service_workspace" -mindepth 1 | read -r _; then
echo "[release] ERROR: service_workspace not empty in staging" >&2
exit 1
fi
# ── overwrite release/ ──────────────────────────────────────
log "overwrite $RELEASE_DIR"
rm -rf "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
rsync -a "$STAGING_DIR/$PKG_NAME/" "$RELEASE_DIR/"
# ── overwrite release.tar.gz ────────────────────────────────
log "write $ARCHIVE"
rm -f "$ARCHIVE"
# write to temp then atomic move to avoid truncated archive on interrupt
TMP_ARCHIVE="$(mktemp "${TMPDIR:-/tmp}/wordcloud-release-tar.XXXXXX").tar.gz"
tar -C "$STAGING_DIR" -czf "$TMP_ARCHIVE" "$PKG_NAME"
mv -f "$TMP_ARCHIVE" "$ARCHIVE"
# verify archive
tar -tzf "$ARCHIVE" >/dev/null
python3 - <<PY
import tarfile
tf = tarfile.open("$ARCHIVE", "r:gz")
names = tf.getnames()
assert "wordcloud/docker-compose.yml" in names
assert "wordcloud/install-ubuntu.sh" in names
assert "wordcloud/backend/wordcloud_generate_hybrid.py" in names
assert "wordcloud/backend/service/app.py" in names
assert "wordcloud/backend/service/line_spacing.py" in names
assert "wordcloud/frontend/src/pages/CanvasStudio.tsx" in names
assert not any(".venv" in n for n in names), "venv leaked into archive"
assert not any("/service_workspace/" in n and not n.endswith("/service_workspace") for n in names), "workspace leaked"
print("archive entries:", len(names))
print("archive ok")
PY
log "done"
echo "---- release root ----"
ls -la "$RELEASE_DIR"
echo "---- sizes ----"
du -sh "$RELEASE_DIR" "$ARCHIVE"
echo "---- backend top ----"
ls -la "$RELEASE_DIR/backend"
echo "---- frontend src files ----"
find "$RELEASE_DIR/frontend/src" -type f | wc -l