diff --git a/DOCKER.md b/DOCKER.md index 1457291..a65d255 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,184 +1,199 @@ # 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/ -├── docker-compose.yml # 编排 frontend + backend -├── Makefile # 常用命令封装 +├── docker-compose.yml +├── Makefile +├── install-ubuntu.sh # Ubuntu 一键安装/启动 +├── DOCKER.md ├── frontend/ -│ ├── Dockerfile # Node 构建 + Nginx 服务 -│ ├── nginx.conf # 静态资源 + /api 反向代理 -│ └── .dockerignore +│ ├── Dockerfile +│ ├── nginx.conf +│ └── ... └── backend/ - ├── Dockerfile # Python + C++ 扩展构建 + ├── Dockerfile ├── requirements.txt - └── .dockerignore + └── ... ``` --- -## 2. 环境要求 +## 3. 环境要求 +- Ubuntu 20.04+(推荐)或其他 Linux - Docker Engine >= 20.10 -- Docker Compose >= 1.29(或 `docker compose` plugin) -- 服务器开放端口:`3000`(前端)、`8000`(后端,可选暴露) +- Docker Compose V2 插件(`docker compose`)或 `docker-compose` >= 1.29 +- 开放端口:`3000`(前端);`8000`(后端,可选) + +若使用 `install-ubuntu.sh`,Docker 可自动安装。 --- -## 3. 快速开始 - -### 3.1 克隆/上传代码到 Ubuntu 服务器 +## 4. 手动命令 ```bash -cd /opt -# 方式 A:git clone -git clone <你的仓库地址> wordcloud -cd wordcloud - -# 方式 B:直接上传整个项目目录后进入 -cd /path/to/wordcloud -``` - -### 3.2 构建并启动 - -```bash -# 使用 Makefile(推荐) +# 构建 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 访问服务 - -- 前端页面:http://`<服务器IP>`:3000 -- 后端 API 文档:http://`<服务器IP>`:8000/docs -- 后端健康检查:http://`<服务器IP>`:8000/api/health - -> 前端 Nginx 已将所有 `/api/*` 请求反向代理到后端容器,因此浏览器只需访问 3000 端口。 +视机器性能,首次约 3–10 分钟。 --- -## 4. 常用命令 +## 5. 常用命令 | 命令 | 说明 | |------|------| +| `./install-ubuntu.sh` | Ubuntu 一键安装 Docker 并启动 | | `make build` | 重新构建镜像 | -| `make up` | 后台启动服务 | +| `make up` | 后台启动 | | `make down` | 停止并移除容器 | -| `make restart` | 重启服务 | -| `make logs` | 查看实时日志 | -| `make logs-backend` | 只看后端日志 | -| `make logs-frontend` | 只看前端日志 | -| `make clean` | 停止并删除容器 + 镜像 + 卷(谨慎) | -| `make shell-backend` | 进入后端容器调试 | +| `make restart` | 重启 | +| `make logs` | 实时日志 | +| `make logs-backend` | 后端日志 | +| `make logs-frontend` | 前端日志 | +| `make status` | 查看容器状态 | +| `make clean` | 停止并删除容器 + 镜像 + 卷(慎用) | +| `make shell-backend` | 进入后端容器 | --- -## 5. 数据持久化 +## 6. 数据持久化 -Docker Compose 已声明以下命名卷,数据会保存在 Docker 宿主机上,容器重建不会丢失: +Compose 使用命名卷,容器重建不丢数据: | 卷名 | 容器内路径 | 用途 | -|------|-----------|------| -| `wordcloud_workspace` | `/app/service_workspace` | 词云任务工作目录 | -| `wordcloud_assets` | `/app/service_assets` | 贴纸资源文件 | -| `wordcloud_projects` | `/app/service_projects` | 保存的项目 | +|------|------------|------| +| `wordcloud_workspace` | `/app/service_workspace` | 任务输入输出 | +| `wordcloud_assets` | `/app/service_assets` | 贴纸/素材 | +| `wordcloud_projects` | `/app/service_projects` | 项目 | | `wordcloud_design_templates` | `/app/service_design_templates` | 设计模板 | -| `wordcloud_fonts` | `/app/service_fonts` | 上传的字体文件 | +| `wordcloud_fonts` | `/app/service_fonts` | 上传字体 | -如需查看本地卷位置: +查看卷: ```bash +docker volume ls | grep wordcloud docker volume inspect wordcloud_workspace ``` --- -## 6. 端口与网络 +## 7. 端口修改 -- `frontend` 容器监听 `3000:80` -- `backend` 容器监听 `8000:8000` -- 两个服务通过默认 Docker bridge 网络通信,`frontend` 的 Nginx 通过服务名 `backend:8000` 访问后端。 +编辑 `docker-compose.yml` 的 `ports`,例如前端改为 `8080:80`: -如需修改端口,编辑 `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 ports: - "8080:80" ``` ---- - -## 9. 本地开发(非 Docker) - -如需本地开发,可分别运行: +然后: ```bash -# 后端 -cd backend -./start-dev.sh - -# 前端 -cd frontend -npm install -npm run dev +make up ``` -本地开发时前端通过 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` diff --git a/Makefile b/Makefile index 51c881d..8745d5e 100644 --- a/Makefile +++ b/Makefile @@ -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: @@ -38,3 +39,14 @@ clean: prune: 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" diff --git a/backend/.dockerignore b/backend/.dockerignore index 8718be2..086767f 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -13,7 +13,6 @@ __pycache__ service_workspace service_assets service_projects -service_design_templates service_fonts *.egg-info build diff --git a/backend/Dockerfile b/backend/Dockerfile index a010df2..8bf63cb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,15 +22,21 @@ COPY EfficientWordCloud ./EfficientWordCloud COPY assets ./assets COPY start-dev.sh ./ 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 RUN cd EfficientWordCloud && python setup.py build_ext --inplace # 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 ENV PYTHONPATH=/app/EfficientWordCloud +ENTRYPOINT ["/app/docker-entrypoint.sh"] CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100755 index 0000000..e7603d0 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -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 "$@" diff --git a/backend/service/app.py b/backend/service/app.py index 5481c9c..db78e88 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -885,27 +885,76 @@ async def upload_asset( return Asset(**meta) -@app.post("/api/assets/from-job/{job_id}", response_model=Asset) -async def import_asset_from_job( - job_id: str, - name: str = Form(""), -) -> Asset: - # 尝试从内存获取产物路径;若服务已重启则从磁盘回退 + +def _svg_with_transparent_background(svg_bytes: bytes) -> bytes: + """Convert full-canvas opaque backgrounds to transparent for sticker use. + + 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 = [ + # + ( + r'(]*\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'(]*\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 if manager.exists(job_id): status = manager.get_status(job_id) svg_path_str = status.artifacts.get("svg", "") if svg_path_str: - svg_path = Path(svg_path_str) - else: - # 服务重启后内存丢失,直接从工作区目录查找 - fallback_dir = storage.base_dir / job_id / "output" - if fallback_dir.exists(): - for candidate in fallback_dir.glob("*.svg"): + candidate = Path(svg_path_str) + if candidate.exists(): 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(): 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) dest = asset_path / "asset.svg" - shutil.copy2(svg_path, dest) - - content = dest.read_bytes() + raw = svg_path.read_bytes() + # 画布贴纸需要透明底;任务主 SVG 默认带白底 rect + content = _svg_with_transparent_background(raw) + dest.write_bytes(content) width, height = _parse_svg_viewbox(dest) + asset_type = (type or "wordcloud").strip() or "wordcloud" display_name = name or f"词云_{job_id[:8]}" meta = { "asset_id": asset_id, "name": display_name, - "type": "wordcloud", + "type": asset_type, "mime_type": "image/svg+xml", "width": width, "height": height, diff --git a/backend/service/runner.py b/backend/service/runner.py index a7a833d..4d23d67 100644 --- a/backend/service/runner.py +++ b/backend/service/runner.py @@ -96,7 +96,12 @@ class JobRunner: log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed) 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) db = next(paths.output_dir.glob("*.db"), None) metrics = next(paths.output_dir.glob("*metrics*.json"), None) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 526e5b2..27a4e69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import CanvasStudio from './pages/CanvasStudio'; import HelpPage from './pages/HelpPage'; import TemplateHome from './pages/TemplateHome'; import TestWorkbench from './pages/TestWorkbench'; -import { CanvasDocument, WordcloudStickerPayload } from './types'; +import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types'; import { createDefaultDocument } from './lib/canvasDocument'; type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help'; @@ -23,6 +23,7 @@ export default function App() { const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme); const [initialDocument, setInitialDocument] = useState(null); const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState(null); + const [pendingReplaceSession, setPendingReplaceSession] = useState(null); const openHelp = () => setPage('help'); @@ -53,8 +54,11 @@ export default function App() { onThemeModeChange={setThemeMode} onOpenCanvas={() => setPage('canvas')} onOpenHelp={openHelp} + replaceSession={pendingReplaceSession} + onConsumeReplaceSession={() => setPendingReplaceSession(null)} onImportWordcloudSticker={(payload) => { setPendingWordcloudSticker(payload); + setPendingReplaceSession(null); setPage('canvas'); }} /> @@ -68,8 +72,15 @@ export default function App() { systemTheme={systemTheme} onThemeModeChange={setThemeMode} onOpenHome={() => setPage('home')} - onOpenWordcloud={() => setPage('wordcloud')} + onOpenWordcloud={() => { + setPendingReplaceSession(null); + setPage('wordcloud'); + }} onOpenHelp={openHelp} + onReplaceWordcloudNames={(session) => { + setPendingReplaceSession(session); + setPage('wordcloud'); + }} initialDocument={initialDocument} onConsumeInitialDocument={() => setInitialDocument(null)} pendingWordcloudSticker={pendingWordcloudSticker} diff --git a/frontend/src/components/AdvancedPanel.tsx b/frontend/src/components/AdvancedPanel.tsx index f6dbca8..13b4b52 100644 --- a/frontend/src/components/AdvancedPanel.tsx +++ b/frontend/src/components/AdvancedPanel.tsx @@ -1,12 +1,110 @@ +import type { ReactNode } from 'react'; import { JobParams } from '../types'; interface AdvancedPanelProps { params: JobParams; onParamsChange: (partial: Partial) => void; 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 ( +
+ {children} +
+ ); +} + +function Hint({ children }: { children: ReactNode }) { + return {children}; +} + +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 ( +
+ + { + if (e.target.value.trim() === '') { + onChange(null); + return; + } + const n = Number(e.target.value); + onChange(Number.isNaN(n) ? null : n); + }} + /> +
+ ); +} + +function BoolField({ + label, + checked, + onChange, + hint, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; + hint?: string; +}) { + return ( +
+ + {hint ? {hint} : null} +
+ ); +} + +export default function AdvancedPanel({ + params, + onParamsChange, + embedded = false, + onResetDefaults, +}: AdvancedPanelProps) { return ( <> {!embedded && ( @@ -15,8 +113,15 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false )}
+ {onResetDefaults && ( +
+ +
+ )} - {/* SEED */} + 基础
{ - const v = e.target.value.trim(); - onParamsChange({ seed: v === '' ? null : parseInt(v) }); - }} + onChange={e => onParamsChange({ seed: parseOptionalInt(e.target.value) })} /> - 相同种子可完整复现布局结果 + 相同种子可完整复现布局结果;默认 42
-
- - {/* FONT_COLOR */}
@@ -54,55 +153,386 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false style={{ flex: 1 }} />
- 默认黑色,留空则使用调色板渐变 + 默认黑色;清空颜色逻辑由后端调色板接管需另配
-
- - {/* N_REPETITIONS */} -
- - + { - const v = parseInt(e.target.value); - onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) }); - }} + step={1} + onChange={v => onParamsChange({ nRepetitions: Math.max(1, Math.min(20, Math.round(v ?? 1))) })} /> - - 词语较少时(如仅 10 个),可增大此值(如 5~10)使每个词语重复出现多次,提升填充率与观感。默认 1(不重复)。 - +
+ + +
+ 名单较少时可增大重复次数(如 5~10)提升填充观感
+ 字号与比例 +
+ onParamsChange({ sizeRatio: v ?? 2 })} + /> + onParamsChange({ packingEfficiency: v ?? 0.85 })} + /> +
+ SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差 - {/* STROKE_WEIGHTS */} -
-