#!/usr/bin/env python3 """宿舍报修系统 —— 统一项目管理 CLI 用法: ./cli.py setup 安装全部依赖 ./cli.py start [backend|frontend|all] 启动服务 ./cli.py stop [backend|frontend|all] 停止服务 ./cli.py restart [backend|frontend|all] 重启服务 ./cli.py status 查看服务状态 ./cli.py logs [backend|frontend] [-f] 查看日志 ./cli.py seed [--force] [--db PATH] 写入演示数据 ./cli.py lint 运行代码检查 ./cli.py typecheck TypeScript 类型检查 ./cli.py build 生产构建(前端) ./cli.py clean [--runtime|--db|--deps|--all] 清理临时文件 ./cli.py env 创建 .env 配置 ./cli.py docker [up|down|build] Docker 快捷操作 """ from __future__ import annotations import argparse import json import os import signal import socket import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import Any IS_WINDOWS = sys.platform == "win32" # Auto-activate virtualenv if present _VENV = Path(__file__).resolve().parent / ".venv" _VENV_PYTHON = _VENV / ("Scripts" if IS_WINDOWS else "bin") / ("python.exe" if IS_WINDOWS else "python") if _VENV_PYTHON.is_file() and Path(sys.executable).resolve() != _VENV_PYTHON.resolve(): os.execv(str(_VENV_PYTHON), [str(_VENV_PYTHON), *sys.argv]) PROJECT_ROOT = Path(__file__).resolve().parent BACKEND_DIR = PROJECT_ROOT / "backend" FRONTEND_DIR = PROJECT_ROOT / "frontend" RUNTIME_DIR = PROJECT_ROOT / ".runtime" / "dev-services" STATE_PATH = RUNTIME_DIR / "state.json" DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" # ── Color helpers ────────────────────────────────────────────── _BOLD = "\033[1m" _GREEN = "\033[32m" _YELLOW = "\033[33m" _RED = "\033[31m" _CYAN = "\033[36m" _RESET = "\033[0m" def _green(s: str) -> str: return f"{_GREEN}{s}{_RESET}" def _yellow(s: str) -> str: return f"{_YELLOW}{s}{_RESET}" def _red(s: str) -> str: return f"{_RED}{s}{_RESET}" def _cyan(s: str) -> str: return f"{_CYAN}{s}{_RESET}" def _bold(s: str) -> str: return f"{_BOLD}{s}{_RESET}" # ── Configuration ────────────────────────────────────────────── @dataclass(frozen=True) class Config: backend_host: str = "127.0.0.1" backend_port: int = 8000 frontend_host: str = "127.0.0.1" frontend_port: int = 5173 default_db: Path = BACKEND_DIR / "data" / "dorm_repair.sqlite3" @property def backend_url(self) -> str: return f"http://{self.backend_host}:{self.backend_port}" @property def frontend_url(self) -> str: return f"http://{self.frontend_host}:{self.frontend_port}" @property def backend_health_url(self) -> str: return f"{self.backend_url}/api/health" CONFIG = Config() # ── Service definitions ──────────────────────────────────────── @dataclass(frozen=True) class Service: name: str command: list[str] cwd: Path url: str host: str port: int env: dict[str, str] | None = None @property def log_path(self) -> Path: return RUNTIME_DIR / f"{self.name}.log" SERVICES = { "backend": Service( name="backend", command=[ "uv", "run", "uvicorn", "app.main:app", "--reload", "--host", CONFIG.backend_host, "--port", str(CONFIG.backend_port), ], cwd=BACKEND_DIR, url=CONFIG.backend_health_url, host=CONFIG.backend_host, port=CONFIG.backend_port, env={"UV_CACHE_DIR": str(RUNTIME_DIR / "uv-cache")}, ), "frontend": Service( name="frontend", command=[ "pnpm", "dev", "--host", CONFIG.frontend_host, "--port", str(CONFIG.frontend_port), "--strictPort", ], cwd=FRONTEND_DIR, url=CONFIG.frontend_url + "/", host=CONFIG.frontend_host, port=CONFIG.frontend_port, ), } # ── State management ─────────────────────────────────────────── def _load_state() -> dict[str, Any]: if not STATE_PATH.exists(): return {} try: return json.loads(STATE_PATH.read_text(encoding="utf-8")) except json.JSONDecodeError: return {} def _save_state(state: dict[str, Any]) -> None: RUNTIME_DIR.mkdir(parents=True, exist_ok=True) STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") # ── Process helpers ──────────────────────────────────────────── def _pid_running(pid: int) -> bool: if IS_WINDOWS: result = subprocess.run( ["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, text=True, ) return str(pid) in result.stdout try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True return True def _service_pid(state: dict[str, Any], svc: Service) -> int | None: raw = state.get(svc.name, {}).get("pid") if not isinstance(raw, int): return None return raw if _pid_running(raw) else None def _port_open(host: str, port: int) -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.2) return sock.connect_ex((host, port)) == 0 # ── Service lifecycle ────────────────────────────────────────── def _start_service(svc: Service, state: dict[str, Any]) -> None: pid = _service_pid(state, svc) if pid is not None: print(f" {svc.name}: 已在运行 pid={pid}") return if _port_open(svc.host, svc.port): print(f" {_yellow(svc.name)}: 端口 {svc.host}:{svc.port} 已被占用") return RUNTIME_DIR.mkdir(parents=True, exist_ok=True) log_file = svc.log_path.open("ab") popen_kwargs: dict[str, Any] = { "cwd": svc.cwd, "env": {**os.environ, **(svc.env or {})}, "stdin": subprocess.DEVNULL, "stdout": log_file, "stderr": subprocess.STDOUT, } if IS_WINDOWS and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP else: popen_kwargs["start_new_session"] = True proc = subprocess.Popen(svc.command, **popen_kwargs) state[svc.name] = { "pid": proc.pid, "command": svc.command, "cwd": str(svc.cwd), "log": str(svc.log_path), "url": svc.url, "started_at": time.strftime(DATETIME_FORMAT), } print(f" {_green(svc.name)}: 已启动 pid={proc.pid}") def _stop_service(svc: Service, state: dict[str, Any]) -> None: pid = _service_pid(state, svc) if pid is None: print(f" {svc.name}: 未运行") state.pop(svc.name, None) return print(f" {svc.name}: 正在停止 pid={pid}") if IS_WINDOWS: subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True) else: try: os.killpg(pid, signal.SIGTERM) except ProcessLookupError: state.pop(svc.name, None) return deadline = time.time() + 8 while time.time() < deadline: if not _pid_running(pid): state.pop(svc.name, None) print(f" {svc.name}: 已停止") return time.sleep(0.2) print(f" {_yellow(svc.name)}: 未响应,强制终止") if IS_WINDOWS: subprocess.run(["taskkill", "/F", "/PID", str(pid), "/T"], capture_output=True) else: try: os.killpg(pid, signal.SIGKILL) except ProcessLookupError: pass state.pop(svc.name, None) def _status_service(svc: Service, state: dict[str, Any]) -> None: pid = _service_pid(state, svc) if pid is None: suffix = " (端口被占用)" if _port_open(svc.host, svc.port) else "" print(f" {svc.name}: 未运行{suffix}") return print(f" {_green(svc.name)}: 运行中 pid={pid} url={svc.url}") def _pick_services(names: list[str]) -> list[Service]: if not names or "all" in names: return list(SERVICES.values()) selected: list[Service] = [] for name in names: svc = SERVICES.get(name) if svc is None: choices = ", ".join(["all", *SERVICES]) raise SystemExit(f"未知服务 '{name}',可选: {choices}") selected.append(svc) return selected # ── Commands ─────────────────────────────────────────────────── def cmd_setup() -> int: """安装后端和前端依赖。""" print(_bold("安装后端依赖 (uv sync) ...")) subprocess.run(["uv", "sync"], cwd=BACKEND_DIR, check=True) print(_bold("安装前端依赖 (pnpm install) ...")) subprocess.run(["pnpm", "install"], cwd=FRONTEND_DIR, check=True) print(_green("安装完成。")) return 0 def cmd_start(services: list[str]) -> int: state = _load_state() svcs = _pick_services(services) for svc in svcs: _start_service(svc, state) _save_state(state) if svcs: _print_urls(svcs) return 0 def _print_urls(svcs: list[Service]) -> None: print() for svc in svcs: label = "后端" if svc.name == "backend" else "前端" print(f" {label}: {_cyan(svc.url)}") print() def cmd_stop(services: list[str]) -> int: state = _load_state() for svc in reversed(_pick_services(services)): _stop_service(svc, state) _save_state(state) return 0 def cmd_restart(services: list[str]) -> int: state = _load_state() svcs = _pick_services(services) for svc in reversed(svcs): _stop_service(svc, state) for svc in svcs: _start_service(svc, state) _save_state(state) if svcs: _print_urls(svcs) return 0 def cmd_status() -> int: state = _load_state() for svc in SERVICES.values(): _status_service(svc, state) return 0 def cmd_logs(service: str, follow: bool) -> int: svc = SERVICES.get(service) if svc is None: raise SystemExit(f"未知服务 '{service}',可选: backend, frontend") log_path = svc.log_path if not log_path.exists(): print(f"日志文件不存在: {log_path}") return 0 if follow: _follow_log(log_path) else: content = log_path.read_text(encoding="utf-8", errors="replace") for line in content.splitlines()[-50:]: print(line) return 0 def _follow_log(path: Path) -> None: if IS_WINDOWS: with path.open("r", encoding="utf-8", errors="replace") as f: f.seek(0, 2) while True: line = f.readline() if line: print(line, end="") else: time.sleep(0.5) else: subprocess.run(["tail", "-n", "50", "-f", str(path)]) def cmd_seed(force: bool, db_path: str | None) -> int: """Seed demo data via standalone seed script.""" db = db_path or str(CONFIG.default_db) seed_script = PROJECT_ROOT / "scripts" / "_seed.py" print(_bold("Seeding demo data ...")) subprocess.run( ["uv", "run", "python", str(seed_script), "--db", db] + (["--force"] if force else []), cwd=BACKEND_DIR, check=True, ) print(f" student01 / Student123 {_cyan('(student)')}") print(f" admin01 / Admin123 {_cyan('(admin)')}") print(" 8 sample repair orders covering all statuses") return 0 def cmd_lint() -> int: print(_bold("Backend lint (ruff) ...")) subprocess.run(["uv", "run", "ruff", "check", "backend"], check=False) subprocess.run(["uv", "run", "ruff", "format", "--check", "backend"], check=False) print(_bold("Frontend lint (eslint) ...")) subprocess.run(["pnpm", "lint"], cwd=FRONTEND_DIR, check=False) return 0 def cmd_typecheck() -> int: print(_bold("TypeScript type check ...")) subprocess.run(["pnpm", "typecheck"], cwd=FRONTEND_DIR, check=False) return 0 def cmd_build() -> int: print(_bold("Building frontend ...")) subprocess.run(["pnpm", "build"], cwd=FRONTEND_DIR, check=True) print(_green(f"Output: {FRONTEND_DIR / 'dist'}/")) return 0 def cmd_clean(runtime: bool, db: bool, deps: bool, all_: bool) -> int: if all_: runtime = db = deps = True if runtime: if RUNTIME_DIR.exists(): import shutil shutil.rmtree(RUNTIME_DIR) print(f"Removed {RUNTIME_DIR}") else: print("No runtime dir to clean.") if db: db_path = CONFIG.default_db if db_path.exists(): db_path.unlink() print(f"Removed {db_path}") else: print("No database to clean.") if deps: for d in [BACKEND_DIR / ".venv", FRONTEND_DIR / "node_modules"]: if d.exists(): import shutil shutil.rmtree(d) print(f"Removed {d}") print(_green("Clean complete.")) return 0 def cmd_env() -> int: for template, target in [ (BACKEND_DIR / ".env.example", BACKEND_DIR / ".env"), (FRONTEND_DIR / ".env.example", FRONTEND_DIR / ".env"), ]: if not template.exists(): print(f"Template not found: {template}") continue if target.exists(): print(f"{_yellow('Skipped')} {target} (already exists)") continue target.write_text(template.read_text(encoding="utf-8"), encoding="utf-8") print(f"Created {target} from {template.name}") print(_green("Environment files ready. Edit them if needed:")) print(f" {BACKEND_DIR / '.env'}") print(f" {FRONTEND_DIR / '.env'}") return 0 def cmd_docker(action: str) -> int: compose_file = PROJECT_ROOT / "docker-compose.yml" if not compose_file.exists(): raise SystemExit("docker-compose.yml not found.") if action == "up": subprocess.run(["docker", "compose", "up", "-d"], check=True) elif action == "down": subprocess.run(["docker", "compose", "down"], check=True) elif action == "build": subprocess.run(["docker", "compose", "build"], check=True) else: raise SystemExit(f"Unknown docker action: {action}") return 0 # ── Argument parsing ─────────────────────────────────────────── def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="宿舍报修系统 —— 项目管理 CLI", formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("setup", help="安装全部依赖(uv sync + pnpm install)") for cmd_name in ("start", "stop", "restart"): label = {"start": "启动", "stop": "停止", "restart": "重启"}[cmd_name] p = sub.add_parser(cmd_name, help=f"{label}服务") p.add_argument( "services", nargs="*", default=["all"], help="目标服务:all, backend, frontend(默认: all)", ) sub.add_parser("status", help="查看服务状态") log_p = sub.add_parser("logs", help="查看服务日志") log_p.add_argument("service", choices=["backend", "frontend"], help="服务名") log_p.add_argument("-f", "--follow", action="store_true", help="实时跟踪日志") seed_p = sub.add_parser("seed", help="写入演示数据") seed_p.add_argument("--force", action="store_true", help="覆盖已有数据") seed_p.add_argument("--db", type=str, default=None, help="SQLite 数据库路径") sub.add_parser("lint", help="运行代码检查(ruff + eslint)") sub.add_parser("typecheck", help="TypeScript 类型检查(vue-tsc)") sub.add_parser("build", help="生产构建(前端)") clean_p = sub.add_parser("clean", help="清理临时文件") clean_p.add_argument("--runtime", action="store_true", help="删除 .runtime/") clean_p.add_argument("--db", action="store_true", help="删除 SQLite 数据库") clean_p.add_argument("--deps", action="store_true", help="删除 .venv 和 node_modules") clean_p.add_argument("--all", action="store_true", help="清理全部") sub.add_parser("env", help="从 .env.example 创建 .env 配置文件") docker_p = sub.add_parser("docker", help="Docker 快捷操作") docker_p.add_argument("action", choices=["up", "down", "build"], help="Docker 操作") return parser.parse_args(argv) # ── Main ─────────────────────────────────────────────────────── def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) cmd = args.command if cmd == "setup": return cmd_setup() elif cmd == "start": return cmd_start(args.services) elif cmd == "stop": return cmd_stop(args.services) elif cmd == "restart": return cmd_restart(args.services) elif cmd == "status": return cmd_status() elif cmd == "logs": return cmd_logs(args.service, args.follow) elif cmd == "seed": return cmd_seed(args.force, args.db) elif cmd == "lint": return cmd_lint() elif cmd == "typecheck": return cmd_typecheck() elif cmd == "build": return cmd_build() elif cmd == "clean": return cmd_clean(args.runtime, args.db, args.deps, args.all) elif cmd == "env": return cmd_env() elif cmd == "docker": return cmd_docker(args.action) return 1 if __name__ == "__main__": raise SystemExit(main())