246 lines
7.8 KiB
Python
246 lines
7.8 KiB
Python
#!/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") or shutil.which("npm.cmd")
|
||
|
||
|
||
def _npm(args: list[str], *, cwd: Path, env: dict | None = None) -> int:
|
||
full_env = {**os.environ, **(env or {})}
|
||
if sys.platform == "win32":
|
||
return subprocess.call(
|
||
"npm " + " ".join(args),
|
||
cwd=cwd,
|
||
shell=True,
|
||
env=full_env,
|
||
)
|
||
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
|
||
next_args = f"npm run dev -- --hostname 0.0.0.0 --port {next_port}"
|
||
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)
|
||
if sys.platform == "win32":
|
||
ui = subprocess.Popen(next_args, cwd=CONSOLE, shell=True, env=env)
|
||
else:
|
||
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()
|