Add advanced canvas editing and Ubuntu deployment
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,7 +13,6 @@ __pycache__
|
||||
service_workspace
|
||||
service_assets
|
||||
service_projects
|
||||
service_design_templates
|
||||
service_fonts
|
||||
*.egg-info
|
||||
build
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
Executable
+24
@@ -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
@@ -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 = [
|
||||
# <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
|
||||
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-2
@@ -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<CanvasDocument | null>(null);
|
||||
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
|
||||
const [pendingReplaceSession, setPendingReplaceSession] = useState<WordcloudReplaceSession | null>(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}
|
||||
|
||||
@@ -1,12 +1,110 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { JobParams } from '../types';
|
||||
|
||||
interface AdvancedPanelProps {
|
||||
params: JobParams;
|
||||
onParamsChange: (partial: Partial<JobParams>) => 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 (
|
||||
<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 (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -15,8 +113,15 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
<label className="form-label">随机种子(SEED)</label>
|
||||
<input
|
||||
@@ -25,17 +130,11 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
|
||||
min={0}
|
||||
value={params.seed ?? ''}
|
||||
placeholder="留空=不固定种子"
|
||||
onChange={e => {
|
||||
const v = e.target.value.trim();
|
||||
onParamsChange({ seed: v === '' ? null : parseInt(v) });
|
||||
}}
|
||||
onChange={e => onParamsChange({ seed: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
<span className="text-xs text-muted">相同种子可完整复现布局结果</span>
|
||||
<Hint>相同种子可完整复现布局结果;默认 42</Hint>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* FONT_COLOR */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">字体颜色</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -54,55 +153,386 @@ export default function AdvancedPanel({ params, onParamsChange, embedded = false
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted">默认黑色,留空则使用调色板渐变</span>
|
||||
<Hint>默认黑色;清空颜色逻辑由后端调色板接管需另配</Hint>
|
||||
</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
|
||||
{/* N_REPETITIONS */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">词语重复填充次数</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
<div className="form-row">
|
||||
<NumberField
|
||||
label="词语重复次数"
|
||||
value={params.nRepetitions}
|
||||
min={1}
|
||||
max={20}
|
||||
value={params.nRepetitions}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value);
|
||||
onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) });
|
||||
}}
|
||||
step={1}
|
||||
onChange={v => onParamsChange({ nRepetitions: Math.max(1, Math.min(20, Math.round(v ?? 1))) })}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
词语较少时(如仅 10 个),可增大此值(如 5~10)使每个词语重复出现多次,提升填充率与观感。默认 1(不重复)。
|
||||
</span>
|
||||
<div className="form-group">
|
||||
<label className="form-label">阴阳刻 FILL_ON</label>
|
||||
<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>
|
||||
<Hint>名单较少时可增大重复次数(如 5~10)提升填充观感</Hint>
|
||||
|
||||
<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-group">
|
||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.strokeWeights}
|
||||
onChange={e => onParamsChange({ strokeWeights: e.target.checked })}
|
||||
<div className="form-row">
|
||||
<NumberField
|
||||
label="用户最小字号"
|
||||
value={params.userMinFontSize}
|
||||
min={1}
|
||||
step={1}
|
||||
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 })}
|
||||
/>
|
||||
根据笔画复杂度调整权重
|
||||
</label>
|
||||
<span className="text-xs text-muted">
|
||||
开启后,笔画复杂的字(如"鑫")会分配更大字号,笔画简单的字(如"一")字号较小。关闭则所有词语按均等权重分配字号。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 }}>
|
||||
更多高级参数(字号范围、填充策略、画布尺寸等)可通过后端
|
||||
<code style={{ fontSize: 10, background: 'var(--bg)', padding: '0 3px', borderRadius: 2 }}>
|
||||
config.json
|
||||
</code>
|
||||
配置,详见 README 4.8 节。
|
||||
以上参数默认值与后端 <code style={{ fontSize: 10 }}>backend/core/config.py</code> 一致。
|
||||
字号观感可优先调节 <strong>SIZE_RATIO</strong>、<strong>大字限制</strong> 与{' '}
|
||||
<strong>笔画权重</strong>。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
|
||||
import { WordcloudMaskSource, WordcloudReplaceTarget, WordcloudStickerPayload } from '../types';
|
||||
import { apiUrl, ensureOk } from '../lib/api';
|
||||
import { addStickerAssetFromJob } from '../lib/stickerLibrary';
|
||||
|
||||
interface ExportPanelProps {
|
||||
jobId: string | null;
|
||||
@@ -8,6 +9,7 @@ interface ExportPanelProps {
|
||||
imageUrl?: string;
|
||||
onOpenCanvas?: () => void;
|
||||
maskSource?: WordcloudMaskSource | null;
|
||||
replaceTarget?: WordcloudReplaceTarget | null;
|
||||
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
@@ -21,6 +23,7 @@ export default function ExportPanel({
|
||||
imageUrl,
|
||||
onOpenCanvas,
|
||||
maskSource,
|
||||
replaceTarget = null,
|
||||
onImportWordcloudSticker,
|
||||
embedded = false,
|
||||
}: ExportPanelProps) {
|
||||
@@ -74,16 +77,48 @@ export default function ExportPanel({
|
||||
triggerDownload(url, 'wordcloud.svg');
|
||||
};
|
||||
|
||||
const handleSaveAsSticker = async () => {
|
||||
const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg');
|
||||
if (!url || isSavingSticker) return;
|
||||
const handleSaveAsSticker = async (mode: 'add' | 'replace' = 'add') => {
|
||||
if (isSavingSticker) return;
|
||||
if (mode === 'replace' && !replaceTarget) {
|
||||
alert('当前不是同底图换名单模式');
|
||||
return;
|
||||
}
|
||||
if (!jobId) {
|
||||
alert('请先生成词云');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingSticker(true);
|
||||
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 svg = await res.text();
|
||||
if (onImportWordcloudSticker) {
|
||||
onImportWordcloudSticker({ svg, mask: maskSource || undefined });
|
||||
onImportWordcloudSticker({
|
||||
svg,
|
||||
mask: maskSource || undefined,
|
||||
});
|
||||
} else {
|
||||
onOpenCanvas?.();
|
||||
}
|
||||
@@ -304,13 +339,28 @@ export default function ExportPanel({
|
||||
>
|
||||
导出 SVG
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm btn-block"
|
||||
disabled={!hasResult || isSavingSticker}
|
||||
onClick={handleSaveAsSticker}
|
||||
>
|
||||
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
|
||||
</button>
|
||||
{replaceTarget ? (
|
||||
<>
|
||||
<div className="settings-note" style={{ marginTop: 8 }}>
|
||||
同底图换名单模式:替换画布上的原词云,不新增贴纸。
|
||||
</div>
|
||||
<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" />
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ interface FileUploaderProps {
|
||||
acceptHint: string;
|
||||
file: File | null;
|
||||
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 [dragging, setDragging] = useState(false);
|
||||
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>) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (disabled) return;
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (!dropped) return;
|
||||
validateAndSet(dropped);
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
validateAndSet(selected);
|
||||
@@ -40,21 +44,32 @@ export default function FileUploader({ label, accept, acceptHint, file, onFileCh
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="form-group">
|
||||
<div
|
||||
className={`upload-zone${dragging ? ' drag-over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
className={`upload-zone${dragging ? ' drag-over' : ''}${disabled ? ' disabled' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); if (!disabled) setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onClick={() => { if (!disabled) inputRef.current?.click(); }}
|
||||
style={disabled ? { opacity: 0.65, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
</div>
|
||||
{(hasFile || error) && (
|
||||
{showStatus && (
|
||||
<div className={`status-bar ${statusClass}`}>
|
||||
<span className="status-bar-filename">
|
||||
{error ? error : file?.name}
|
||||
{filenameText}
|
||||
</span>
|
||||
<span className="status-bar-label">
|
||||
{error ? '导入失败' : '导入成功'}
|
||||
{labelText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,9 @@ interface ImportPanelProps {
|
||||
onFontDelete: (fontId: string) => void;
|
||||
onFontSelect: (fontId: string) => void;
|
||||
embedded?: boolean;
|
||||
maskLocked?: boolean;
|
||||
maskPreviewUrl?: string | null;
|
||||
maskStatusText?: string;
|
||||
}
|
||||
|
||||
const TEMPLATE_URL = '#';
|
||||
@@ -25,6 +28,9 @@ export default function ImportPanel({
|
||||
onMaskChange, onNamesChange, onParamsChange,
|
||||
onFontUpload, onFontDelete, onFontSelect,
|
||||
embedded = false,
|
||||
maskLocked = false,
|
||||
maskPreviewUrl = null,
|
||||
maskStatusText,
|
||||
}: ImportPanelProps) {
|
||||
const fontInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -48,12 +54,40 @@ export default function ImportPanel({
|
||||
{/* 底图导入 */}
|
||||
<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
|
||||
label="拖动或点击上传底图"
|
||||
label={maskLocked ? '底图已锁定' : '拖动或点击上传底图'}
|
||||
accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png"
|
||||
acceptHint="支持 svg · jpg · png"
|
||||
acceptHint={maskLocked ? '同底图换名单模式' : '支持 svg · jpg · png'}
|
||||
file={maskFile}
|
||||
onFileChange={onMaskChange}
|
||||
disabled={maskLocked}
|
||||
statusText={maskStatusText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,6 +141,30 @@ export async function addStickerAsset(
|
||||
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> {
|
||||
await apiDeleteAsset(id);
|
||||
removeTint(id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LineSpacingAnalysisSummary,
|
||||
ShapeCanvasElement,
|
||||
StickerAsset,
|
||||
WordcloudReplaceSession,
|
||||
WordcloudStickerPayload,
|
||||
} from '../types';
|
||||
import {
|
||||
@@ -62,6 +63,7 @@ interface CanvasStudioProps {
|
||||
onOpenHome: () => void;
|
||||
onOpenWordcloud: () => void;
|
||||
onOpenHelp?: () => void;
|
||||
onReplaceWordcloudNames?: (session: WordcloudReplaceSession) => void;
|
||||
initialDocument?: CanvasDocument | null;
|
||||
onConsumeInitialDocument?: () => void;
|
||||
pendingWordcloudSticker?: WordcloudStickerPayload | null;
|
||||
@@ -110,6 +112,7 @@ export default function CanvasStudio({
|
||||
onOpenHome,
|
||||
onOpenWordcloud,
|
||||
onOpenHelp,
|
||||
onReplaceWordcloudNames,
|
||||
initialDocument,
|
||||
onConsumeInitialDocument,
|
||||
pendingWordcloudSticker,
|
||||
@@ -471,7 +474,165 @@ export default function CanvasStudio({
|
||||
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) => {
|
||||
|
||||
// 快路径:调用方已完成入库,这里只换词云层 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 elementGroupId = makeId('element-group');
|
||||
const maskLayerId = makeId('layer');
|
||||
@@ -481,25 +642,20 @@ export default function CanvasStudio({
|
||||
const x = Math.round((normalizedDocument.width - width) / 2);
|
||||
const y = Math.round((normalizedDocument.height - height) / 2);
|
||||
|
||||
let cloudAsset: StickerAsset;
|
||||
let maskAsset: StickerAsset | null = null;
|
||||
try {
|
||||
cloudAsset = await addStickerAsset({
|
||||
name: `词云 ${new Date().toLocaleString('zh-CN')}`,
|
||||
type: 'svg',
|
||||
source: payload.svg,
|
||||
});
|
||||
if (payload.mask) {
|
||||
if (payload.mask) {
|
||||
try {
|
||||
maskAsset = await addStickerAsset({
|
||||
name: `遮罩 ${payload.mask.name}`,
|
||||
type: payload.mask.type === 'svg' ? 'svg' : 'image',
|
||||
source: payload.mask.source,
|
||||
tint: 'gray',
|
||||
});
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '遮罩贴纸保存失败');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '贴纸保存失败');
|
||||
return;
|
||||
setStickers(await loadStickerLibrary());
|
||||
}
|
||||
|
||||
const nextLayers: CanvasLayer[] = [
|
||||
@@ -513,7 +669,6 @@ export default function CanvasStudio({
|
||||
layerIds: maskAsset ? [maskLayerId, cloudLayerId] : [cloudLayerId],
|
||||
};
|
||||
|
||||
setStickers(await loadStickerLibrary());
|
||||
setDocumentModel(prev => ({
|
||||
...prev,
|
||||
layers: nextLayers,
|
||||
@@ -554,6 +709,43 @@ export default function CanvasStudio({
|
||||
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 = () => {
|
||||
setDocumentModel(createDefaultDocument());
|
||||
setSelectedId(null);
|
||||
@@ -715,6 +907,7 @@ export default function CanvasStudio({
|
||||
onDelete={deleteSelected}
|
||||
onBringForward={bringForward}
|
||||
onSendBackward={sendBackward}
|
||||
onReplaceWordcloudNames={selectedElement ? () => { void startReplaceWordcloudNames(selectedElement); } : undefined}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -1294,6 +1487,7 @@ function PropertiesPanel({
|
||||
onDelete,
|
||||
onBringForward,
|
||||
onSendBackward,
|
||||
onReplaceWordcloudNames,
|
||||
}: {
|
||||
element: CanvasElement | null;
|
||||
elements: CanvasElement[];
|
||||
@@ -1306,6 +1500,7 @@ function PropertiesPanel({
|
||||
onDelete: () => void;
|
||||
onBringForward: () => void;
|
||||
onSendBackward: () => void;
|
||||
onReplaceWordcloudNames?: () => void;
|
||||
}) {
|
||||
const [spacingPercentile, setSpacingPercentile] = useState('3');
|
||||
const [spacingBusy, setSpacingBusy] = useState(false);
|
||||
@@ -1315,9 +1510,10 @@ function PropertiesPanel({
|
||||
|
||||
const percentile = parseSpacingPercentile(spacingPercentile);
|
||||
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 => (
|
||||
item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId))
|
||||
item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId), documentModel, stickerById)
|
||||
));
|
||||
|
||||
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 })} />
|
||||
</div>
|
||||
|
||||
{selectedIsWordcloud && (
|
||||
{(selectedIsWordcloud || selectedCanReplaceNames) && (
|
||||
<div className="wordcloud-spacing-panel">
|
||||
<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="form-row">
|
||||
<NumberField
|
||||
@@ -1518,6 +1730,8 @@ function PropertiesPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1676,11 +1890,216 @@ function parseSpacingPercentile(raw: string) {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function isWordcloudStickerElement(element: CanvasElement, asset?: StickerAsset) {
|
||||
return element.type === 'sticker'
|
||||
&& asset?.type === 'svg'
|
||||
&& asset.tint !== 'gray'
|
||||
&& /^词云(?:\s|$)/.test(asset.name.trim());
|
||||
function layerNameOf(element: CanvasElement, documentModel: CanvasDocument) {
|
||||
if (!element.layerId) return '';
|
||||
return (documentModel.layers || []).find(layer => layer.id === element.layerId)?.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({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
NameLocation,
|
||||
Font,
|
||||
WordcloudMaskSource,
|
||||
WordcloudReplaceSession,
|
||||
WordcloudStickerPayload,
|
||||
} from '../types';
|
||||
import ImportPanel from '../components/ImportPanel';
|
||||
@@ -36,13 +37,70 @@ import {
|
||||
} from '../components/Icons';
|
||||
|
||||
const DEFAULT_PARAMS: JobParams = {
|
||||
// 导入/基础(seed 前端预置 42;其余对齐 backend/core/config.py)
|
||||
seed: 42,
|
||||
dataColIndex: 1, // 0-based,默认第2列(B列)
|
||||
headerRow: 1, // 1-based,默认第1行为表头,数据从第2行开始
|
||||
weightColIndex: null, // 不指定权重列,由 ENABLE_STROKE_WEIGHTS 决定是否启用笔画权重
|
||||
fontColor: '#000000', // 字体颜色,默认黑色
|
||||
nRepetitions: 1, // 词语重复填充次数,词语较少时可增大以提升填充率
|
||||
strokeWeights: true, // 根据笔画复杂度调整权重
|
||||
dataColIndex: 1,
|
||||
headerRow: 1,
|
||||
weightColIndex: null,
|
||||
fontColor: '#000000',
|
||||
nRepetitions: 1,
|
||||
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 = {
|
||||
@@ -76,6 +134,8 @@ interface TestWorkbenchProps {
|
||||
onOpenCanvas?: () => void;
|
||||
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
|
||||
onOpenHelp?: () => void;
|
||||
replaceSession?: WordcloudReplaceSession | null;
|
||||
onConsumeReplaceSession?: () => void;
|
||||
}
|
||||
|
||||
export default function TestWorkbench({
|
||||
@@ -85,6 +145,8 @@ export default function TestWorkbench({
|
||||
onOpenCanvas,
|
||||
onImportWordcloudSticker,
|
||||
onOpenHelp,
|
||||
replaceSession,
|
||||
onConsumeReplaceSession,
|
||||
}: TestWorkbenchProps) {
|
||||
const [maskFile, setMaskFile] = 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 [isGenerating, setIsGenerating] = useState(false);
|
||||
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 [zoom, setZoom] = useState(1);
|
||||
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
|
||||
@@ -104,6 +170,79 @@ export default function TestWorkbench({
|
||||
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
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 workspaceSizeRef = useRef<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 () => {
|
||||
if (isGenerating) return;
|
||||
if (activeReplaceSession && (!replaceMaskReady || !maskSubmitFile)) {
|
||||
alert(replaceMaskError || '原底图尚未载入完成,请稍候再生成');
|
||||
return;
|
||||
}
|
||||
if (!namesFile) {
|
||||
alert('请先导入名单(.xlsx)');
|
||||
return;
|
||||
@@ -319,23 +462,64 @@ export default function TestWorkbench({
|
||||
const paramsObj: Record<string, unknown> = {
|
||||
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
|
||||
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) {
|
||||
paramsObj.SEED = params.seed; // 全局随机种子
|
||||
paramsObj.SEED = params.seed;
|
||||
}
|
||||
if (usingEditedEntries) {
|
||||
paramsObj.WEIGHT_COL_INDEX = 2;
|
||||
} else if (params.weightColIndex !== null) {
|
||||
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列
|
||||
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex;
|
||||
}
|
||||
if (params.fontColor) {
|
||||
paramsObj.FONT_COLOR = params.fontColor;
|
||||
if (params.userMinFontSize !== null) {
|
||||
paramsObj.USER_MIN_FONT_SIZE = params.userMinFontSize;
|
||||
}
|
||||
if (params.nRepetitions > 1) {
|
||||
paramsObj.N_REPETITIONS = params.nRepetitions; // 词语重复填充次数
|
||||
if (params.userMaxFontSize !== null) {
|
||||
paramsObj.USER_MAX_FONT_SIZE = params.userMaxFontSize;
|
||||
}
|
||||
if (!params.strokeWeights) {
|
||||
paramsObj.ENABLE_STROKE_WEIGHTS = false; // 关闭笔画权重
|
||||
if (params.layoutSeed !== null) {
|
||||
paramsObj.LAYOUT_SEED = params.layoutSeed;
|
||||
}
|
||||
|
||||
// ── FormData(字段名严格按 README 6.5)────────────────────────────
|
||||
@@ -563,6 +747,21 @@ export default function TestWorkbench({
|
||||
params={params}
|
||||
fonts={fonts}
|
||||
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}
|
||||
onNamesChange={handleNamesChange}
|
||||
onParamsChange={handleParamsChange}
|
||||
@@ -579,7 +778,16 @@ export default function TestWorkbench({
|
||||
imageUrl={jobResult?.image_url}
|
||||
onOpenCanvas={onOpenCanvas}
|
||||
maskSource={maskSource}
|
||||
onImportWordcloudSticker={onImportWordcloudSticker}
|
||||
replaceTarget={activeReplaceSession?.target}
|
||||
onImportWordcloudSticker={(payload) => {
|
||||
onImportWordcloudSticker?.(payload);
|
||||
if (payload.replaceTarget) {
|
||||
setActiveReplaceSession(null);
|
||||
setReplaceMaskReady(false);
|
||||
setReplaceMaskError('');
|
||||
appliedReplaceSessionKeyRef.current = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{panel === 'edit' && (
|
||||
@@ -601,6 +809,7 @@ export default function TestWorkbench({
|
||||
embedded
|
||||
params={params}
|
||||
onParamsChange={handleParamsChange}
|
||||
onResetDefaults={() => setParams(DEFAULT_PARAMS)}
|
||||
/>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
@@ -614,6 +823,9 @@ export default function TestWorkbench({
|
||||
<div className="navbar-brand">
|
||||
<div className="navbar-brand-icon"><IconCloud /></div>
|
||||
<span className="navbar-brand-name">词云工具</span>
|
||||
{activeReplaceSession && (
|
||||
<span className="navbar-mode-badge">同底图换名单</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="navbar-actions">
|
||||
{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) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -131,6 +131,20 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
+87
-9
@@ -4,19 +4,76 @@ export interface NameEntry {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
// 前端持有的参数状态
|
||||
// 前端持有的参数状态(字段默认值与 backend/core/config.py 对齐)
|
||||
// dataColIndex: 0-based,对应后端 DATA_COL_INDEX
|
||||
// headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用)
|
||||
// seed: 对应后端 SEED
|
||||
// seed: 对应后端 SEED(前端预置 42 便于复现;后端默认 null)
|
||||
// weightColIndex: 0-based,对应后端 WEIGHT_COL_INDEX(null 表示不传)
|
||||
export interface JobParams {
|
||||
// 导入/基础
|
||||
seed: number | null;
|
||||
dataColIndex: number; // 0-based,名字列索引
|
||||
headerRow: number; // 1-based,表头行号;数据从下一行开始
|
||||
weightColIndex: number | null; // 0-based,权重列索引;null=不传
|
||||
fontColor: string; // 字体颜色,默认 #000000
|
||||
nRepetitions: number; // 词语重复填充次数,默认 1;词语较少时可增大以提升填充率
|
||||
strokeWeights: boolean; // 是否根据笔画复杂度调整权重,默认 true
|
||||
dataColIndex: number;
|
||||
headerRow: number;
|
||||
weightColIndex: number | null;
|
||||
fontColor: string;
|
||||
nRepetitions: number;
|
||||
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 + 输出说明推断)
|
||||
@@ -209,9 +266,30 @@ export interface WordcloudMaskSource {
|
||||
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 {
|
||||
svg: string;
|
||||
/** 新增导入时使用的 SVG 文本;快路径替换可省略 */
|
||||
svg?: string;
|
||||
mask?: WordcloudMaskSource;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** 已入库贴纸 ID:画布侧只换 assetId,不再二次上传 */
|
||||
assetId?: string;
|
||||
/** 存在时表示替换画布上已有词云,而不是新增贴纸 */
|
||||
replaceTarget?: WordcloudReplaceTarget;
|
||||
}
|
||||
|
||||
Executable
+153
@@ -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 "$@"
|
||||
Executable
+171
@@ -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
|
||||
Reference in New Issue
Block a user