From a3e8a4a96ce97f233372910ae97519fdf95ae9ec Mon Sep 17 00:00:00 2001 From: root Date: Sun, 17 May 2026 13:21:44 +0000 Subject: [PATCH] s --- d2ypp2/package.json | 6 +- d2ypp2/scripts/env_check.py | 33 +- d2ypp2/scripts/launch.py | 401 +++++++++++++++--- d2ypp2/web-console/README.md | 39 +- d2ypp2/web-console/app/globals.css | 44 ++ .../console/LiveConsoleWorkspace.tsx | 106 ++++- .../live-product/LiveAndroidStreamConsole.tsx | 17 +- .../live-product/LiveControlApp.tsx | 90 +++- .../live-product/LiveProcessLiveLogCard.tsx | 34 +- d2ypp2/web-console/out/.build-stamp.json | 6 + d2ypp2/web-console/out/404.html | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 .../static/chunks/246-3ed61e76b25802d7.js | 1 + .../static/chunks/246-a5b3d260d1721b03.js | 1 - .../chunks/app/page-5e18bf638b82a736.js | 1 - .../chunks/app/page-e7af0c144ec5a257.js | 1 + ...f3d4117d74443.css => 28e54b8ddc54dfea.css} | 2 +- d2ypp2/web-console/out/android.html | 2 +- d2ypp2/web-console/out/android.txt | 4 +- d2ypp2/web-console/out/console.html | 2 +- d2ypp2/web-console/out/console.txt | 4 +- d2ypp2/web-console/out/index.html | 2 +- d2ypp2/web-console/out/index.txt | 4 +- d2ypp2/web-console/out/shellcrash.html | 2 +- d2ypp2/web-console/out/shellcrash.txt | 4 +- d2ypp2/web-console/package.json | 3 + 27 files changed, 671 insertions(+), 140 deletions(-) create mode 100644 d2ypp2/web-console/out/.build-stamp.json rename d2ypp2/web-console/out/_next/static/{4paJoVoY-KFg9DRarNRLx => -PANSW4kZKRVhenJZX140}/_buildManifest.js (100%) rename d2ypp2/web-console/out/_next/static/{4paJoVoY-KFg9DRarNRLx => -PANSW4kZKRVhenJZX140}/_ssgManifest.js (100%) create mode 100644 d2ypp2/web-console/out/_next/static/chunks/246-3ed61e76b25802d7.js delete mode 100644 d2ypp2/web-console/out/_next/static/chunks/246-a5b3d260d1721b03.js delete mode 100644 d2ypp2/web-console/out/_next/static/chunks/app/page-5e18bf638b82a736.js create mode 100644 d2ypp2/web-console/out/_next/static/chunks/app/page-e7af0c144ec5a257.js rename d2ypp2/web-console/out/_next/static/css/{91df3d4117d74443.css => 28e54b8ddc54dfea.css} (85%) diff --git a/d2ypp2/package.json b/d2ypp2/package.json index 5e7bad0..7c8f1fd 100644 --- a/d2ypp2/package.json +++ b/d2ypp2/package.json @@ -3,10 +3,10 @@ "private": true, "description": "直播推流控制台:一键开发/构建脚本入口(核心逻辑见 scripts/launch.py)", "scripts": { - "dev": "concurrently -k -n api,console -c cyan,magenta \"npm run dev:api\" \"npm run dev:console\"", + "dev": "python scripts/launch.py --dev", "dev:api": "python -m uvicorn web:app --host 0.0.0.0 --port 8001 --reload", - "dev:console": "npm run dev --prefix web-console", - "build": "npm run build --prefix web-console", + "dev:console": "python scripts/launch.py --dev", + "build": "python scripts/launch.py --build-only", "start": "python scripts/launch.py", "start:api": "python scripts/launch.py --api-only", "preview": "python scripts/launch.py --skip-build" diff --git a/d2ypp2/scripts/env_check.py b/d2ypp2/scripts/env_check.py index 0b8080c..e2002f2 100644 --- a/d2ypp2/scripts/env_check.py +++ b/d2ypp2/scripts/env_check.py @@ -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("│ ⚠ 未找到 ffplay(HDMI 全屏播放需要完整 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.js(Next.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):") diff --git a/d2ypp2/scripts/launch.py b/d2ypp2/scripts/launch.py index c39398b..866f678 100644 --- a/d2ypp2/scripts/launch.py +++ b/d2ypp2/scripts/launch.py @@ -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 diff --git a/d2ypp2/web-console/README.md b/d2ypp2/web-console/README.md index e215bc4..de4b2c0 100644 --- a/d2ypp2/web-console/README.md +++ b/d2ypp2/web-console/README.md @@ -1,36 +1,13 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# web-console -## Getting Started - -First, run the development server: +Run from repo root: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +python scripts/launch.py --dev +python scripts/launch.py --build-only ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Compatibility: +- Prefers a local Node 18+ runtime +- Falls back to Docker if local Node is too old +- Writes `out/.build-stamp.json` to avoid rebuilding unchanged sources diff --git a/d2ypp2/web-console/app/globals.css b/d2ypp2/web-console/app/globals.css index 50c1891..17bb422 100644 --- a/d2ypp2/web-console/app/globals.css +++ b/d2ypp2/web-console/app/globals.css @@ -12,6 +12,7 @@ --text: #e8edf7; --muted: #94a3b8; --ring-offset: #060912; + color-scheme: dark; } html[data-theme="light"] { @@ -20,6 +21,11 @@ html[data-theme="light"] { --text: #0f172a; --muted: #64748b; --ring-offset: #f1f5f9; + color-scheme: light; +} + +html { + scroll-behavior: smooth; } body { @@ -27,9 +33,32 @@ body { background: var(--bg-deep); min-height: 100vh; font-family: var(--font-geist-sans), system-ui, sans-serif; + overflow-x: hidden; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; transition: background-color 0.35s ease, color 0.25s ease; } +* { + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.35) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.35); + border-radius: 999px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + .app-bg-layer { position: fixed; inset: 0; @@ -297,3 +326,18 @@ html[data-theme="light"] .chart-surface { .lp-noise { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx b/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx index de068f0..8f224e5 100644 --- a/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx +++ b/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import OverviewCharts from "@/components/OverviewCharts"; import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel"; @@ -167,6 +167,41 @@ function parseResolution(resolution?: string) { return { width: Number(match[1]), height: Number(match[2]) }; } +function clientPointToImagePixel( + img: HTMLImageElement, + clientX: number, + clientY: number, +): { x: number; y: number } | null { + const rect = img.getBoundingClientRect(); + const nw = img.naturalWidth; + const nh = img.naturalHeight; + if (!nw || !nh || !rect.width || !rect.height) return null; + const elAR = rect.width / rect.height; + const imAR = nw / nh; + let drawW: number; + let drawH: number; + let offX: number; + let offY: number; + if (imAR > elAR) { + drawW = rect.width; + drawH = rect.width / imAR; + offX = 0; + offY = (rect.height - drawH) / 2; + } else { + drawH = rect.height; + drawW = rect.height * imAR; + offX = (rect.width - drawW) / 2; + offY = 0; + } + const lx = clientX - rect.left - offX; + const ly = clientY - rect.top - offY; + if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null; + return { + x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))), + y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))), + }; +} + function Button({ children, className, @@ -212,6 +247,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: const [error, setError] = useState(null); const [toast, setToast] = useState(null); const [busy, setBusy] = useState(null); + const [pageVisible, setPageVisible] = useState(true); + const toastTimerRef = useRef(null); const [entries, setEntries] = useState([]); const [currentProcess, setCurrentProcess] = useState(""); @@ -280,7 +317,13 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: const notify = useCallback((message: string) => { setToast(message); - window.setTimeout(() => setToast(null), 2500); + if (toastTimerRef.current != null) { + window.clearTimeout(toastTimerRef.current); + } + toastTimerRef.current = window.setTimeout(() => { + setToast(null); + toastTimerRef.current = null; + }, 2500); }, []); const fetchJson = useCallback( @@ -481,6 +524,22 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: window.localStorage.setItem("live-console-theme", theme); }, [theme, embeddedShellCrash]); + useEffect(() => { + const onVis = () => setPageVisible(document.visibilityState === "visible"); + onVis(); + document.addEventListener("visibilitychange", onVis); + return () => document.removeEventListener("visibilitychange", onVis); + }, []); + + useEffect( + () => () => { + if (toastTimerRef.current != null) { + window.clearTimeout(toastTimerRef.current); + } + }, + [], + ); + useEffect(() => { if (!embeddedShellCrash) return; if (portalLang === "zh" || portalLang === "en") setLang(portalLang); @@ -496,19 +555,19 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: }, [boot]); useEffect(() => { - if (!currentProcess) return; + if (!currentProcess || !pageVisible) return; void refreshProcess(); const timer = window.setInterval(() => void refreshProcess(), 1500); return () => window.clearInterval(timer); - }, [currentProcess, refreshProcess]); + }, [currentProcess, pageVisible, refreshProcess]); useEffect(() => { - if (booting || error) return; + if (booting || error || !pageVisible) return; if (tab !== "overview" && tab !== "modules" && tab !== "shellcrash") return; void refreshServices(); const timer = window.setInterval(() => void refreshServices(), 10000); return () => clearInterval(timer); - }, [booting, error, refreshServices, tab]); + }, [booting, error, pageVisible, refreshServices, tab]); useEffect(() => { const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots; @@ -557,13 +616,15 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: }, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]); useEffect(() => { - if (tab !== "android" || !androidSerial) return; + if (tab !== "android" || !androidSerial || !pageVisible) return; + void refreshAndroidOverview(androidSerial); + setShotNonce(Date.now()); const timer = window.setInterval(() => { void refreshAndroidOverview(androidSerial); setShotNonce(Date.now()); }, 5000); return () => window.clearInterval(timer); - }, [androidSerial, refreshAndroidOverview, tab]); + }, [androidSerial, pageVisible, refreshAndroidOverview, tab]); useEffect(() => { if (!androidSerial) { @@ -773,17 +834,17 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); return; } - const rect = event.currentTarget.getBoundingClientRect(); - const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth || rect.width; - const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight || rect.height; - const x = Math.max( - 0, - Math.min(sourceWidth, Math.round((event.clientX - rect.left) * (sourceWidth / rect.width))), - ); - const y = Math.max( - 0, - Math.min(sourceHeight, Math.round((event.clientY - rect.top) * (sourceHeight / rect.height))), - ); + const point = clientPointToImagePixel(event.currentTarget, event.clientX, event.clientY); + if (!point) { + notify(tr("请点击画面有效区域", "Tap inside the visible frame")); + return; + } + const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth; + const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight; + const scaleX = sourceWidth > 0 ? sourceWidth / Math.max(1, event.currentTarget.naturalWidth) : 1; + const scaleY = sourceHeight > 0 ? sourceHeight / Math.max(1, event.currentTarget.naturalHeight) : 1; + const x = Math.max(0, Math.min(sourceWidth || point.x, Math.round(point.x * scaleX))); + const y = Math.max(0, Math.min(sourceHeight || point.y, Math.round(point.y * scaleY))); setPreviewPoint({ x, y }); if (previewMode === "long_press") { await sendAndroidAction("long_press", { x, y, duration: 750 }); @@ -843,7 +904,12 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: } > {toast ? ( -
+
{toast}
) : null} diff --git a/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx b/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx index cbcf2af..9117f6c 100644 --- a/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx +++ b/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx @@ -187,6 +187,7 @@ export function LiveAndroidStreamConsole({ server_version?: string; hint_zh?: string; } | null>(null); + const [pageVisible, setPageVisible] = useState(true); const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null); const imgRef = useRef(null); const canvasRef = useRef(null); @@ -206,6 +207,13 @@ export function LiveAndroidStreamConsole({ setFrameTs((n) => n + 1); }, []); + useEffect(() => { + const onVis = () => setPageVisible(document.visibilityState === "visible"); + onVis(); + document.addEventListener("visibilitychange", onVis); + return () => document.removeEventListener("visibilitychange", onVis); + }, []); + useEffect(() => { try { const raw = window.localStorage.getItem(BOOKMARKS_KEY); @@ -256,10 +264,15 @@ export function LiveAndroidStreamConsole({ }, [apiBase, adbAvailable, serial]); useEffect(() => { - if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return; + if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream || !pageVisible) return; const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs); return () => clearInterval(id); - }, [adbAvailable, liveMs, serial, scrcpyStream]); + }, [adbAvailable, liveMs, pageVisible, serial, scrcpyStream]); + + useEffect(() => { + if (!pageVisible || !adbAvailable || !serial || scrcpyStream) return; + bumpFrame(); + }, [adbAvailable, bumpFrame, pageVisible, serial, scrcpyStream]); useEffect(() => { if (!adbAvailable) { diff --git a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx index 19b1779..f5bd612 100644 --- a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx +++ b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx @@ -167,7 +167,8 @@ function formatUptime(sec?: number) { } function formatBytes(value?: number) { - if (!value) return "—"; + if (value == null || !Number.isFinite(value)) return "—"; + if (value === 0) return "0 B"; const u = ["B", "KB", "MB", "GB", "TB"]; let n = value; let i = 0; @@ -212,6 +213,8 @@ export default function LiveControlApp() { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [toast, setToast] = useState(null); + const [lastSyncedAt, setLastSyncedAt] = useState(null); + const [pageVisible, setPageVisible] = useState(true); const [entries, setEntries] = useState([]); const [processRows, setProcessRows] = useState([]); @@ -226,12 +229,19 @@ export default function LiveControlApp() { const processMonitorInFlightRef = useRef | null>(null); const currentProcRef = useRef(currentProc); const urlConfigRequestSeqRef = useRef(0); + const toastTimerRef = useRef(null); const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]); const notify = useCallback((msg: string) => { setToast(msg); - window.setTimeout(() => setToast(null), 2800); + if (toastTimerRef.current != null) { + window.clearTimeout(toastTimerRef.current); + } + toastTimerRef.current = window.setTimeout(() => { + setToast(null); + toastTimerRef.current = null; + }, 2800); }, []); const fetchJson = useCallback( @@ -248,6 +258,7 @@ export default function LiveControlApp() { const d = await fetchJson("/hub/dashboard"); setDash(d); setDashErr(null); + setLastSyncedAt(Date.now()); const devs = d.android?.devices || []; if (devs.length && !androidSerial) { const ready = devs.find((x) => (x.state || "").toLowerCase() === "device"); @@ -279,6 +290,7 @@ export default function LiveControlApp() { const list = data.entries || []; setProcessRows(list); setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label }))); + setLastSyncedAt(Date.now()); setCurrentProc((prev) => { if (!list.length) return prev; if (prev && list.some((row) => row.pm2 === prev)) return prev; @@ -314,6 +326,36 @@ export default function LiveControlApp() { } }, [portalTheme]); + useEffect(() => { + const onVis = () => setPageVisible(document.visibilityState === "visible"); + onVis(); + document.addEventListener("visibilitychange", onVis); + return () => document.removeEventListener("visibilitychange", onVis); + }, []); + + useEffect(() => { + if (!mobileNav) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMobileNav(false); + }; + window.addEventListener("keydown", onKeyDown); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prev; + }; + }, [mobileNav]); + + useEffect( + () => () => { + if (toastTimerRef.current != null) { + window.clearTimeout(toastTimerRef.current); + } + }, + [], + ); + useEffect(() => { try { if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false); @@ -332,20 +374,20 @@ export default function LiveControlApp() { }, [refreshProcessMonitor]); useEffect(() => { - if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return; + if (!autoRefreshEnabled || !pageVisible || view === "live_youtube" || view === "live_tiktok") return; const id = window.setInterval(() => { void refreshDashboard(); }, 10000); return () => clearInterval(id); - }, [autoRefreshEnabled, refreshDashboard, view]); + }, [autoRefreshEnabled, pageVisible, refreshDashboard, view]); useEffect(() => { - if (!autoRefreshEnabled) return; + if (!autoRefreshEnabled || !pageVisible) return; const id = window.setInterval(() => { void refreshProcessMonitor(); }, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000); return () => clearInterval(id); - }, [autoRefreshEnabled, refreshProcessMonitor, view]); + }, [autoRefreshEnabled, pageVisible, refreshProcessMonitor, view]); useEffect(() => { currentProcRef.current = currentProc; @@ -497,7 +539,22 @@ export default function LiveControlApp() { ]; const copyText = (text: string) => { - void navigator.clipboard.writeText(text).then( + const write = async () => { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const ta = document.createElement("textarea"); + ta.value = text; + ta.readOnly = true; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + }; + void write().then( () => notify(t("已复制", "Copied")), () => notify(t("复制失败", "Copy failed")), ); @@ -506,6 +563,10 @@ export default function LiveControlApp() { const onlineServices = dash?.services?.filter((s) => s.running && s.available).length ?? 0; const totalServices = dash?.services?.length ?? 0; + const lastSyncLabel = lastSyncedAt + ? new Date(lastSyncedAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour12: false }) + : t("未同步", "Not synced"); + const refreshStateLabel = pageVisible ? t("前台自动刷新", "Auto refresh") : t("后台已暂停刷新", "Refresh paused in background"); return (
{toast} @@ -571,6 +635,7 @@ export default function LiveControlApp() { key={item.id} type="button" onClick={() => setView(item.id)} + aria-current={active ? "page" : undefined} className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition ${ portalTheme === "light" ? active @@ -633,7 +698,8 @@ export default function LiveControlApp() {

{dash?.stack?.mdns_host || "live.local"} ·{" "} - {portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {t("自动刷新", "Auto refresh")} + {portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {refreshStateLabel} ·{" "} + {t("最后同步", "Last sync")} {lastSyncLabel}

@@ -1532,6 +1598,7 @@ export default function LiveControlApp() {