diff --git a/README.md b/README.md index add376b..a2cdcc2 100644 --- a/README.md +++ b/README.md @@ -48,93 +48,62 @@ CapaReport/ │ └─ config.py # 配置读写 ├─ frontend/ # Vue 前端 ├─ src-tauri/ # Tauri 桌面壳 -├─ scripts/ # 一键构建脚本 -├─ packaging/ # Docker、Compose、PyInstaller 配置 -├─ dist/ # 一键构建后的最终产物 +├─ scripts/ # 运行 / 编译 Python 脚本(dev、run、tauri、docker、clean...) +├─ packaging/ # Dockerfile、Compose、PyInstaller 配置 +├─ dist/ # 编译后的最终产物 ├─ docs/project_context.md # 项目维护记录 ├─ Configure.json # 应用配置 ├─ ReportScript.sql # SQL 处理脚本 ├─ requirements.txt # Python 依赖 -└─ run.bat # Windows 本地启动脚本 +└─ Dockerfile # 服务端容器镜像 ``` -## 本地运行 +## 运行与编译脚本 + +所有运行 / 编译都统一为 `scripts/` 下的 Python 脚本,直接用系统 Python 运行即可(`python scripts/xxx.py`)。脚本会**自动创建 `.venv` 并安装依赖**(使用标准库 `venv`,不依赖 uv),自动安装前端依赖;缺少 Node.js / Rust / Docker 时会给出安装引导。 前置要求: -- Windows 10/11 -- Python 与 `uv` -- Node.js -- MySQL 8.0+ +- Python 3.10+(加入 PATH,作为创建 `.venv` 的基础解释器) +- Node.js 18+(前端构建) +- MySQL 8.0+(直连仓库模式) +- Rust 工具链(仅编译 Tauri 桌面版需要) +- Docker(仅编译 Docker 镜像需要) -安装后端依赖: +| 脚本 | 用途 | +| --- | --- | +| `python scripts/dev.py` | 开发测试:后端自动重载 + 前端 Vite 热更新 | +| `python scripts/run.py` | 本地运行:构建前端(如缺失)并启动服务 | +| `python scripts/tauri_dev.py` | Tauri 桌面端测试运行 | +| `python scripts/build_server.py` | 编译 Server 便携版 | +| `python scripts/build_tauri.py` | 编译 Tauri 桌面版 | +| `python scripts/build_docker.py` | 编译 / 更新 Docker 镜像 | +| `python scripts/clean.py` | 清理缓存、`__pycache__`、编译产物、运行时临时数据 | +| `python scripts/gen_license_code.py` | 根据授权 key 生成激活码 | + +### 开发测试 ```powershell -uv venv -uv pip install -r requirements.txt +python scripts/dev.py ``` -安装前端依赖: +启动后端(`http://127.0.0.1:9081`,自动重载)和前端(`http://127.0.0.1:5174`,Vite 热更新,`/api` 与 `/health` 代理到后端)。按 `Ctrl+C` 停止。 + +### 本地运行 ```powershell -cd frontend -npm install -cd .. +python scripts/run.py # 默认 0.0.0.0:9081 +python scripts/run.py --port 8080 +python scripts/run.py --rebuild # 强制重新构建前端 ``` -启动服务: +访问 `http://localhost:9081`。 + +### Server 便携版 ```powershell -.\run.bat -``` - -访问地址: - -```text -http://localhost:9081 -``` - -前端开发模式: - -```powershell -cd frontend -npm run dev -``` - -Vite 会把 `/api` 和 `/health` 代理到 `http://localhost:9081`。如果 `frontend/dist` 不存在,`run.bat` 会自动安装前端依赖并执行构建。 - -## 一键构建 - -Windows 在项目根目录执行: - -```bat -scripts\build.bat -? -``` - -可用目标: - -```bat -scripts\build.bat server -scripts\build.bat desktop -scripts\build.bat docker -scripts\build.bat all -``` - -常用参数: - -```bat -scripts\build.bat server -NoArchive -scripts\build.bat all -Clean -scripts\build.bat docker -SkipDockerBuild -scripts\build.bat all -SkipDesktopBuild -``` - -构建完成后只保留 `dist/` 下的最终产物;`dist/.tmp`、`frontend/dist`、`src-tauri/target`、`src-tauri/binaries` 等中间产物会自动清理。 - -### Server Portable - -```bat -scripts\build.bat server +python scripts/build_server.py +python scripts/build_server.py --no-archive ``` 输出: @@ -144,80 +113,60 @@ dist\server\CapacityReport-Server-windows-x64\ dist\server\CapacityReport-Server-windows-x64.zip ``` -便携版内包含后端可执行文件、前端构建产物、`Configure.json`、`ReportScript.sql`、`cache/`、`logs/` 和启动脚本。默认监听端口为 `9081`。 +便携版内包含后端可执行文件、前端构建产物、`Configure.json`、`ReportScript.sql`、`CellData.sql`、`cache/`、`logs/` 和启动脚本(Windows 为 `run.bat`,Linux/macOS 为 `start.sh`)。默认监听端口 `9081`。便携版需在目标系统原生构建。 -### 桌面版 +### Tauri 桌面版 -```bat -scripts\build.bat desktop +```powershell +python scripts/build_tauri.py # 编译当前系统的桌面版 +python scripts/build_tauri.py --platform windows ``` -输出: +输出:`dist\desktop\` 下的安装包(Windows: `.msi` / `.exe`;Linux: `.deb` / `.AppImage`;macOS: `.dmg`)。 -```text -dist\desktop\*-setup.exe +- 桌面版使用 Tauri 启动 Python sidecar,sidecar 监听 `127.0.0.1:9081`,运行数据写入系统 app data 目录。 +- Windows 安装包内置 WebView2 离线安装器,适合无外网、未预装 WebView2 Runtime 的机器。 +- Windows 默认安装到 `D:\Program Files\CapacityReport`,无 D 盘时回落系统盘。 +- 首次安装运行 Rust/Tauri 时,脚本会在缺少 Tauri CLI 时自动 `cargo install tauri-cli --locked`。 +- Tauri 无法可靠跨系统交叉编译:`--platform` 必须与当前系统一致,否则脚本会提示需在目标系统本机构建。 + +### Docker 镜像 + +```powershell +python scripts/build_docker.py # 编译镜像并生成 dist/docker/ 离线部署包 +python scripts/build_docker.py update # 重新编译镜像并就地更新本机容器 +python scripts/build_docker.py --no-save # 编译但不导出 tar ``` -桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:9081`,运行数据写入系统 app data 目录,不写入安装目录。 +离线部署包输出到 `dist\docker\`(`capacity-report-app-latest.tar`、`docker-compose.yml`、`Configure.json`、`ReportScript.sql`、`CellData.sql`、`mysql/`)。运行态数据落在 `/data` 数据卷,首次启动由 `docker/entrypoint.sh` 播种默认配置。 -Windows 桌面安装包会内置 WebView2 离线安装器,适合没有外网且未预装 WebView2 Runtime 的机器。该模式会让安装包体积增加约 127 MB;构建机需要能在构建阶段下载 WebView2 离线安装器。 -首次启动会把安装包内置的 `Configure.json` 和 `ReportScript.sql` 复制到运行数据目录;Windows 下通常是 `%APPDATA%\com.nixevol.capacityreport\`。安装目录中的 `_up_` 只是 Tauri 打包资源目录,程序运行时不会直接编辑它。 -Windows 桌面版使用 NSIS 安装器,卸载时会询问是否同时删除 `%APPDATA%\com.nixevol.capacityreport\` 中的配置、脚本、授权、缓存和日志。 -Windows 桌面版默认安装到 `D:\Program Files\CapacityReport`;如果没有 D 盘,则默认安装到系统 `Program Files\CapacityReport`。桌面版已关闭 release DevTools 和右键浏览器菜单。 - -构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行: - -```bat -cargo install tauri-cli --locked -``` - -### Docker 版 - -```bat -scripts\build.bat docker -``` - -输出: - -```text -capacity-report-app:latest -dist\docker\capacity-report-app-latest.tar -dist\docker\docker-compose.yml -dist\docker\Configure.json -dist\docker\ReportScript.sql -``` - -启动: - -```bat -docker load -i dist\docker\capacity-report-app-latest.tar -docker compose -f dist\docker\docker-compose.yml up -d -``` - -访问: - -```text -http://localhost:9081 -``` - -停止: - -```bat -docker compose -f dist\docker\docker-compose.yml down -``` - -## Linux / macOS 构建 - -在对应系统原生环境执行: +部署: ```bash -sh scripts/build.sh server -sh scripts/build.sh desktop -sh scripts/build.sh docker -sh scripts/build.sh all +docker load -i dist/docker/capacity-report-app-latest.tar +docker compose -f dist/docker/docker-compose.yml up -d ``` -Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 Windows 构建,Linux 包在 Linux 构建,macOS 包在 macOS 构建。Docker 镜像可以在 Windows 开发机上构建。 +访问 `http://localhost:9081`;停止:`docker compose -f dist/docker/docker-compose.yml down`。 + +### 清理 + +```powershell +python scripts/clean.py # 清理缓存 / 编译产物 / 运行时临时数据 +python scripts/clean.py --deep # 额外清理 .venv 与 frontend/node_modules +``` + +## Linux / macOS + +脚本跨平台通用,在对应系统原生环境执行相同命令即可: + +```bash +python3 scripts/build_server.py +python3 scripts/build_tauri.py +python3 scripts/build_docker.py +``` + +Server 便携版与桌面版需在目标系统原生构建(Windows 包在 Windows、Linux 包在 Linux、macOS 包在 macOS);Docker 镜像可在任意装有 Docker 的开发机构建。 ## 配置说明 diff --git a/debug.bat b/debug.bat deleted file mode 100644 index 61f6d75..0000000 --- a/debug.bat +++ /dev/null @@ -1,58 +0,0 @@ -@echo off -setlocal -title CapacityReport Debug - -cd /d "%~dp0" - -set PYTHON_EXE=.venv\Scripts\python.exe -if not exist "%PYTHON_EXE%" ( - echo [ERROR] Python virtual environment not found: %PYTHON_EXE% - echo [HINT] Run this command in the project root first: uv venv - pause - exit /b 1 -) - -"%PYTHON_EXE%" -c "import fastapi, uvicorn, pandas, openpyxl, pymysql, chardet" >nul 2>&1 -if errorlevel 1 ( - echo [INFO] Installing or repairing Python dependencies... - uv pip install -r requirements.txt - if errorlevel 1 ( - echo [ERROR] Failed to install Python dependencies. - pause - exit /b 1 - ) -) - -where npm >nul 2>&1 -if errorlevel 1 ( - echo [ERROR] npm not found. Install Node.js first. - pause - exit /b 1 -) - -pushd frontend -if not exist "node_modules" ( - echo [INFO] Installing frontend dependencies... - call npm ci - if errorlevel 1 ( - echo [ERROR] Failed to install frontend dependencies. - popd - pause - exit /b 1 - ) -) -popd - -echo ======================================== -echo CapacityReport - debug mode -echo ======================================== -echo [INFO] Backend: http://127.0.0.1:9081 -echo [INFO] Frontend: http://127.0.0.1:5173 -echo [INFO] Close both debug windows to stop. -echo. - -start "CapacityReport Backend" cmd /k ""%PYTHON_EXE%" -m app.main --host 127.0.0.1 --port 9081" -start "CapacityReport Frontend" cmd /k "pushd "%~dp0frontend" && npm run dev -- --host 127.0.0.1 --port 5173" - -echo [OK] Debug services are starting. -pause diff --git a/docs/project_context.md b/docs/project_context.md index 8e36e08..28d9d10 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -1,5 +1,27 @@ # 项目上下文记录 +## 2026-06-26:统一运行/编译脚本为 scripts/ 下纯 Python(傻瓜式,自动 venv) + +把分散的运行/编译入口(根 `run.bat`/`debug.bat`/`start.sh`/`dev.py`、`scripts/build.{sh,ps1,bat}`、`supervisord.conf`、`packaging/Dockerfile`)清理掉,统一改为 `scripts/` 下只依赖标准库的 Python 脚本,可直接 `python scripts/xxx.py` 运行: + +- `scripts/_env.py`:共享工具。自动创建 `.venv`(标准库 `venv`,**不使用 uv**),按 `requirements.txt` 的 sha256(存 `.venv/.requirements.hash`)变化自动重装依赖;自动装前端依赖;检测 Node.js / Rust(cargo,rustc) / Docker,缺失时打印安装引导并退出;提供 `build_frontend`(可传 `VITE_API_BASE`)、PyInstaller 打包(`build_server_binary` onedir/onefile)、`build_tauri_sidecar`(按 rust host triple 命名复制到 `src-tauri/binaries`)、`remove_path`(限工作区内)等助手。额外包用 `pip show` 探测是否已装。 +- `scripts/dev.py`:开发测试(移植旧 `dev.py`)——后端 uvicorn `--reload`(9081) + 前端 Vite HMR(5174),多进程流式日志、Ctrl+C 统一退出。 +- `scripts/run.py`:本地运行(替代 `run.bat`)——构建前端(缺失时) + `python -m app.main`,支持 `--host/--port/--rebuild`。 +- `scripts/tauri_dev.py`:桌面端测试运行——构建前端(desktop api base) + 构建 sidecar(缺失时) + `cargo tauri dev`,`--rebuild` 强制重建。 +- `scripts/build_tauri.py`:桌面版编译——`--platform current|windows|linux|macos`,与宿主不一致时报错提示需本机构建(Tauri 不可靠交叉编译);Windows 复用旧 ps1 的 NSIS 模板定制(默认装 `D:\Program Files\CapacityReport`、内置 WebView2 离线安装器走 tauri.conf.json),临时改 `tauri.conf.json` 的 `nsis.template` 后构建并还原;产物收集到 `dist/desktop/`。 +- `scripts/build_server.py`:Server 便携版——PyInstaller onedir + 前端 dist + `Configure.json`/`ReportScript.sql`/`CellData.sql` + `cache/`/`logs/` + 启动脚本(win `run.bat`/unix `start.sh`),zip 打包,`--no-archive` 跳过。 +- `scripts/build_docker.py`:Docker 编译/更新——以**根 `Dockerfile` + `docker/entrypoint.sh`**(前端预构建、`/data` 卷、uvicorn)为准;`build`(默认)生成 `dist/docker/` 离线包(tar+compose+配置+mysql),`update` 重新构建并 `docker compose ... up -d --force-recreate`,`--no-save` 跳过 tar。 +- `scripts/clean.py`:清理 `__pycache__`/`*.pyc`、编译产物(`dist`、`frontend/dist`、`.vite`、`src-tauri/target`、`src-tauri/binaries`、各 `.cache`)、运行时临时数据(`cache`/`logs`/`uploads`);`--deep` 再清 `.venv`/`node_modules`。遍历跳过 `.git/.venv/node_modules`,仅删工作区内路径。 +- `scripts/gen_license_code.py` 保留。 + +清理与统一: +- 删除 `scripts/build.sh`、`scripts/build.ps1`、`scripts/build.bat`、根 `run.bat`/`debug.bat`/`start.sh`/`dev.py`、`supervisord.conf`、`packaging/Dockerfile`(supervisord 多阶段那套已废弃,确认 `app/` 无 supervisor 引用、根 Dockerfile 用 `docker/entrypoint.sh`)。 +- 重写 `packaging/docker-compose.yml` 匹配根 Dockerfile:app 服务挂 `capacity-data:/data` 命名卷(不再 `/app` 挂载 + supervisord 环境变量),保留 MySQL 服务与 `mysql/` 初始化/配置。 +- 运行时 SQL 文件确认为 `ReportScript.sql` 与 `CellData.sql`(`app/config.py`:`CELLDATA_SCRIPT = BASE_DIR / "CellData.sql"`,非 `CellDataScript.sql`)。便携版/Docker 离线包按此打包。 +- 环境检测:Tauri 桌面端用 Rust(非 Go),脚本检测 Node.js + Rust(+Docker 镜像);用户最初提到的「go」实为 Rust。 +- `README.md` 改写「运行与编译脚本」表格与各分项说明,目录结构条目改为 `scripts/`(py 脚本) 与根 `Dockerfile`。 +- 验证:9 个脚本 `python -m py_compile` 全通过;`clean.py`/`build_docker.py`/`build_tauri.py` `--help` 正常。 + ## 2026-06-01:修复前端 npm audit moderate 漏洞 - `frontend/package.json` 增加 `overrides.dompurify=3.4.7`,将 `monaco-editor@0.55.1` 间接依赖的 vulnerable `dompurify@3.2.7` 覆盖到安全版本。 diff --git a/packaging/Dockerfile b/packaging/Dockerfile deleted file mode 100644 index cb12bdf..0000000 --- a/packaging/Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -# CapacityReport Dockerfile -# The frontend is built in a Node stage and served by the FastAPI runtime. - -FROM node:22-slim AS frontend-build - -WORKDIR /frontend - -COPY frontend/package*.json ./ -RUN npm ci - -COPY frontend/ ./ -RUN npm run build - - -FROM python:3.13.11-slim - -LABEL maintainer="CapacityReport" -LABEL description="CapacityReport processing service" - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - libmariadb-dev \ - curl \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -RUN mkdir -p /var/log/supervisor /var/run /app/cache - -COPY app ./app -COPY Configure.json ReportScript.sql start.sh supervisord.conf ./ -COPY --from=frontend-build /frontend/dist /app/frontend/dist - -RUN chmod +x /app/start.sh 2>/dev/null || true - -EXPOSE 9081 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:9081/health || exit 1 - -CMD ["supervisord", "-c", "/app/supervisord.conf"] diff --git a/packaging/docker-compose.yml b/packaging/docker-compose.yml index b3df048..dcc5415 100644 --- a/packaging/docker-compose.yml +++ b/packaging/docker-compose.yml @@ -6,14 +6,10 @@ services: ports: - "9081:9081" volumes: - - ./cache:/app/cache - - ./Configure.json:/app/Configure.json - - ./ReportScript.sql:/app/ReportScript.sql - - ./logs:/var/log/supervisor + - capacity-data:/data environment: - TZ=Asia/Shanghai - PYTHONUNBUFFERED=1 - - SUPERVISOR_ENABLED=1 depends_on: capacity-mysql: condition: service_healthy @@ -60,3 +56,5 @@ networks: volumes: capacity-mysql-data: driver: local + capacity-data: + driver: local diff --git a/run.bat b/run.bat deleted file mode 100644 index 52c00fc..0000000 --- a/run.bat +++ /dev/null @@ -1,73 +0,0 @@ -@echo off -title CapacityReport v3.0.0 - -echo ======================================== -echo CapacityReport - report service -echo ======================================== -echo. - -cd /d "%~dp0" - -set PYTHON_EXE=.venv\Scripts\python.exe -if not exist "%PYTHON_EXE%" ( - echo [ERROR] Python virtual environment not found: %PYTHON_EXE% - echo [HINT] Run this command in the project root first: uv venv - pause - exit /b 1 -) - -"%PYTHON_EXE%" -c "import fastapi, uvicorn, pandas, openpyxl, pymysql, chardet" >nul 2>&1 -if errorlevel 1 ( - echo [INFO] Installing or repairing Python dependencies... - uv pip install -r requirements.txt - if errorlevel 1 ( - echo [ERROR] Failed to install Python dependencies. - pause - exit /b 1 - ) -) - -if not exist "frontend\dist\index.html" ( - echo [INFO] Frontend build output not found. Building frontend... - where npm >nul 2>&1 - if errorlevel 1 ( - echo [ERROR] npm not found. Install Node.js first. - pause - exit /b 1 - ) - - pushd frontend - if not exist "node_modules" ( - echo [INFO] Installing frontend dependencies... - call npm ci - if errorlevel 1 ( - echo [ERROR] Failed to install frontend dependencies. - popd - pause - exit /b 1 - ) - ) - - call npm run build - if errorlevel 1 ( - echo [ERROR] Failed to build frontend. - popd - pause - exit /b 1 - ) - popd -) - -:loop -echo. -echo [%date% %time%] Starting service... -echo [INFO] Frontend: http://localhost:9081 -echo [INFO] Close this window to stop the service. -echo. - -"%PYTHON_EXE%" -m app.main - -echo. -echo [%date% %time%] Service stopped. Restarting in 3 seconds... -timeout /t 3 /nobreak >nul -goto loop diff --git a/scripts/_env.py b/scripts/_env.py new file mode 100644 index 0000000..b93b761 --- /dev/null +++ b/scripts/_env.py @@ -0,0 +1,342 @@ +"""CapacityReport 脚本共享工具。 + +所有 scripts/ 下的运行/编译脚本都只依赖 Python 标准库,可直接用任意系统 Python 运行 +(python scripts/xxx.py)。本模块负责: + +- 自动创建并维护项目 .venv(使用标准库 venv,不使用 uv),按 requirements.txt 变化自动重装依赖。 +- 检测 Node.js / Rust(Cargo) / Docker 环境,缺失时给出安装引导。 +- 统一的日志、命令执行、平台判断等助手。 +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +IS_WINDOWS = os.name == "nt" + +VENV_DIR = ROOT / ".venv" +VENV_BIN = VENV_DIR / ("Scripts" if IS_WINDOWS else "bin") +VENV_PYTHON = VENV_BIN / ("python.exe" if IS_WINDOWS else "python") +REQUIREMENTS = ROOT / "requirements.txt" +REQ_HASH_FILE = VENV_DIR / ".requirements.hash" + +FRONTEND_DIR = ROOT / "frontend" +SRC_TAURI_DIR = ROOT / "src-tauri" +PACKAGING_DIR = ROOT / "packaging" +DIST_DIR = ROOT / "dist" + +DESKTOP_API_BASE = "http://127.0.0.1:9081" +SERVER_NAME = "capareport-server" + + +# --------------------------------------------------------------------------- 日志 + + +def _supports_color() -> bool: + return sys.stdout.isatty() and not IS_WINDOWS + + +_C = { + "reset": "\033[0m", + "green": "\033[32m", + "cyan": "\033[36m", + "yellow": "\033[33m", + "red": "\033[31m", +} + + +def setup_console() -> None: + """让 Windows 控制台也能正常输出 UTF-8,避免中文乱码报错。""" + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + +def step(message: str) -> None: + color = _C["green"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"\n{color}==> {message}{reset}", flush=True) + + +def info(message: str) -> None: + print(f" {message}", flush=True) + + +def warn(message: str) -> None: + color = _C["yellow"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"{color}[warn] {message}{reset}", flush=True) + + +def die(message: str, hint: str | None = None) -> "NoReturn": # type: ignore[name-defined] + color = _C["red"] if _supports_color() else "" + reset = _C["reset"] if _supports_color() else "" + print(f"{color}[error] {message}{reset}", file=sys.stderr, flush=True) + if hint: + print(f" {hint}", file=sys.stderr, flush=True) + raise SystemExit(1) + + +# --------------------------------------------------------------------------- 命令执行 + + +def run(args: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + """执行命令并实时输出;失败抛 SystemExit。""" + printable = " ".join(str(a) for a in args) + info(printable) + completed = subprocess.run( + [str(a) for a in args], + cwd=str(cwd) if cwd else None, + env=env, + shell=False, + ) + if completed.returncode != 0: + die(f"命令执行失败(退出码 {completed.returncode}):{printable}") + + +def which(name: str) -> str | None: + return shutil.which(name) + + +def host_platform() -> str: + if IS_WINDOWS: + return "windows" + if sys.platform == "darwin": + return "macos" + return "linux" + + +def server_exe_name(platform: str | None = None) -> str: + platform = platform or host_platform() + return f"{SERVER_NAME}.exe" if platform == "windows" else SERVER_NAME + + +# --------------------------------------------------------------------------- Python venv + + +def _base_python() -> str: + """返回用于创建 venv 的基础解释器,优先当前运行脚本的解释器。""" + if sys.executable: + return sys.executable + for name in ("python3", "python"): + found = shutil.which(name) + if found: + return found + die( + "未找到可用的 Python 解释器。", + "请安装 Python 3.10+ 并加入 PATH:https://www.python.org/downloads/", + ) + + +def _requirements_hash() -> str: + try: + return hashlib.sha256(REQUIREMENTS.read_bytes()).hexdigest() + except OSError: + return "" + + +def ensure_venv(extra_packages: list[str] | None = None) -> Path: + """确保 .venv 存在且依赖最新,返回 venv 内的 python 路径。""" + extra_packages = extra_packages or [] + created = False + + if not VENV_PYTHON.exists(): + base = _base_python() + step(f"创建虚拟环境 .venv({base})") + run([base, "-m", "venv", str(VENV_DIR)]) + created = True + + current_hash = _requirements_hash() + stored_hash = REQ_HASH_FILE.read_text(encoding="utf-8").strip() if REQ_HASH_FILE.exists() else "" + + if created or current_hash != stored_hash: + step("安装/更新 Python 依赖(requirements.txt)") + run([str(VENV_PYTHON), "-m", "pip", "install", "--upgrade", "pip"]) + run([str(VENV_PYTHON), "-m", "pip", "install", "-r", str(REQUIREMENTS)], cwd=ROOT) + try: + REQ_HASH_FILE.write_text(current_hash, encoding="utf-8") + except OSError: + pass + + for package in extra_packages: + _ensure_python_package(package) + + return VENV_PYTHON + + +def _ensure_python_package(package: str) -> None: + """确保某个额外包(如 pyinstaller)已安装在 venv 中。""" + name = package.split("==")[0].split(">=")[0].strip() + probe = subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "show", name], + capture_output=True, + ) + if probe.returncode == 0: + return + step(f"安装构建依赖 {package}") + run([str(VENV_PYTHON), "-m", "pip", "install", package], cwd=ROOT) + + +# --------------------------------------------------------------------------- Node.js + + +def ensure_node() -> str: + """确保 Node.js / npm 可用,返回 npm 可执行路径。""" + npm = shutil.which("npm") + node = shutil.which("node") + if not npm or not node: + die( + "未检测到 Node.js / npm(构建前端需要)。", + "请安装 Node.js 18+:https://nodejs.org/ ,安装后重新打开终端再运行。", + ) + return npm + + +def ensure_frontend_deps() -> None: + npm = ensure_node() + if (FRONTEND_DIR / "node_modules").exists(): + return + step("安装前端依赖(npm install)") + run([npm, "install"], cwd=FRONTEND_DIR) + + +def build_frontend(api_base: str | None = None) -> None: + npm = ensure_node() + ensure_frontend_deps() + step("构建前端(npm run build)") + env = os.environ.copy() + if api_base: + env["VITE_API_BASE"] = api_base + else: + env.pop("VITE_API_BASE", None) + completed = subprocess.run([npm, "run", "build"], cwd=str(FRONTEND_DIR), env=env) + if completed.returncode != 0: + die("前端构建失败(npm run build)") + + +# --------------------------------------------------------------------------- Rust / Tauri + + +def ensure_rust() -> None: + """确保 Rust 工具链(cargo + rustc)可用(编译 Tauri 桌面端需要)。""" + if shutil.which("cargo") and shutil.which("rustc"): + return + die( + "未检测到 Rust 工具链(cargo / rustc),编译 Tauri 桌面端需要。", + "请安装 Rust:https://www.rust-lang.org/tools/install ,安装后重新打开终端再运行。", + ) + + +def rust_host_triple() -> str: + output = subprocess.run(["rustc", "-vV"], capture_output=True, text=True) + for line in output.stdout.splitlines(): + if line.startswith("host:"): + return line.split(":", 1)[1].strip() + die("无法识别 Rust host triple(rustc -vV)") + + +def ensure_tauri_cli() -> None: + probe = subprocess.run(["cargo", "tauri", "--version"], capture_output=True) + if probe.returncode == 0: + return + step("安装 Tauri CLI(cargo install tauri-cli)") + run(["cargo", "install", "tauri-cli", "--locked"]) + + +# --------------------------------------------------------------------------- PyInstaller + +PYINSTALLER_SPEC = PACKAGING_DIR / "capareport-server.spec" + + +def build_server_binary(python: Path, onefile: bool, work_dir: Path, dist_dir: Path) -> Path: + """用 PyInstaller 打包后端为可执行文件。 + + onefile=True 返回单文件可执行文件路径;onefile=False 返回 onedir 目录路径。 + """ + remove_path(work_dir) + remove_path(dist_dir) + work_dir.mkdir(parents=True, exist_ok=True) + dist_dir.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + if onefile: + env["CAPAREPORT_ONEFILE"] = "1" + else: + env.pop("CAPAREPORT_ONEFILE", None) + + step(f"PyInstaller 打包后端({'onefile' if onefile else 'onedir'})") + run( + [ + str(python), "-m", "PyInstaller", "--clean", "--noconfirm", + "--workpath", str(work_dir), "--distpath", str(dist_dir), str(PYINSTALLER_SPEC), + ], + cwd=ROOT, + env=env, + ) + + if onefile: + binary = dist_dir / server_exe_name() + if not binary.exists(): + die(f"未找到 PyInstaller 单文件产物:{binary}") + return binary + + out_dir = dist_dir / SERVER_NAME + if not out_dir.exists(): + die(f"未找到 PyInstaller onedir 产物:{out_dir}") + return out_dir + + +def build_tauri_sidecar(python: Path, work_dir: Path, dist_dir: Path) -> Path: + """构建桌面端 sidecar(onefile)并按 Rust host triple 命名复制到 src-tauri/binaries。""" + binary = build_server_binary(python, onefile=True, work_dir=work_dir, dist_dir=dist_dir) + triple = rust_host_triple() + sidecar_dir = SRC_TAURI_DIR / "binaries" + sidecar_dir.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if host_platform() == "windows" else "" + target = sidecar_dir / f"{SERVER_NAME}-{triple}{suffix}" + shutil.copy2(binary, target) + info(f"sidecar -> {target}") + return target + + +# --------------------------------------------------------------------------- Docker + + +def ensure_docker() -> None: + if not shutil.which("docker"): + die( + "未检测到 Docker,构建镜像需要。", + "请安装 Docker Desktop / Docker Engine:https://docs.docker.com/get-docker/", + ) + + +# --------------------------------------------------------------------------- 清理助手 + + +def remove_path(path: Path) -> bool: + """安全删除文件或目录,仅允许删除工作区内路径。""" + try: + resolved = path.resolve() + except OSError: + return False + if ROOT not in resolved.parents and resolved != ROOT: + warn(f"跳过工作区外路径:{resolved}") + return False + if not resolved.exists(): + return False + if resolved.is_dir(): + shutil.rmtree(resolved, ignore_errors=True) + else: + try: + resolved.unlink() + except OSError: + return False + return True diff --git a/scripts/build.bat b/scripts/build.bat deleted file mode 100644 index 59f1147..0000000 --- a/scripts/build.bat +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -setlocal - -set SCRIPT_DIR=%~dp0 -powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" %* -exit /b %ERRORLEVEL% diff --git a/scripts/build.ps1 b/scripts/build.ps1 deleted file mode 100644 index 4924e18..0000000 --- a/scripts/build.ps1 +++ /dev/null @@ -1,523 +0,0 @@ -param( - [ValidateSet("server", "desktop", "docker", "all")] - [string]$Target = "all", - - [ValidateSet("current", "windows", "linux", "macos")] - [string]$Platform = "current", - - [switch]$SkipDockerBuild, - [switch]$SkipDesktopBuild, - [switch]$NoArchive, - [switch]$Clean, - [Alias("h", "?")] - [switch]$Help -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$Root = (Resolve-Path (Join-Path $ScriptDir "..")).Path -$DistDir = Join-Path $Root "dist" -$ServerDistDir = Join-Path $DistDir "server" -$DesktopDistDir = Join-Path $DistDir "desktop" -$DockerDistDir = Join-Path $DistDir "docker" -$TempDir = Join-Path $DistDir ".tmp" -$PyInstallerDir = Join-Path $TempDir "pyinstaller" -$FrontendDir = Join-Path $Root "frontend" -$PackagingDir = Join-Path $Root "packaging" -$ServerName = "capareport-server" -$DesktopApiBase = "http://127.0.0.1:9081" - -function Write-Step($Message) { - Write-Host "" - Write-Host "==> $Message" -ForegroundColor Green -} - -function Write-Info($Message) { - Write-Host " $Message" -} - -function Show-Usage { - Write-Host "Usage: scripts\build.bat [server|desktop|docker|all] [options]" - Write-Host "" - Write-Host "Options:" - Write-Host " -Platform current|windows|linux|macos" - Write-Host " -NoArchive" - Write-Host " -Clean" - Write-Host " -SkipDockerBuild" - Write-Host " -SkipDesktopBuild" -} - -function Assert-InWorkspace($Path) { - $resolved = (Resolve-Path -LiteralPath $Path).Path - if (!$resolved.StartsWith($Root, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to operate outside workspace: $resolved" - } - return $resolved -} - -function Remove-Tree($Path) { - if (Test-Path -LiteralPath $Path) { - $resolved = Assert-InWorkspace $Path - Remove-Item -LiteralPath $resolved -Recurse -Force - } -} - -function Invoke-Checked($Command, $WorkingDirectory = $Root) { - Write-Info $Command - Push-Location $WorkingDirectory - try { - cmd /c $Command - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code $LASTEXITCODE`: $Command" - } - } - finally { - Pop-Location - } -} - -function Get-CurrentPlatform { - if ($Platform -ne "current") { - return $Platform - } - if ($env:OS -eq "Windows_NT") { - return "windows" - } - $uname = (uname -s 2>$null) - if ($uname -eq "Darwin") { - return "macos" - } - return "linux" -} - -function Get-ExecutableName($PlatformName) { - if ($PlatformName -eq "windows") { - return "$ServerName.exe" - } - return $ServerName -} - -function Get-RustHostTriple { - $output = rustc -vV - foreach ($line in $output) { - if ($line -like "host:*") { - return $line.Substring(5).Trim() - } - } - throw "Unable to detect Rust host triple" -} - -function Test-TauriCli { - cmd /c "cargo tauri --version >nul 2>nul" - return $LASTEXITCODE -eq 0 -} - -function Ensure-PythonEnvironment { - Write-Step "Preparing Python environment" - $venvPython = Join-Path $Root ".venv\Scripts\python.exe" - if (!(Test-Path $venvPython)) { - Invoke-Checked "uv venv" $Root - } - Invoke-Checked "uv pip install -r requirements.txt" $Root - Invoke-Checked "uv pip install pyinstaller" $Root -} - -function Build-Frontend($ApiBase) { - Write-Step "Building frontend" - if (!(Test-Path (Join-Path $FrontendDir "node_modules"))) { - Invoke-Checked "npm ci" $FrontendDir - } - - $previousApiBase = $env:VITE_API_BASE - if ($ApiBase) { - $env:VITE_API_BASE = $ApiBase - } - else { - Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue - } - - try { - Invoke-Checked "npm run build" $FrontendDir - } - finally { - if ($null -ne $previousApiBase) { - $env:VITE_API_BASE = $previousApiBase - } - else { - Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue - } - } -} - -function Build-ServerBinary([switch]$OneFile) { - Ensure-PythonEnvironment - Write-Step "Building server binary" - Remove-Tree $PyInstallerDir - New-Item -ItemType Directory -Force -Path $PyInstallerDir | Out-Null - - $python = Join-Path $Root ".venv\Scripts\python.exe" - $workPath = Join-Path $PyInstallerDir "work" - $distPath = Join-Path $PyInstallerDir "dist" - $specPath = Join-Path $PackagingDir "capareport-server.spec" - $previousOneFile = $env:CAPAREPORT_ONEFILE - - if ($OneFile) { - $env:CAPAREPORT_ONEFILE = "1" - } - else { - Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue - } - - try { - Invoke-Checked "`"$python`" -m PyInstaller --clean --noconfirm --workpath `"$workPath`" --distpath `"$distPath`" `"$specPath`"" $Root - } - finally { - if ($null -ne $previousOneFile) { - $env:CAPAREPORT_ONEFILE = $previousOneFile - } - else { - Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue - } - } - - if ($OneFile) { - $binaryDir = $distPath - $binaryPath = Join-Path $binaryDir (Get-ExecutableName (Get-CurrentPlatform)) - if (!(Test-Path $binaryPath)) { - throw "PyInstaller output not found: $binaryPath" - } - } - else { - $binaryDir = Join-Path $distPath $ServerName - if (!(Test-Path $binaryDir)) { - throw "PyInstaller output not found: $binaryDir" - } - } - return $binaryDir -} - -function Write-TextFile($Path, $Content, [switch]$Lf) { - $parent = Split-Path -Parent $Path - New-Item -ItemType Directory -Force -Path $parent | Out-Null - if ($Lf) { - [System.IO.File]::WriteAllText($Path, $Content.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) - } - else { - [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) - } -} - -function Convert-ToNsisPath($Path) { - return (Resolve-Path -LiteralPath $Path).Path -} - -function Get-TauriNsisTemplatePath { - $registryDir = Join-Path $env:USERPROFILE ".cargo\registry\src" - if (!(Test-Path $registryDir)) { - throw "Cargo registry not found: $registryDir" - } - - $templates = Get-ChildItem -Path $registryDir -Recurse -Filter "installer.nsi" | - Where-Object { $_.FullName -match "tauri-bundler-[^\\]+\\src\\bundle\\windows\\nsis\\installer\.nsi$" } | - Sort-Object FullName -Descending - - if (!$templates) { - throw "Tauri NSIS template not found. Run cargo tauri build once to fetch tauri-bundler." - } - - return $templates[0].FullName -} - -function New-DesktopNsisTemplate { - $source = Get-TauriNsisTemplatePath - $target = Join-Path $TempDir "tauri-nsis-installer.nsi" - $hookPath = Convert-ToNsisPath (Join-Path $Root "src-tauri\windows\nsis-hooks.nsh") - $content = [System.IO.File]::ReadAllText($source) - - $content = [regex]::Replace( - $content, - '\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}', - "!include `"$hookPath`"" - ) - - $restoreLine = " Call RestorePreviousInstallLocation" - $installDirOverride = @' - Call RestorePreviousInstallLocation - - !if "${INSTALLMODE}" == "perMachine" - ${If} ${FileExists} "D:\*.*" - StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" - ${Else} - ${If} ${RunningX64} - StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" - ${Else} - StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" - ${EndIf} - ${EndIf} - !endif -'@ - - if (!$content.Contains($restoreLine)) { - throw "Tauri NSIS template changed: RestorePreviousInstallLocation marker not found" - } - $content = $content.Replace($restoreLine, $installDirOverride) - - $onInitTail = @' - !if "${INSTALLMODE}" == "both" - !insertmacro MULTIUSER_INIT - !endif -FunctionEnd -'@ - $onInitOverride = @' - !if "${INSTALLMODE}" == "both" - !insertmacro MULTIUSER_INIT - !endif - - !if "${INSTALLMODE}" == "perMachine" - ${If} ${FileExists} "D:\*.*" - StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" - ${Else} - ${If} ${RunningX64} - StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" - ${Else} - StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" - ${EndIf} - ${EndIf} - !endif -FunctionEnd -'@ - if (!$content.Contains($onInitTail)) { - throw "Tauri NSIS template changed: .onInit tail marker not found" - } - $content = $content.Replace($onInitTail, $onInitOverride) - - $restoreFunction = @' -Function RestorePreviousInstallLocation - ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" - StrCmp $4 "" +2 0 - StrCpy $INSTDIR $4 -FunctionEnd -'@ - $restoreOverride = @' -Function RestorePreviousInstallLocation - !if "${INSTALLMODE}" == "perMachine" - ${If} ${FileExists} "D:\*.*" - StrCpy $INSTDIR "D:\Program Files\${PRODUCTNAME}" - Return - ${EndIf} - !endif - - ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" - StrCmp $4 "" +2 0 - StrCpy $INSTDIR $4 -FunctionEnd -'@ - if (!$content.Contains($restoreFunction)) { - throw "Tauri NSIS template changed: RestorePreviousInstallLocation function marker not found" - } - $content = $content.Replace($restoreFunction, $restoreOverride) - - Write-TextFile $target $content -Lf - return $target -} - -function Invoke-WithDesktopTauriConfig($NsisTemplatePath, [scriptblock]$Action) { - $configPath = Join-Path $Root "src-tauri\tauri.conf.json" - $original = [System.IO.File]::ReadAllText($configPath) - try { - $config = $original | ConvertFrom-Json - $config.bundle.windows.nsis | Add-Member -NotePropertyName "template" -NotePropertyValue $NsisTemplatePath -Force - Write-TextFile $configPath ($config | ConvertTo-Json -Depth 50) - & $Action - } - finally { - [System.IO.File]::WriteAllText($configPath, $original, [System.Text.UTF8Encoding]::new($false)) - } -} - -function Copy-RuntimeFiles($OutputDir) { - Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $OutputDir "Configure.json") -Force - Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $OutputDir "ReportScript.sql") -Force - New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "cache") | Out-Null - New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "logs") | Out-Null - - $frontendDist = Join-Path $FrontendDir "dist" - if (!(Test-Path $frontendDist)) { - throw "Frontend dist not found: $frontendDist" - } - New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "frontend") | Out-Null - Copy-Item -Path $frontendDist -Destination (Join-Path $OutputDir "frontend") -Recurse -Force -} - -function Write-ServerLaunchers($OutputDir, $PlatformName) { - $exe = Get-ExecutableName $PlatformName - $bat = @" -@echo off -setlocal -cd /d "%~dp0" -if not exist cache mkdir cache -if not exist logs mkdir logs -echo Starting CapacityReport server... -echo URL: http://localhost:9081 -"%~dp0$exe" --host 0.0.0.0 --port 9081 -pause -"@ - Write-TextFile (Join-Path $OutputDir "run.bat") $bat - - $sh = @' -#!/usr/bin/env sh -set -eu -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -cd "$SCRIPT_DIR" -mkdir -p cache logs -chmod +x "./__SERVER_EXE__" 2>/dev/null || true -echo "Starting CapacityReport server..." -echo "URL: http://localhost:9081" -exec "./__SERVER_EXE__" --host 0.0.0.0 --port 9081 -'@ - $sh = $sh.Replace("__SERVER_EXE__", $exe) - Write-TextFile (Join-Path $OutputDir "start.sh") $sh -Lf -} - -function Build-ServerPortable { - $platformName = Get-CurrentPlatform - if ($platformName -ne "windows" -and $env:OS -eq "Windows_NT") { - Write-Host "Native $platformName portable packages must be built on $platformName." -ForegroundColor Yellow - Write-Host "The Windows build will continue for the current host." -ForegroundColor Yellow - $platformName = "windows" - } - - $outputDir = Join-Path $ServerDistDir "CapacityReport-Server-$platformName-x64" - $archive = "$outputDir.zip" - Remove-Tree $outputDir - Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue - - Build-Frontend $null - $binaryDir = Build-ServerBinary - - New-Item -ItemType Directory -Force -Path $outputDir | Out-Null - - Copy-Item -Path (Join-Path $binaryDir "*") -Destination $outputDir -Recurse -Force - Copy-RuntimeFiles $outputDir - Write-ServerLaunchers $outputDir $platformName - - if (!$NoArchive) { - Compress-Archive -Path (Join-Path $outputDir "*") -DestinationPath $archive -Force - Write-Info "Archive: $archive" - } - - Write-Info "Server package: $outputDir" -} - -function Copy-DockerBundle { - Remove-Tree $DockerDistDir - New-Item -ItemType Directory -Force -Path $DockerDistDir | Out-Null - New-Item -ItemType Directory -Force -Path (Join-Path $DockerDistDir "cache") | Out-Null - New-Item -ItemType Directory -Force -Path (Join-Path $DockerDistDir "logs") | Out-Null - Copy-Item -Path (Join-Path $PackagingDir "docker-compose.yml") -Destination (Join-Path $DockerDistDir "docker-compose.yml") -Force - Copy-Item -Path (Join-Path $PackagingDir "mysql") -Destination (Join-Path $DockerDistDir "mysql") -Recurse -Force - Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $DockerDistDir "Configure.json") -Force - Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $DockerDistDir "ReportScript.sql") -Force -} - -function Build-DockerImage { - Remove-Tree $DockerDistDir - - if ($SkipDockerBuild) { - Write-Host "Docker image build skipped." - Copy-DockerBundle - Invoke-Checked "docker compose -f `"$DockerDistDir\docker-compose.yml`" config" $Root - Write-Info "Docker package: $DockerDistDir" - return - } - - Write-Step "Building Docker image" - Invoke-Checked "docker build --progress=plain -f `"$PackagingDir\Dockerfile`" -t capacity-report-app:latest ." $Root - - Write-Step "Packaging Docker output" - Copy-DockerBundle - Invoke-Checked "docker save -o `"$DockerDistDir\capacity-report-app-latest.tar`" capacity-report-app:latest" $Root - Invoke-Checked "docker compose -f `"$DockerDistDir\docker-compose.yml`" config" $Root - Write-Info "Docker package: $DockerDistDir" -} - -function Build-DesktopPackage { - if ($SkipDesktopBuild) { - Write-Host "Desktop build skipped." - return - } - - if (!(Get-Command rustc -ErrorAction SilentlyContinue)) { - throw "Rust is required for the Tauri desktop build" - } - - Remove-Tree $DesktopDistDir - - Build-Frontend $DesktopApiBase - $binaryDir = Build-ServerBinary -OneFile - $platformName = Get-CurrentPlatform - $exe = Get-ExecutableName $platformName - $triple = Get-RustHostTriple - $sidecarDir = Join-Path $Root "src-tauri\binaries" - New-Item -ItemType Directory -Force -Path $sidecarDir | Out-Null - $sidecarName = if ($platformName -eq "windows") { "$ServerName-$triple.exe" } else { "$ServerName-$triple" } - Copy-Item -Path (Join-Path $binaryDir $exe) -Destination (Join-Path $sidecarDir $sidecarName) -Force - - if (!(Test-TauriCli)) { - Write-Step "Installing Tauri CLI" - Invoke-Checked "cargo install tauri-cli --locked" $Root - } - - $nsisTemplate = New-DesktopNsisTemplate - Invoke-WithDesktopTauriConfig $nsisTemplate { - Remove-Tree (Join-Path $Root "src-tauri\target\release\bundle") - Write-Step "Building Tauri desktop package" - Invoke-Checked "cargo tauri build" (Join-Path $Root "src-tauri") - } - - New-Item -ItemType Directory -Force -Path $DesktopDistDir | Out-Null - $bundleDir = Join-Path $Root "src-tauri\target\release\bundle" - Get-ChildItem -Path $bundleDir -Recurse -File -Include "*.msi", "*.exe" | - Copy-Item -Destination $DesktopDistDir -Force - Write-Info "Desktop package: $DesktopDistDir" -} - -function Clean-Intermediates { - Write-Step "Cleaning intermediate output" - Remove-Tree $TempDir - Remove-Tree (Join-Path $Root "frontend\dist") - Remove-Tree (Join-Path $Root "src-tauri\binaries") - Remove-Tree (Join-Path $Root "src-tauri\target") -} - -if ($Help) { - Show-Usage - exit 0 -} - -if ($Clean) { - Write-Step "Cleaning dist output" - Remove-Tree $DistDir - Remove-Tree (Join-Path $Root "frontend\dist") - Remove-Tree (Join-Path $Root "src-tauri\binaries") - Remove-Tree (Join-Path $Root "src-tauri\target") -} - -New-Item -ItemType Directory -Force -Path $DistDir | Out-Null - -switch ($Target) { - "server" { Build-ServerPortable } - "docker" { Build-DockerImage } - "desktop" { Build-DesktopPackage } - "all" { - Build-ServerPortable - Build-DockerImage - Build-DesktopPackage - } -} - -Clean-Intermediates - -Write-Step "Build finished" diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index 01b7c11..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env sh -set -eu - -TARGET="${1:-server}" -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" -DIST_DIR="$ROOT/dist" -SERVER_DIST_DIR="$DIST_DIR/server" -DESKTOP_DIST_DIR="$DIST_DIR/desktop" -DOCKER_DIST_DIR="$DIST_DIR/docker" -TMP_DIR="$DIST_DIR/.tmp" -PYINSTALLER_DIR="$TMP_DIR/pyinstaller" -PACKAGING_DIR="$ROOT/packaging" -DESKTOP_API_BASE="http://127.0.0.1:9081" -SERVER_NAME="capareport-server" -cd "$ROOT" - -log() { - printf '\n==> %s\n' "$1" -} - -run() { - printf ' %s\n' "$*" - "$@" -} - -platform_name() { - case "$(uname -s)" in - Darwin) printf 'macos' ;; - Linux) printf 'linux' ;; - *) printf 'windows' ;; - esac -} - -server_exe_name() { - case "$(platform_name)" in - windows) printf '%s.exe' "$SERVER_NAME" ;; - *) printf '%s' "$SERVER_NAME" ;; - esac -} - -clean_intermediates() { - log "Cleaning intermediate output" - rm -rf "$TMP_DIR" "$ROOT/frontend/dist" "$ROOT/src-tauri/binaries" "$ROOT/src-tauri/target" -} - -ensure_python() { - log "Preparing Python environment" - if [ ! -x ".venv/bin/python" ]; then - run uv venv - fi - run uv pip install -r requirements.txt - run uv pip install pyinstaller -} - -build_frontend() { - log "Building frontend" - if [ ! -d "frontend/node_modules" ]; then - (cd frontend && run npm ci) - fi - if [ "${1:-}" = "desktop" ]; then - (cd frontend && VITE_API_BASE="$DESKTOP_API_BASE" run npm run build) - else - (cd frontend && run npm run build) - fi -} - -build_server_binary() { - mode="${1:-onedir}" - ensure_python - log "Building server binary" - rm -rf "$PYINSTALLER_DIR" - mkdir -p "$PYINSTALLER_DIR" - - if [ "$mode" = "onefile" ]; then - CAPAREPORT_ONEFILE=1 - export CAPAREPORT_ONEFILE - run .venv/bin/python -m PyInstaller --clean --noconfirm \ - --workpath "$PYINSTALLER_DIR/work" \ - --distpath "$PYINSTALLER_DIR/dist" \ - "$PACKAGING_DIR/capareport-server.spec" - unset CAPAREPORT_ONEFILE - else - run .venv/bin/python -m PyInstaller --clean --noconfirm \ - --workpath "$PYINSTALLER_DIR/work" \ - --distpath "$PYINSTALLER_DIR/dist" \ - "$PACKAGING_DIR/capareport-server.spec" - fi -} - -write_launchers() { - out_dir="$1" - exe_name="$2" - cat > "$out_dir/start.sh" </dev/null || true -echo "Starting CapacityReport server..." -echo "URL: http://localhost:9081" -exec "./$exe_name" --host 0.0.0.0 --port 9081 -EOF - chmod +x "$out_dir/start.sh" -} - -build_server() { - platform="$(platform_name)" - exe_name="$(server_exe_name)" - out_dir="$SERVER_DIST_DIR/CapacityReport-Server-$platform-x64" - rm -rf "$out_dir" "$SERVER_DIST_DIR/$(basename "$out_dir").tar.gz" - - build_frontend server - build_server_binary onedir - - mkdir -p "$out_dir/cache" "$out_dir/logs" "$out_dir/frontend" - cp -R "$PYINSTALLER_DIR/dist/$SERVER_NAME/." "$out_dir/" - cp Configure.json ReportScript.sql "$out_dir/" - cp -R frontend/dist "$out_dir/frontend/" - write_launchers "$out_dir" "$exe_name" - (cd "$SERVER_DIST_DIR" && tar -czf "$(basename "$out_dir").tar.gz" "$(basename "$out_dir")") - log "Server package: $out_dir" -} - -copy_docker_bundle() { - rm -rf "$DOCKER_DIST_DIR" - mkdir -p "$DOCKER_DIST_DIR/cache" "$DOCKER_DIST_DIR/logs" - cp "$PACKAGING_DIR/docker-compose.yml" "$DOCKER_DIST_DIR/docker-compose.yml" - cp Configure.json ReportScript.sql "$DOCKER_DIST_DIR/" - cp -R "$PACKAGING_DIR/mysql" "$DOCKER_DIST_DIR/mysql" -} - -build_docker() { - rm -rf "$DOCKER_DIST_DIR" - - log "Building Docker image" - run docker build --progress=plain -f "$PACKAGING_DIR/Dockerfile" -t capacity-report-app:latest . - log "Packaging Docker output" - copy_docker_bundle - run docker save -o "$DOCKER_DIST_DIR/capacity-report-app-latest.tar" capacity-report-app:latest - run docker compose -f "$DOCKER_DIST_DIR/docker-compose.yml" config - log "Docker package: $DOCKER_DIST_DIR" -} - -build_desktop() { - rm -rf "$DESKTOP_DIST_DIR" - - build_frontend desktop - build_server_binary onefile - triple="$(rustc -vV | awk '/^host:/ {print $2}')" - mkdir -p src-tauri/binaries - cp "$PYINSTALLER_DIR/dist/$(server_exe_name)" "src-tauri/binaries/$SERVER_NAME-$triple" - if ! cargo tauri --version >/dev/null 2>&1; then - run cargo install tauri-cli --locked - fi - rm -rf src-tauri/target/release/bundle - (cd src-tauri && run cargo tauri build) - - rm -rf "$DESKTOP_DIST_DIR" - mkdir -p "$DESKTOP_DIST_DIR" - find src-tauri/target/release/bundle -type f \( -name '*.msi' -o -name '*.exe' \) -exec cp {} "$DESKTOP_DIST_DIR/" \; - log "Desktop package: $DESKTOP_DIST_DIR" -} - -mkdir -p "$DIST_DIR" - -case "$TARGET" in - server) build_server ;; - docker) build_docker ;; - desktop) build_desktop ;; - all) build_server; build_docker; build_desktop ;; - *) echo "Usage: scripts/build.sh [server|docker|desktop|all]" >&2; exit 1 ;; -esac - -clean_intermediates - -log "Build finished" diff --git a/scripts/build_docker.py b/scripts/build_docker.py new file mode 100644 index 0000000..0a50ddd --- /dev/null +++ b/scripts/build_docker.py @@ -0,0 +1,88 @@ +"""Docker 镜像编译 / 更新脚本(基于根目录 Dockerfile + docker/entrypoint.sh)。 + +用法: + python scripts/build_docker.py # 编译镜像并生成可部署离线包(dist/docker/) + python scripts/build_docker.py update # 重新编译镜像并就地更新本机运行的容器 + python scripts/build_docker.py --no-save # 编译但不导出 tar(本机调试更快) + +镜像 tag:capacity-report-app:latest。镜像内运行态数据落在 /data 数据卷, +首次启动由 docker/entrypoint.sh 播种默认 Configure.json / ReportScript.sql。 +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +IMAGE_TAG = "capacity-report-app:latest" +DOCKERFILE = _env.ROOT / "Dockerfile" +COMPOSE_FILE = _env.PACKAGING_DIR / "docker-compose.yml" +BUNDLE_FILES = ["Configure.json", "ReportScript.sql", "CellData.sql"] + + +def build_image() -> None: + _env.ensure_docker() + _env.build_frontend() + _env.step("构建 Docker 镜像") + _env.run( + ["docker", "build", "--progress=plain", "-f", str(DOCKERFILE), "-t", IMAGE_TAG, "."], + cwd=_env.ROOT, + ) + + +def make_bundle(save_tar: bool) -> Path: + out_dir = _env.DIST_DIR / "docker" + _env.remove_path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "cache").mkdir(exist_ok=True) + (out_dir / "logs").mkdir(exist_ok=True) + + shutil.copy2(COMPOSE_FILE, out_dir / "docker-compose.yml") + shutil.copytree(_env.PACKAGING_DIR / "mysql", out_dir / "mysql", dirs_exist_ok=True) + for name in BUNDLE_FILES: + src = _env.ROOT / name + if src.exists(): + shutil.copy2(src, out_dir / name) + + if save_tar: + _env.step("导出镜像 tar") + _env.run(["docker", "save", "-o", str(out_dir / "capacity-report-app-latest.tar"), IMAGE_TAG]) + + _env.step(f"Docker 离线包:{out_dir}") + return out_dir + + +def update_running() -> None: + _env.step("更新本机运行的容器(docker compose up -d)") + _env.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--force-recreate", "capacity-app"], + cwd=_env.PACKAGING_DIR, + ) + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 / 更新 Docker 镜像") + parser.add_argument("action", nargs="?", choices=["build", "update"], default="build", help="build=编译并打包;update=编译并就地更新容器") + parser.add_argument("--no-save", action="store_true", help="编译镜像但不导出 tar") + args = parser.parse_args() + + build_image() + + if args.action == "update": + update_running() + else: + make_bundle(save_tar=not args.no_save) + + _env.step("清理前端中间产物") + _env.remove_path(_env.FRONTEND_DIR / "dist") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_server.py b/scripts/build_server.py new file mode 100644 index 0000000..dc5daf5 --- /dev/null +++ b/scripts/build_server.py @@ -0,0 +1,105 @@ +"""Server 便携版编译脚本:打包后端可执行文件 + 前端产物 + 配置 + 启动脚本。 + +用法: + python scripts/build_server.py # 编译当前系统的便携版 + python scripts/build_server.py --no-archive # 不生成 zip 压缩包 + +产物:dist/server/CapacityReport-Server-<平台>-x64/ 及其 .zip。 +注意:便携版需在目标系统原生构建(Windows 包在 Windows、Linux 包在 Linux、macOS 包在 macOS)。 +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +RUNTIME_FILES = ["Configure.json", "ReportScript.sql", "CellData.sql"] + +RUN_BAT = """@echo off +setlocal +cd /d "%~dp0" +if not exist cache mkdir cache +if not exist logs mkdir logs +echo Starting CapacityReport server... +echo URL: http://localhost:9081 +"%~dp0{exe}" --host 0.0.0.0 --port 9081 +pause +""" + +START_SH = """#!/usr/bin/env sh +set -eu +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$SCRIPT_DIR" +mkdir -p cache logs +chmod +x "./{exe}" 2>/dev/null || true +echo "Starting CapacityReport server..." +echo "URL: http://localhost:9081" +exec "./{exe}" --host 0.0.0.0 --port 9081 +""" + + +def write_launchers(out_dir: Path, platform: str) -> None: + exe = _env.server_exe_name(platform) + if platform == "windows": + (out_dir / "run.bat").write_text(RUN_BAT.format(exe=exe), encoding="utf-8") + else: + start = out_dir / "start.sh" + start.write_text(START_SH.format(exe=exe).replace("\r\n", "\n"), encoding="utf-8", newline="\n") + start.chmod(0o755) + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 Server 便携版") + parser.add_argument("--no-archive", action="store_true", help="不生成 zip 压缩包") + args = parser.parse_args() + + platform = _env.host_platform() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + _env.build_frontend() + + tmp = _env.DIST_DIR / ".tmp" / "pyinstaller" + binary_dir = _env.build_server_binary(python, onefile=False, work_dir=tmp / "work", dist_dir=tmp / "dist") + + out_name = f"CapacityReport-Server-{platform}-x64" + out_dir = _env.DIST_DIR / "server" / out_name + _env.remove_path(out_dir) + _env.remove_path(_env.DIST_DIR / "server" / f"{out_name}.zip") + out_dir.mkdir(parents=True, exist_ok=True) + + _env.step("组装便携版目录") + shutil.copytree(binary_dir, out_dir, dirs_exist_ok=True) + for name in RUNTIME_FILES: + src = _env.ROOT / name + if src.exists(): + shutil.copy2(src, out_dir / name) + (out_dir / "cache").mkdir(exist_ok=True) + (out_dir / "logs").mkdir(exist_ok=True) + + frontend_dist = _env.FRONTEND_DIR / "dist" + if not frontend_dist.exists(): + _env.die(f"前端产物不存在:{frontend_dist}") + shutil.copytree(frontend_dist, out_dir / "frontend" / "dist", dirs_exist_ok=True) + + write_launchers(out_dir, platform) + + if not args.no_archive: + _env.step("生成压缩包") + archive = shutil.make_archive(str(out_dir), "zip", root_dir=out_dir) + _env.info(f"压缩包:{archive}") + + _env.step("清理中间产物") + _env.remove_path(_env.DIST_DIR / ".tmp") + _env.remove_path(frontend_dist) + + _env.step(f"便携版完成:{out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_tauri.py b/scripts/build_tauri.py new file mode 100644 index 0000000..70100ea --- /dev/null +++ b/scripts/build_tauri.py @@ -0,0 +1,218 @@ +"""Tauri 桌面版编译脚本。 + +用法: + python scripts/build_tauri.py # 编译当前系统的桌面版 + python scripts/build_tauri.py --platform windows # 指定平台(windows/linux/macos) + +特性: +- Windows 安装包内置 WebView2 离线安装器(tauri.conf.json 已配置 offlineInstaller), + 适合无外网、未预装 WebView2 Runtime 的机器。 +- Windows NSIS 安装器默认安装到 D:\\Program Files\\CapacityReport(无 D 盘时回落系统盘)。 +- 跨平台限制:Tauri 无法可靠跨系统交叉编译,--platform 必须与当前系统一致,否则会提示需在目标系统本机构建。 + +产物:dist/desktop/ 下的安装包(Windows: .msi/.exe;Linux: .deb/.AppImage;macOS: .dmg)。 +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +TAURI_CONF = _env.SRC_TAURI_DIR / "tauri.conf.json" +NSIS_HOOK = _env.SRC_TAURI_DIR / "windows" / "nsis-hooks.nsh" + +INSTALLER_EXTS = { + "windows": (".msi", ".exe"), + "linux": (".deb", ".AppImage", ".rpm"), + "macos": (".dmg", ".app.tar.gz"), +} + + +# --------------------------------------------------------------------------- NSIS 定制(Windows 默认装 D 盘) + +_RESTORE_LINE = " Call RestorePreviousInstallLocation" + +_INSTALL_DIR_OVERRIDE = """ Call RestorePreviousInstallLocation + + !if "${INSTALLMODE}" == "perMachine" + ${If} ${FileExists} "D:\\*.*" + StrCpy $INSTDIR "D:\\Program Files\\${PRODUCTNAME}" + ${Else} + ${If} ${RunningX64} + StrCpy $INSTDIR "$PROGRAMFILES64\\${PRODUCTNAME}" + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\\${PRODUCTNAME}" + ${EndIf} + ${EndIf} + !endif +""" + +_ON_INIT_TAIL = """ !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd""" + +_ON_INIT_OVERRIDE = """ !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif + + !if "${INSTALLMODE}" == "perMachine" + ${If} ${FileExists} "D:\\*.*" + StrCpy $INSTDIR "D:\\Program Files\\${PRODUCTNAME}" + ${Else} + ${If} ${RunningX64} + StrCpy $INSTDIR "$PROGRAMFILES64\\${PRODUCTNAME}" + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\\${PRODUCTNAME}" + ${EndIf} + ${EndIf} + !endif +FunctionEnd""" + +_RESTORE_FUNCTION = """Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd""" + +_RESTORE_OVERRIDE = """Function RestorePreviousInstallLocation + !if "${INSTALLMODE}" == "perMachine" + ${If} ${FileExists} "D:\\*.*" + StrCpy $INSTDIR "D:\\Program Files\\${PRODUCTNAME}" + Return + ${EndIf} + !endif + + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd""" + + +def find_nsis_template() -> Path: + registry = Path.home() / ".cargo" / "registry" / "src" + if not registry.exists(): + _env.die(f"未找到 Cargo registry:{registry}", "先运行一次 cargo tauri build 拉取 tauri-bundler。") + candidates = [ + path + for path in registry.rglob("installer.nsi") + if "tauri-bundler-" in str(path) and path.parts[-4:] == ("bundle", "windows", "nsis", "installer.nsi") + ] + if not candidates: + _env.die("未找到 Tauri NSIS 模板(installer.nsi)", "先运行一次 cargo tauri build 拉取 tauri-bundler。") + return sorted(candidates, reverse=True)[0] + + +def make_nsis_template(tmp_dir: Path) -> Path: + source = find_nsis_template() + content = source.read_text(encoding="utf-8") + + import re + + hook_path = str(NSIS_HOOK.resolve()) + content, n = re.subn( + r'\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}', + f'!include "{hook_path}"', + content, + ) + if n == 0: + _env.die("Tauri NSIS 模板已变化:installer_hooks 标记未找到") + + for marker, replacement, label in ( + (_RESTORE_LINE, _INSTALL_DIR_OVERRIDE, "RestorePreviousInstallLocation 调用点"), + (_ON_INIT_TAIL, _ON_INIT_OVERRIDE, ".onInit 结尾"), + (_RESTORE_FUNCTION, _RESTORE_OVERRIDE, "RestorePreviousInstallLocation 函数"), + ): + if marker not in content: + _env.die(f"Tauri NSIS 模板已变化:{label}标记未找到") + content = content.replace(marker, replacement) + + target = tmp_dir / "tauri-nsis-installer.nsi" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content.replace("\r\n", "\n"), encoding="utf-8", newline="\n") + return target + + +def build_with_windows_nsis(tmp_dir: Path) -> None: + template = make_nsis_template(tmp_dir) + original = TAURI_CONF.read_text(encoding="utf-8") + try: + config = json.loads(original) + config["bundle"]["windows"]["nsis"]["template"] = str(template) + TAURI_CONF.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + _env.remove_path(_env.SRC_TAURI_DIR / "target" / "release" / "bundle") + _env.step("编译 Tauri 桌面版(cargo tauri build)") + _env.run(["cargo", "tauri", "build"], cwd=_env.SRC_TAURI_DIR) + finally: + TAURI_CONF.write_text(original, encoding="utf-8") + + +def collect_installers(platform: str) -> None: + bundle_dir = _env.SRC_TAURI_DIR / "target" / "release" / "bundle" + out_dir = _env.DIST_DIR / "desktop" + _env.remove_path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + exts = INSTALLER_EXTS[platform] + found = 0 + for path in bundle_dir.rglob("*"): + if path.is_file() and path.name.endswith(exts): + shutil.copy2(path, out_dir / path.name) + found += 1 + _env.info(f"安装包:{path.name}") + if found == 0: + _env.warn(f"未在 {bundle_dir} 找到安装包产物") + _env.step(f"桌面版完成:{out_dir}") + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="编译 Tauri 桌面版") + parser.add_argument( + "--platform", + choices=["current", "windows", "linux", "macos"], + default="current", + help="目标平台(默认当前系统)", + ) + args = parser.parse_args() + + host = _env.host_platform() + platform = host if args.platform == "current" else args.platform + if platform != host: + _env.die( + f"无法在 {host} 上交叉编译 {platform} 桌面版。", + f"Tauri 需要在目标系统本机构建,请在 {platform} 机器上运行本脚本。", + ) + + _env.ensure_rust() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + _env.build_frontend(api_base=_env.DESKTOP_API_BASE) + + tmp = _env.DIST_DIR / ".tmp" + _env.build_tauri_sidecar(python, tmp / "pyinstaller" / "work", tmp / "pyinstaller" / "dist") + _env.ensure_tauri_cli() + + if platform == "windows": + build_with_windows_nsis(tmp) + else: + _env.remove_path(_env.SRC_TAURI_DIR / "target" / "release" / "bundle") + _env.step("编译 Tauri 桌面版(cargo tauri build)") + _env.run(["cargo", "tauri", "build"], cwd=_env.SRC_TAURI_DIR) + + collect_installers(platform) + + _env.step("清理中间产物") + _env.remove_path(tmp) + _env.remove_path(_env.FRONTEND_DIR / "dist") + _env.remove_path(_env.SRC_TAURI_DIR / "binaries") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/clean.py b/scripts/clean.py new file mode 100644 index 0000000..e08efc0 --- /dev/null +++ b/scripts/clean.py @@ -0,0 +1,91 @@ +"""清理脚本:清除缓存、__pycache__、编译产物和运行时临时数据,保持项目整洁。 + +用法: + python scripts/clean.py # 清理缓存 / 编译产物 / 运行时临时数据(保留 .venv 与前端依赖) + python scripts/clean.py --deep # 额外清理 .venv 与 frontend/node_modules(下次运行会重装) + +默认不会删除源码、配置(Configure.json/*.sql)、授权文件或 .git。 +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + +# 遍历时跳过这些目录(既慢又无意义) +PRUNE_DIRS = {".git", ".venv", "node_modules"} + +# 编译产物 / 缓存目录(相对项目根) +ARTIFACT_DIRS = [ + "dist", + "frontend/dist", + "frontend/.vite", + "src-tauri/target", + "src-tauri/binaries", + ".ruff_cache", + ".pytest_cache", + ".mypy_cache", +] + +# 运行时临时数据目录 +RUNTIME_DIRS = ["cache", "logs", "uploads"] + +DEEP_DIRS = [".venv", "frontend/node_modules"] + + +def clean_pycache() -> int: + removed = 0 + for current, dirs, files in os.walk(_env.ROOT): + dirs[:] = [d for d in dirs if d not in PRUNE_DIRS] + for d in list(dirs): + if d == "__pycache__": + if _env.remove_path(Path(current) / d): + removed += 1 + dirs.remove(d) + for f in files: + if f.endswith((".pyc", ".pyo")): + if _env.remove_path(Path(current) / f): + removed += 1 + return removed + + +def clean_dirs(rel_dirs: list[str]) -> int: + removed = 0 + for rel in rel_dirs: + if _env.remove_path(_env.ROOT / rel): + _env.info(f"已删除 {rel}") + removed += 1 + return removed + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="清理缓存与编译产物") + parser.add_argument("--deep", action="store_true", help="额外清理 .venv 与 frontend/node_modules") + args = parser.parse_args() + + _env.step("清理 __pycache__ / *.pyc") + n = clean_pycache() + _env.info(f"清理 {n} 项") + + _env.step("清理编译产物") + clean_dirs(ARTIFACT_DIRS) + + _env.step("清理运行时临时数据(cache / logs / uploads)") + clean_dirs(RUNTIME_DIRS) + + if args.deep: + _env.step("深度清理依赖(.venv / node_modules)") + clean_dirs(DEEP_DIRS) + + _env.step("清理完成") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev.py b/scripts/dev.py similarity index 57% rename from dev.py rename to scripts/dev.py index 652b03a..9baef73 100644 --- a/dev.py +++ b/scripts/dev.py @@ -1,18 +1,18 @@ -"""CapacityReport standalone dev launcher. +"""开发测试脚本:同时启动后端(自动重载)和前端(Vite 热更新)。 -Usage: - python dev.py +用法: + python scripts/dev.py -Starts two processes: - [api] CapacityReport API http://127.0.0.1:9081 (auto-reload) - [web] CapacityReport web http://127.0.0.1:5174 (vite HMR, proxy /api -> 9081) -Press Ctrl+C to stop. +启动两个进程: + [api] CapacityReport API http://127.0.0.1:9081 (自动重载) + [web] CapacityReport web http://127.0.0.1:5174 (Vite 热更新,/api 代理到 9081) + +按 Ctrl+C 停止全部。首次运行会自动创建 .venv 并安装依赖、自动安装前端依赖。 """ from __future__ import annotations import os -import shutil import signal import subprocess import sys @@ -22,39 +22,24 @@ import urllib.error import urllib.request from pathlib import Path -ROOT = Path(__file__).resolve().parent -IS_WINDOWS = os.name == "nt" -VENV_DIR = ROOT / ".venv" -VENV_PYTHON = VENV_DIR / ("Scripts" if IS_WINDOWS else "bin") / ("python.exe" if IS_WINDOWS else "python") -REQUIREMENTS = ROOT / "requirements.txt" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + API_PORT = 9081 WEB_PORT = 5174 -SYSTEM_PYTHON_CANDIDATES = [ - Path(r"D:\Program Files\uv\python\cpython-3.14.3-windows-x86_64-none\python.exe"), - Path(shutil.which("python3") or ""), - Path(shutil.which("python") or ""), -] - _processes: list[tuple[str, subprocess.Popen]] = [] _stopping = threading.Event() def log(name: str, message: str) -> None: - line = f"[{name}] {message}".rstrip("\r\n") + "\n" - try: - sys.stdout.write(line) - except UnicodeEncodeError: - encoding = sys.stdout.encoding or "utf-8" - sys.stdout.write(line.encode(encoding, "replace").decode(encoding, "replace")) - sys.stdout.flush() + print(f"[{name}] {message}".rstrip(), flush=True) def _stream(name: str, process: subprocess.Popen) -> None: assert process.stdout is not None for raw in iter(process.stdout.readline, b""): - line = raw.decode("utf-8", "replace").rstrip("\r\n") - log(name, line) + log(name, raw.decode("utf-8", "replace").rstrip("\r\n")) def _spawn(name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen: @@ -64,7 +49,7 @@ def _spawn(name: str, args: list[str], cwd: Path, env: dict[str, str] | None = N "stderr": subprocess.STDOUT, "env": env, } - if IS_WINDOWS: + if _env.IS_WINDOWS: kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "CREATE_NO_WINDOW", 0) else: kwargs["start_new_session"] = True @@ -83,7 +68,7 @@ def _stop_all() -> None: continue log(name, "stopping...") try: - if IS_WINDOWS: + if _env.IS_WINDOWS: subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)], capture_output=True, check=False) else: os.killpg(os.getpgid(process.pid), signal.SIGTERM) @@ -108,62 +93,22 @@ def _wait_for_http(proc_name: str, url: str, timeout: float = 90.0) -> bool: return False -def _find_system_python() -> Path | None: - for candidate in SYSTEM_PYTHON_CANDIDATES: - if candidate and candidate.exists(): - return candidate - return None - - -def _ensure_venv() -> Path: - if VENV_PYTHON.exists(): - return VENV_PYTHON - - base_python = _find_system_python() - if base_python is None: - log("dev", "no Python found; install Python or uv first") - raise SystemExit(1) - - log("dev", f"creating venv with {base_python} ...") - subprocess.run([str(base_python), "-m", "venv", str(VENV_DIR)], check=True) - - log("dev", "installing dependencies ...") - subprocess.run([str(VENV_PYTHON), "-m", "pip", "install", "-r", str(REQUIREMENTS)], cwd=str(ROOT), check=True) - - return VENV_PYTHON - - -def _ensure_node_modules() -> None: - web_dir = ROOT / "frontend" - if (web_dir / "node_modules").exists(): - return - npm = shutil.which("npm") - if not npm: - log("dev", "npm not found; install Node.js first") - raise SystemExit(1) - log("web", "installing frontend dependencies ...") - subprocess.run([npm, "install"], cwd=str(web_dir), shell=IS_WINDOWS, check=True) - - def main() -> int: - try: - sys.stdout.reconfigure(errors="replace") - except Exception: - pass + _env.setup_console() - python = _ensure_venv() - _ensure_node_modules() + python = _env.ensure_venv() + _env.ensure_frontend_deps() - node = shutil.which("node") - vite_bin = ROOT / "frontend" / "node_modules" / "vite" / "bin" / "vite.js" + node = _env.which("node") + vite_bin = _env.FRONTEND_DIR / "node_modules" / "vite" / "bin" / "vite.js" if not node or not vite_bin.exists(): - log("dev", "node or vite not found after npm install") - return 1 + _env.die("未找到 node 或 vite(请确认前端依赖已安装)") - api_proc = _spawn( + _env.step("启动开发服务") + _spawn( "api", [str(python), "-m", "uvicorn", "app.main:app", "--reload", "--host", "127.0.0.1", "--port", str(API_PORT)], - ROOT, + _env.ROOT, ) log("api", f"starting -> http://127.0.0.1:{API_PORT} (auto-reload)") @@ -173,10 +118,11 @@ def main() -> int: elif not _stopping.is_set(): log("web", "backend not ready in time; starting frontend anyway") + api_proc = _processes[0][1] if _stopping.is_set() or api_proc.poll() is not None: return api_proc.poll() or 1 - _spawn("web", [node, str(vite_bin), "--host", "127.0.0.1", "--port", str(WEB_PORT)], ROOT / "frontend") + _spawn("web", [node, str(vite_bin), "--host", "127.0.0.1", "--port", str(WEB_PORT)], _env.FRONTEND_DIR) log("web", f"starting -> http://127.0.0.1:{WEB_PORT} (hot reload)") log("dev", "press Ctrl+C to stop") diff --git a/scripts/run.py b/scripts/run.py new file mode 100644 index 0000000..b809174 --- /dev/null +++ b/scripts/run.py @@ -0,0 +1,49 @@ +"""本地运行脚本:构建前端(如缺失)并启动后端服务。 + +用法: + python scripts/run.py # 默认 0.0.0.0:9081 + python scripts/run.py --port 8080 # 指定端口 + python scripts/run.py --rebuild # 强制重新构建前端 + +首次运行会自动创建 .venv、安装依赖;前端构建产物缺失时自动构建。按 Ctrl+C 停止。 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="本地运行 CapacityReport 服务") + parser.add_argument("--host", default="0.0.0.0", help="监听地址(默认 0.0.0.0)") + parser.add_argument("--port", default="9081", help="监听端口(默认 9081)") + parser.add_argument("--rebuild", action="store_true", help="强制重新构建前端") + args = parser.parse_args() + + python = _env.ensure_venv() + + dist_index = _env.FRONTEND_DIR / "dist" / "index.html" + if args.rebuild or not dist_index.exists(): + _env.build_frontend() + + _env.step(f"启动服务 -> http://localhost:{args.port}") + _env.info("按 Ctrl+C 停止") + try: + completed = subprocess.run( + [str(python), "-m", "app.main", "--host", args.host, "--port", str(args.port)], + cwd=str(_env.ROOT), + ) + except KeyboardInterrupt: + return 0 + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tauri_dev.py b/scripts/tauri_dev.py new file mode 100644 index 0000000..5521611 --- /dev/null +++ b/scripts/tauri_dev.py @@ -0,0 +1,54 @@ +"""Tauri 桌面端测试运行脚本:构建前端 + 后端 sidecar,然后 cargo tauri dev。 + +用法: + python scripts/tauri_dev.py # 复用已有 sidecar/前端产物(缺失才构建) + python scripts/tauri_dev.py --rebuild # 强制重建前端和 sidecar + +桌面端会启动本机 sidecar(http://127.0.0.1:9081)。首次运行会自动准备 .venv、 +前端依赖、PyInstaller,并在缺少 Rust / Tauri CLI 时给出安装引导。 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _env # noqa: E402 + + +def main() -> int: + _env.setup_console() + parser = argparse.ArgumentParser(description="Tauri 桌面端测试运行") + parser.add_argument("--rebuild", action="store_true", help="强制重建前端与 sidecar") + args = parser.parse_args() + + _env.ensure_rust() + python = _env.ensure_venv(extra_packages=["pyinstaller"]) + + dist_index = _env.FRONTEND_DIR / "dist" / "index.html" + if args.rebuild or not dist_index.exists(): + _env.build_frontend(api_base=_env.DESKTOP_API_BASE) + + triple = _env.rust_host_triple() + suffix = ".exe" if _env.host_platform() == "windows" else "" + sidecar = _env.SRC_TAURI_DIR / "binaries" / f"{_env.SERVER_NAME}-{triple}{suffix}" + if args.rebuild or not sidecar.exists(): + tmp = _env.DIST_DIR / ".tmp" / "pyinstaller" + _env.build_tauri_sidecar(python, tmp / "work", tmp / "dist") + + _env.ensure_tauri_cli() + + _env.step("启动 Tauri 开发模式(cargo tauri dev)") + _env.info("按 Ctrl+C 停止") + try: + completed = subprocess.run(["cargo", "tauri", "dev"], cwd=str(_env.SRC_TAURI_DIR)) + except KeyboardInterrupt: + return 0 + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/start.sh b/start.sh deleted file mode 100644 index bfe9188..0000000 --- a/start.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -# CapacityReport container startup script - -set -e - -mkdir -p /var/log/supervisor /var/run /app/cache - -exec supervisord -c /app/supervisord.conf diff --git a/supervisord.conf b/supervisord.conf deleted file mode 100644 index 40f9fea..0000000 --- a/supervisord.conf +++ /dev/null @@ -1,29 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid -childlogdir=/var/log/supervisor -user=root - -[unix_http_server] -file=/var/run/supervisor.sock -chmod=0700 - -[rpcinterface:supervisor] -supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface - -[supervisorctl] -serverurl=unix:///var/run/supervisor.sock - -[program:fastapi] -command=python -m app.main -directory=/app -autostart=true -autorestart=true -startsecs=5 -startretries=3 -redirect_stderr=true -stdout_logfile=/var/log/supervisor/fastapi.log -stdout_logfile_maxbytes=50MB -stdout_logfile_backups=5 -environment=PYTHONUNBUFFERED=1