#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 一键启动 Web 控制台: 生产:构建 web-console(若需要)+ 仅 uvicorn(单端口托管 API + 静态页) 开发:uvicorn + Next dev(默认绑定 0.0.0.0,局域网可访问) 自动加载 config/runtime.env(若存在,不覆盖已有环境变量)。 用法: python scripts/launch.py python scripts/launch.py --dev python scripts/launch.py --port 8101 python scripts/launch.py --skip-build python scripts/launch.py --no-env-check 环境变量: HOST, PORT, NEXT_DEV_PORT, WEB_STACK=api, API_PROXY_TARGET(开发) """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import time from pathlib import Path if hasattr(sys.stdout, "reconfigure"): try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except Exception: pass ROOT = Path(__file__).resolve().parent.parent SCRIPTS_DIR = Path(__file__).resolve().parent CONSOLE = ROOT / "web-console" OUT_INDEX = CONSOLE / "out" / "index.html" sys.path.insert(0, str(SCRIPTS_DIR)) import env_check # noqa: E402 def load_runtime_env() -> None: path = ROOT / "config" / "runtime.env" if not path.is_file(): return for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: continue k, _, v = line.partition("=") k, v = k.strip(), v.strip().strip('"').strip("'") if k and k not in os.environ: os.environ[k] = v def _which_npm() -> str | None: return shutil.which("npm") def _npm(args: list[str], *, cwd: Path, env: dict | None = None) -> int: full_env = {**os.environ, **(env or {})} npm = _which_npm() if not npm: return 127 return subprocess.call([npm, *args], cwd=cwd, env=full_env) def ensure_console_built(*, skip: bool) -> bool: """返回是否已存在可托管的静态 index。""" if skip: return OUT_INDEX.is_file() if not CONSOLE.is_dir(): print("[launch] 未找到 web-console,将仅使用根目录 index.html(若存在)。") return False if not _which_npm(): print("[launch] 未检测到 npm,跳过前端构建;请先安装 Node.js 或手动在 web-console 执行 npm run build。") return OUT_INDEX.is_file() if OUT_INDEX.is_file(): return True print("[launch] 正在安装依赖并构建 Web 控制台(首次略慢)…") lock = CONSOLE / "package-lock.json" if lock.is_file(): if _npm(["ci"], cwd=CONSOLE) != 0: print("[launch] npm ci 失败,尝试 npm install…") if _npm(["install"], cwd=CONSOLE) != 0: sys.exit(1) else: if _npm(["install"], cwd=CONSOLE) != 0: sys.exit(1) if _npm(["run", "build"], cwd=CONSOLE) != 0: sys.exit(1) print("[launch] 前端构建完成。") return True def _terminate_process(p: subprocess.Popen | None) -> None: if p is None or p.poll() is not None: return p.terminate() try: p.wait(timeout=8) except subprocess.TimeoutExpired: p.kill() p.wait(timeout=3) def run_dev(host: str, port: str, next_port: str) -> None: if not _which_npm(): print("[launch] 开发模式需要 Node.js/npm。仅起 API 请用: python scripts/launch.py --api-only --reload") sys.exit(1) api_url = f"http://127.0.0.1:{port}" env = os.environ.copy() env.setdefault("API_PROXY_TARGET", api_url) api_cmd = [ sys.executable, "-m", "uvicorn", "web:app", "--host", host, "--port", port, "--reload", ] os.chdir(ROOT) api: subprocess.Popen | None = None ui: subprocess.Popen | None = None try: api = subprocess.Popen(api_cmd, cwd=ROOT) time.sleep(0.6) if api.poll() is not None: print("[launch] API 进程异常退出,请检查端口是否被占用。") sys.exit(api.returncode or 1) ui = subprocess.Popen( ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", next_port], cwd=CONSOLE, env=env, ) print() print(" —— 开发模式(局域网可访问)——") print(f" 控制台界面: http://0.0.0.0:{next_port} 或用本机 IP + 端口") print(f" API: http://{host}:{port}/ (Next 已代理同名接口到 {api_url})") print(" 按 Ctrl+C 可同时结束前后端") if host == "0.0.0.0": print(" 提示: 绑定 0.0.0.0 时请勿把管理端口暴露到公网") print() while True: time.sleep(0.5) if api.poll() is not None: print("[launch] API 已退出") break if ui and ui.poll() is not None: print("[launch] Next 已退出") break except KeyboardInterrupt: print("\n[launch] 正在关闭…") finally: _terminate_process(ui) _terminate_process(api) def run_api_only(host: str, port: str, reload: bool) -> None: os.chdir(ROOT) cmd = [sys.executable, "-m", "uvicorn", "web:app", "--host", host, "--port", port] if reload: cmd.append("--reload") os.execvp(sys.executable, cmd) def run_prod(host: str, port: str, skip_build: bool, reload: bool) -> None: ensure_console_built(skip=skip_build) run_api_only(host, port, reload) def main() -> None: load_runtime_env() default_port = os.environ.get("PORT", "8001") ap = argparse.ArgumentParser(description="直播控制台一键启动") ap.add_argument("--dev", action="store_true", help="开发:API + Next(需 Node)") ap.add_argument( "--api-only", action="store_true", help="仅 uvicorn,不构建、不启动 Next", ) ap.add_argument("--host", default=None, help="监听地址(默认生产 0.0.0.0,可经 HOST 环境变量)") ap.add_argument("--port", default=None, help="端口(默认 PORT 或 8001)") ap.add_argument("--skip-build", action="store_true", help="跳过前端构建检测") ap.add_argument("--reload", action="store_true", help="uvicorn --reload(生产慎用)") ap.add_argument("--no-env-check", action="store_true", help="跳过环境分析") args = ap.parse_args() if os.environ.get("WEB_STACK", "").lower() == "api": args.api_only = True port = str(args.port if args.port is not None else default_port) next_port = os.environ.get("NEXT_DEV_PORT", "3000") if args.dev: host = args.host or os.environ.get("HOST") or "0.0.0.0" else: host = args.host or os.environ.get("HOST") or "0.0.0.0" need_npm = args.dev or ( not args.skip_build and not args.api_only and not OUT_INDEX.is_file() ) if not args.no_env_check: env_check.print_env_report( dev=bool(args.dev), need_npm=need_npm, host=host, port=port, next_port=next_port, ) if args.dev: run_dev(host, port, next_port) return if args.api_only: run_api_only(host, port, args.reload) return run_prod(host, port, skip_build=args.skip_build, reload=args.reload) if __name__ == "__main__": main()