This commit is contained in:
root
2026-05-17 13:21:44 +00:00
parent ffdc887b8a
commit a3e8a4a96c
27 changed files with 671 additions and 140 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import importlib.util
import shutil
import socket
import subprocess
import sys
@@ -25,6 +26,21 @@ def _local_ips() -> list[str]:
return out[:4]
def _node_major() -> tuple[int | None, str | None]:
node = shutil.which("node")
if not node:
return None, None
try:
raw = subprocess.check_output([node, "-v"], stderr=subprocess.STDOUT, text=True).strip()
except Exception:
return None, node
try:
major = int(raw.lstrip("v").split(".", 1)[0])
except Exception:
major = None
return major, f"{node} ({raw})"
def print_env_report(
*,
dev: bool,
@@ -60,12 +76,25 @@ def print_env_report(
print("│ ⚠ 未找到 ffplayHDMI 全屏播放需要完整 ffmpeg 套件)")
if need_npm:
major, node_desc = _node_major()
npm = shutil.which("npm")
docker = shutil.which("docker")
if node_desc:
if major is not None and major < 18:
print(f"│ ⚠ Node: {node_desc}Next.js 推荐 Node 18+;可自动尝试 Docker 兜底)")
else:
print(f"│ ✓ Node: {node_desc}")
else:
print("│ ✕ 未找到 Node.jsNext.js 构建/开发需要 Node 18+ 或 Docker 兜底)")
ok = False
if npm:
print(f"│ ✓ npm: {npm}")
else:
print(" 未找到 npm构建/开发前端需要安装 Node.js")
ok = False
print(" 未找到 npm若有 Node 18+ 通常可用;否则请安装完整 Node 或启用 Docker")
if docker:
print(f"│ ✓ Docker: {docker}")
elif major is None or (major is not None and major < 18):
print("│ ⚠ 无 Docker 时Node 过低会依赖本机升级才能构建前端")
print("")
print("│ 同机端口提示(若冲突请在 config/runtime.env 修改 PORT")

View File

@@ -19,11 +19,14 @@
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
@@ -37,6 +40,9 @@ ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = Path(__file__).resolve().parent
CONSOLE = ROOT / "web-console"
OUT_INDEX = CONSOLE / "out" / "index.html"
OUT_STAMP = CONSOLE / "out" / ".build-stamp.json"
MIN_WEB_NODE_MAJOR = 18
DOCKER_NODE_IMAGE = os.environ.get("WEB_CONSOLE_NODE_IMAGE", "node:20-bookworm-slim")
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(SCRIPTS_DIR))
@@ -60,54 +66,282 @@ def load_runtime_env() -> None:
os.environ[k] = v
def _which_npm() -> str | None:
return shutil.which("npm")
@dataclass(frozen=True)
class NodeRuntime:
node: Path
major: int
npm: Path | None
source: str
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 _node_version_major(node_bin: str | Path) -> int | None:
try:
raw = subprocess.check_output([str(node_bin), "-v"], stderr=subprocess.STDOUT, text=True).strip()
except Exception:
return None
match = re.match(r"v(\d+)\.", raw)
if not match:
return None
return int(match.group(1))
def ensure_console_built(*, skip: bool) -> bool:
def _candidate_node_bins() -> list[Path]:
seen: set[str] = set()
out: list[Path] = []
def add(path_like: str | Path | None) -> None:
if not path_like:
return
p = Path(path_like).expanduser()
if p.is_dir():
p = p / "node"
key = str(p.resolve() if p.exists() else p)
if key in seen or not p.exists():
return
seen.add(key)
out.append(p)
add(shutil.which("node"))
for name in ("node22", "node20", "node18", "node"):
add(shutil.which(name))
nvm_bin = os.environ.get("NVM_BIN")
if nvm_bin:
add(Path(nvm_bin) / "node")
for env_var in ("VOLTA_HOME", "MISE_INSTALL_PATH"):
base = os.environ.get(env_var)
if base:
add(Path(base) / "bin" / "node")
home = Path.home()
for p in [
home / ".nvm" / "versions" / "node",
home / ".asdf" / "installs" / "nodejs",
home / ".local" / "share" / "mise" / "installs" / "node",
Path("/usr/local/bin/node"),
Path("/opt/homebrew/bin/node"),
Path("/usr/bin/node"),
]:
if p.is_dir():
for c in sorted(p.glob("*/bin/node")):
add(c)
else:
add(p)
return out
def _pick_node_runtime(min_major: int = MIN_WEB_NODE_MAJOR) -> NodeRuntime | None:
best: NodeRuntime | None = None
for node in _candidate_node_bins():
major = _node_version_major(node)
if major is None:
continue
npm = node.with_name("npm")
if not npm.exists():
npm = shutil.which("npm", path=str(node.parent))
npm = Path(npm) if npm else None
runtime = NodeRuntime(node=node, major=major, npm=npm, source="local")
if major >= min_major:
return runtime
if best is None or major > best.major:
best = runtime
return best
def _runtime_env(runtime: NodeRuntime, extra: dict[str, str] | None = None) -> dict[str, str]:
env = os.environ.copy()
path = env.get("PATH", "")
node_dir = str(runtime.node.parent)
env["PATH"] = f"{node_dir}{os.pathsep}{path}" if path else node_dir
if runtime.npm is not None:
env["npm_config_script_shell"] = env.get("npm_config_script_shell", "/bin/sh")
if extra:
env.update(extra)
return env
def _console_sources_mtime() -> float:
roots = [
CONSOLE / "app",
CONSOLE / "components",
CONSOLE / "lib",
]
files = [
CONSOLE / "middleware.ts",
CONSOLE / "next.config.mjs",
CONSOLE / "package.json",
CONSOLE / "package-lock.json",
CONSOLE / "pnpm-lock.yaml",
CONSOLE / "postcss.config.mjs",
CONSOLE / "tailwind.config.ts",
CONSOLE / "tsconfig.json",
CONSOLE / "README.md",
]
latest = 0.0
for path in files:
if path.is_file():
latest = max(latest, path.stat().st_mtime)
for root in roots:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file():
continue
latest = max(latest, path.stat().st_mtime)
return latest
def _read_console_stamp() -> dict[str, object] | None:
if not OUT_STAMP.is_file():
return None
try:
return json.loads(OUT_STAMP.read_text(encoding="utf-8"))
except Exception:
return None
def _console_needs_rebuild() -> bool:
if not OUT_INDEX.is_file():
return True
stamp = _read_console_stamp()
if not stamp:
return True
try:
src_mtime = float(stamp.get("source_mtime") or 0.0)
except Exception:
return True
return _console_sources_mtime() > src_mtime + 0.5
def _write_console_stamp(*, toolchain: str, major: int | None = None) -> None:
try:
OUT_STAMP.parent.mkdir(parents=True, exist_ok=True)
payload = {
"built_at": time.time(),
"source_mtime": _console_sources_mtime(),
"toolchain": toolchain,
"node_major": major,
}
OUT_STAMP.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def _docker_available() -> str | None:
return shutil.which("docker")
def _run_console_cmd(cmd: list[str], *, cwd: Path, env: dict[str, str]) -> int:
return subprocess.call(cmd, cwd=cwd, env=env)
def _npm_cmd(runtime: NodeRuntime | None = None) -> str:
if runtime and runtime.npm is not None:
return str(runtime.npm)
return shutil.which("npm") or "npm"
def _try_local_console_build() -> tuple[bool, str]:
runtime = _pick_node_runtime()
if runtime is None:
return False, "未找到可用 Node.js"
if runtime.major < MIN_WEB_NODE_MAJOR:
return False, f"当前 Node {runtime.major} 过低"
if runtime.npm is None:
return False, f"找到 Node {runtime.node},但未找到同目录 npm"
env = _runtime_env(runtime)
npm = _npm_cmd(runtime)
print(f"[launch] 使用本机 Node {runtime.major} 构建 Web 控制台: {runtime.node}")
rebuild = _console_needs_rebuild()
if not rebuild:
print("[launch] 控制台产物已是最新。")
return True, "up-to-date"
print("[launch] 正在构建 Web 控制台…")
lock = CONSOLE / "package-lock.json"
if lock.is_file():
rc = _run_console_cmd([npm, "ci"], cwd=CONSOLE, env=env)
if rc != 0:
print("[launch] npm ci 失败,尝试 npm install…")
rc = _run_console_cmd([npm, "install"], cwd=CONSOLE, env=env)
if rc != 0:
return False, "npm install 失败"
else:
rc = _run_console_cmd([npm, "install"], cwd=CONSOLE, env=env)
if rc != 0:
return False, "npm install 失败"
rc = _run_console_cmd([npm, "run", "build"], cwd=CONSOLE, env=env)
if rc != 0:
return False, "npm run build 失败"
_write_console_stamp(toolchain="local", major=runtime.major)
print("[launch] 前端构建完成。")
return True, "built"
def _docker_console_build() -> bool:
docker = _docker_available()
if not docker:
return False
print(f"[launch] 本机 Node 不足,改用 Docker 构建: {DOCKER_NODE_IMAGE}")
script = (
"set -e; "
"cd /workspace/web-console; "
"if [ ! -d node_modules ]; then npm ci || npm install; fi; "
"npm run build"
)
cmd = [
docker,
"run",
"--rm",
"-u",
f"{os.getuid()}:{os.getgid()}",
"-v",
f"{ROOT}:/workspace",
"-w",
"/workspace",
DOCKER_NODE_IMAGE,
"sh",
"-lc",
script,
]
rc = subprocess.call(cmd, cwd=ROOT)
if rc != 0:
return False
_write_console_stamp(toolchain="docker", major=20)
print("[launch] Docker 构建完成。")
return True
def ensure_console_built(*, skip: bool, strict: bool = False) -> 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():
if not _console_needs_rebuild():
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:
if OUT_INDEX.is_file():
print(
"[launch] npm run build 失败,但检测到已有 web-console/out/index.html"
"将继续启动 API静态页可能为旧构建请修复构建后重启",
)
return True
ok, reason = _try_local_console_build()
if ok:
return True
if _docker_console_build():
return True
if OUT_INDEX.is_file() and not strict:
print(
"[launch] npm run build 失败且缺少 web-console/out/index.html"
"无法提供控制台。请在 web-console 目录执行 npm run build 或检查 Node/内存",
f"[launch] {reason},但检测到已有 web-console/out/index.html,将继续启动。"
" 如需刷新,请安装 Node 18+ 或 Docker 后重新构建",
)
sys.exit(1)
print("[launch] 前端构建完成。")
return True
return True
print(
f"[launch] {reason},且未能通过 Docker 兜底构建 Web 控制台。"
" 请安装 Node 18+ / pnpm 或启用 Docker。",
)
sys.exit(1)
def _terminate_process(p: subprocess.Popen | None) -> None:
@@ -121,13 +355,79 @@ def _terminate_process(p: subprocess.Popen | None) -> None:
p.wait(timeout=3)
def _console_dev_runtime() -> NodeRuntime | None:
runtime = _pick_node_runtime()
if runtime is None:
return None
if runtime.major < MIN_WEB_NODE_MAJOR or runtime.npm is None:
return None
return runtime
def _start_local_console_dev(*, host: str, port: str, next_port: str) -> subprocess.Popen | None:
runtime = _console_dev_runtime()
if runtime is None:
return None
env = _runtime_env(runtime, {"API_PROXY_TARGET": f"http://127.0.0.1:{port}"})
npm = _npm_cmd(runtime)
node_modules = CONSOLE / "node_modules"
if not node_modules.exists():
lock = CONSOLE / "package-lock.json"
print("[launch] 未检测到 web-console/node_modules先安装依赖…")
install_cmd = [npm, "ci"] if lock.is_file() else [npm, "install"]
if _run_console_cmd(install_cmd, cwd=CONSOLE, env=env) != 0:
print("[launch] 依赖安装失败,准备尝试 Docker 兜底。")
return None
cmd = [npm, "run", "dev", "--", "--hostname", "0.0.0.0", "--port", next_port]
return subprocess.Popen(cmd, cwd=CONSOLE, env=env)
def _start_docker_console_dev(*, host: str, port: str, next_port: str) -> subprocess.Popen | None:
docker = _docker_available()
if not docker:
return None
proxy_target = f"http://host.docker.internal:{port}"
script = (
"set -e; "
"cd /workspace/web-console; "
"if [ ! -d node_modules ]; then npm ci || npm install; fi; "
"npm run dev -- --hostname 0.0.0.0 --port 3000"
)
cmd = [
docker,
"run",
"--rm",
"--add-host",
"host.docker.internal:host-gateway",
"-p",
f"{next_port}:3000",
"-e",
f"API_PROXY_TARGET={proxy_target}",
"-e",
"HOST=0.0.0.0",
"-e",
"PORT=3000",
"-e",
"WATCHPACK_POLLING=true",
"-e",
"CHOKIDAR_USEPOLLING=true",
"-e",
"NEXT_TELEMETRY_DISABLED=1",
"-v",
f"{ROOT}:/workspace",
"-w",
"/workspace",
DOCKER_NODE_IMAGE,
"sh",
"-lc",
script,
]
print(f"[launch] 本机 Node 不足,改用 Docker 开发容器: {DOCKER_NODE_IMAGE}")
return subprocess.Popen(cmd, cwd=ROOT)
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,
@@ -149,11 +449,13 @@ def run_dev(host: str, port: str, next_port: str) -> None:
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,
)
ui = _start_local_console_dev(host=host, port=port, next_port=next_port)
if ui is None:
ui = _start_docker_console_dev(host=host, port=port, next_port=next_port)
if ui is None:
print("[launch] 未找到可用的 Node 18+ 或 Docker无法启动 Next dev。")
print("[launch] 仅起 API 请用: python scripts/launch.py --api-only --reload")
sys.exit(1)
print()
print(" —— 开发模式(局域网可访问)——")
print(f" 控制台界面: http://0.0.0.0:{next_port} 或用本机 IP + 端口")
@@ -205,6 +507,7 @@ def main() -> None:
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("--build-only", action="store_true", help="只构建 web-console不启动 API")
ap.add_argument("--reload", action="store_true", help="uvicorn --reload生产慎用")
ap.add_argument("--no-env-check", action="store_true", help="跳过环境分析")
args = ap.parse_args()
@@ -220,9 +523,7 @@ def main() -> None:
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()
)
need_npm = bool(args.dev or args.build_only or (not args.skip_build and not args.api_only and _console_needs_rebuild()))
if not args.no_env_check:
env_check.print_env_report(
dev=bool(args.dev),
@@ -236,6 +537,10 @@ def main() -> None:
run_dev(host, port, next_port)
return
if args.build_only:
ensure_console_built(skip=False, strict=True)
return
if args.api_only:
run_api_only(host, port, args.reload)
return