feat: 导入 CapacityReport 初始源码

This commit is contained in:
Nixevol
2026-09-24 06:16:41 +08:00
commit 89cca70430
134 changed files with 38799 additions and 0 deletions
+342
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
"""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)
shutil.copytree(_env.PACKAGING_DIR / "sftpgo", out_dir / "sftpgo", dirs_exist_ok=True)
readme = _env.PACKAGING_DIR / "README.md"
if readme.exists():
shutil.copy2(readme, out_dir / "README.md")
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())
+105
View File
@@ -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())
+255
View File
@@ -0,0 +1,255 @@
"""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 os
import re
import shutil
import sys
import tarfile
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 _cargo_home() -> Path:
return Path(os.environ.get("CARGO_HOME") or (Path.home() / ".cargo"))
def _read_template_from_src() -> str | None:
"""优先读取 registry/src 下已解压的 tauri-bundler 模板。"""
registry = _cargo_home() / "registry" / "src"
if not registry.exists():
return None
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:
return None
return sorted(candidates, reverse=True)[0].read_text(encoding="utf-8")
def _read_template_from_crate() -> str | None:
"""registry/src 被 cargo gc 清理时,从缓存的 tauri-bundler-*.crate 包内直接解出模板。"""
cache = _cargo_home() / "registry" / "cache"
if not cache.exists():
return None
crates = sorted(cache.rglob("tauri-bundler-*.crate"), reverse=True)
member_suffix = "src/bundle/windows/nsis/installer.nsi"
for crate in crates:
try:
with tarfile.open(crate, "r:gz") as archive:
for member in archive.getmembers():
if member.name.endswith(member_suffix):
extracted = archive.extractfile(member)
if extracted is not None:
return extracted.read().decode("utf-8")
except (tarfile.TarError, OSError):
continue
return None
def read_nsis_template_text() -> str:
content = _read_template_from_src() or _read_template_from_crate()
if content is None:
_env.die(
"未找到 Tauri NSIS 模板(installer.nsi)",
"先运行一次 cargo tauri build 拉取 tauri-bundler。",
)
return content
def make_nsis_template(tmp_dir: Path) -> Path:
content = read_nsis_template_text()
hook_path = str(NSIS_HOOK.resolve())
include_line = f'!include "{hook_path}"'
content, n = re.subn(
r'\{\{#if installer_hooks\}\}\s*!include "\{\{installer_hooks\}\}"\s*\{\{/if\}\}',
lambda _: include_line,
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)
# 按字节备份/还原,避免 Windows 文本写入把原文件 LF 改成 CRLF。
original = TAURI_CONF.read_bytes()
try:
config = json.loads(original.decode("utf-8"))
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_bytes(original)
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())
+91
View File
@@ -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())
+150
View File
@@ -0,0 +1,150 @@
"""开发测试脚本:同时启动后端(自动重载)和前端(Vite 热更新)。
用法:
python scripts/dev.py
启动两个进程:
[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 signal
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _env # noqa: E402
API_PORT = 9081
WEB_PORT = 5174
_processes: list[tuple[str, subprocess.Popen]] = []
_stopping = threading.Event()
def log(name: str, message: str) -> None:
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""):
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:
kwargs: dict[str, object] = {
"cwd": str(cwd),
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"env": env,
}
if _env.IS_WINDOWS:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "CREATE_NO_WINDOW", 0)
else:
kwargs["start_new_session"] = True
process = subprocess.Popen(args, **kwargs) # type: ignore[arg-type]
_processes.append((name, process))
threading.Thread(target=_stream, args=(name, process), daemon=True).start()
return process
def _stop_all() -> None:
if _stopping.is_set():
return
_stopping.set()
for name, process in _processes:
if process.poll() is not None:
continue
log(name, "stopping...")
try:
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)
except Exception:
pass
def _wait_for_http(proc_name: str, url: str, timeout: float = 90.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if _stopping.is_set():
return False
if any(name == proc_name and proc.poll() is not None for name, proc in _processes):
return False
try:
with urllib.request.urlopen(url, timeout=2):
return True
except urllib.error.HTTPError:
return True
except (urllib.error.URLError, OSError):
time.sleep(0.4)
return False
def main() -> int:
_env.setup_console()
python = _env.ensure_venv()
_env.ensure_frontend_deps()
node = _env.which("node")
vite_bin = _env.FRONTEND_DIR / "node_modules" / "vite" / "bin" / "vite.js"
if not node or not vite_bin.exists():
_env.die("未找到 node 或 vite(请确认前端依赖已安装)")
_env.step("启动开发服务")
_spawn(
"api",
[str(python), "-m", "uvicorn", "app.main:app", "--reload", "--host", "127.0.0.1", "--port", str(API_PORT)],
_env.ROOT,
)
log("api", f"starting -> http://127.0.0.1:{API_PORT} (auto-reload)")
log("web", "waiting for backend ...")
if _wait_for_http("api", f"http://127.0.0.1:{API_PORT}/health"):
log("web", "backend ready")
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)], _env.FRONTEND_DIR)
log("web", f"starting -> http://127.0.0.1:{WEB_PORT} (hot reload)")
log("dev", "press Ctrl+C to stop")
reported: set[str] = set()
try:
while not _stopping.is_set():
for name, process in _processes:
code = process.poll()
if code is None or name in reported:
continue
reported.add(name)
if name == "api":
log(name, f"exited with code {code}; shutting down")
return code or 0
log(name, f"exited with code {code}")
time.sleep(0.5)
except KeyboardInterrupt:
log("dev", "received Ctrl+C")
finally:
_stop_all()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+54
View File
@@ -0,0 +1,54 @@
"""Tauri 桌面端测试运行脚本:构建前端 + 后端 sidecar,然后 cargo tauri dev。
用法:
python scripts/dev_tauri.py # 复用已有 sidecar/前端产物(缺失才构建)
python scripts/dev_tauri.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())
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""根据授权 key 生成激活码(与 app/services/license.py 算法一致)。"""
from __future__ import annotations
import argparse
import sys
from datetime import date, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.license import activation_hash, format_key_label
def parse_key(raw: str) -> date:
text = raw.strip()
for fmt in ("%Y/%m/%d", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise ValueError("无法解析 key,请使用 YYYY/MM/DD 格式,例如 2026/06/20")
def generate_code(key: str) -> tuple[str, str]:
expires_on = parse_key(key)
key_label = format_key_label(expires_on)
return key_label, activation_hash(expires_on)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="输入授权 key(到期日期),输出对应激活码。",
)
parser.add_argument(
"key",
nargs="?",
help="授权 key,格式 YYYY/MM/DD 或 YYYY-MM-DD,例如 2026/06/20",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
key_input = args.key
if not key_input:
try:
key_input = input("请输入 key (YYYY/MM/DD): ").strip()
except EOFError:
print("未输入 key。", file=sys.stderr)
return 1
if not key_input:
print("key 不能为空。", file=sys.stderr)
return 1
try:
key_label, code = generate_code(key_input)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
print(f"key: {key_label}")
print(f"激活码: {code}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+49
View File
@@ -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())