's'
This commit is contained in:
@@ -59,6 +59,22 @@ compose_run() {
|
||||
fi
|
||||
}
|
||||
|
||||
compose_in_service_dir() {
|
||||
local compose_file="$1"
|
||||
shift
|
||||
local d base
|
||||
d="$(cd "$(dirname "$compose_file")" && pwd)"
|
||||
base="$(basename "$compose_file")"
|
||||
(
|
||||
cd "$d" || exit 1
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose -f "$base" "$@"
|
||||
else
|
||||
docker-compose -f "$base" "$@"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
as_live() {
|
||||
runuser -u live -- bash -lc "cd '$ROOT_DIR' && $*"
|
||||
}
|
||||
@@ -435,10 +451,7 @@ install_neko_browser() {
|
||||
export NEKO_WEBRTC_NAT1TO1
|
||||
fi
|
||||
export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}"
|
||||
(
|
||||
cd "$neko_dir" || exit 1
|
||||
compose_run "$neko_dir/docker-compose.yml" up -d
|
||||
)
|
||||
compose_in_service_dir "$neko_dir/docker-compose.yml" up -d
|
||||
}
|
||||
|
||||
configure_adb_udev() {
|
||||
@@ -642,7 +655,7 @@ prepare_homepage() {
|
||||
fi
|
||||
log "Preparing Homepage"
|
||||
render_homepage_services
|
||||
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d
|
||||
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d
|
||||
}
|
||||
|
||||
prepare_filebrowser() {
|
||||
@@ -660,7 +673,7 @@ prepare_filebrowser() {
|
||||
rm -f "$fb_db"
|
||||
touch "$fb_db"
|
||||
chmod -R 0777 "$ROOT_DIR/services/filebrowser/database" "$ROOT_DIR/services/filebrowser/config"
|
||||
compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true
|
||||
compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true
|
||||
|
||||
docker run --rm \
|
||||
-v "$fb_db_dir:/database" \
|
||||
@@ -736,7 +749,7 @@ PY
|
||||
rm -f "$fb_cfg_dir/users.backup.json"
|
||||
rm -f "$fb_tmp_json"
|
||||
|
||||
compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d
|
||||
compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d
|
||||
}
|
||||
|
||||
prepare_srs() {
|
||||
@@ -745,7 +758,7 @@ prepare_srs() {
|
||||
fi
|
||||
log "Starting SRS"
|
||||
mkdir -p "$ROOT_DIR/services/srs/data"
|
||||
compose_run "$ROOT_DIR/services/srs/docker-compose.yml" up -d
|
||||
compose_in_service_dir "$ROOT_DIR/services/srs/docker-compose.yml" up -d
|
||||
}
|
||||
|
||||
run_hardware_probe() {
|
||||
@@ -775,9 +788,9 @@ install_live_edge_proxy() {
|
||||
if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then
|
||||
sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true
|
||||
fi
|
||||
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true
|
||||
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true
|
||||
render_homepage_services
|
||||
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true
|
||||
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -88,6 +88,24 @@ compose_run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# docker-compose v1 将 ./卷 解析为当前工作目录,必须在 compose 文件所在目录执行(与 Neko 一致)
|
||||
compose_in_service_dir() {
|
||||
local compose_file="$1"
|
||||
shift
|
||||
local d base
|
||||
d="$(cd "$(dirname "$compose_file")" && pwd)"
|
||||
base="$(basename "$compose_file")"
|
||||
(
|
||||
cd "$d" || exit 1
|
||||
cmd="$(compose_bin)" || exit 1
|
||||
if [ "$cmd" = "docker compose" ]; then
|
||||
docker compose -f "$base" "$@"
|
||||
else
|
||||
docker-compose -f "$base" "$@"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
docker_container_status() {
|
||||
local name="$1"
|
||||
if ! have docker; then
|
||||
@@ -227,12 +245,12 @@ service_srs_status() {
|
||||
}
|
||||
|
||||
service_srs_start() {
|
||||
compose_run "$SRS_COMPOSE" up -d
|
||||
compose_in_service_dir "$SRS_COMPOSE" up -d
|
||||
kv message "SRS started"
|
||||
}
|
||||
|
||||
service_srs_stop() {
|
||||
compose_run "$SRS_COMPOSE" down
|
||||
compose_in_service_dir "$SRS_COMPOSE" down
|
||||
kv message "SRS stopped"
|
||||
}
|
||||
|
||||
@@ -310,12 +328,12 @@ service_filebrowser_status() {
|
||||
}
|
||||
|
||||
service_filebrowser_start() {
|
||||
compose_run "$FILEBROWSER_COMPOSE" up -d
|
||||
compose_in_service_dir "$FILEBROWSER_COMPOSE" up -d
|
||||
kv message "File Browser started"
|
||||
}
|
||||
|
||||
service_filebrowser_stop() {
|
||||
compose_run "$FILEBROWSER_COMPOSE" down
|
||||
compose_in_service_dir "$FILEBROWSER_COMPOSE" down
|
||||
kv message "File Browser stopped"
|
||||
}
|
||||
|
||||
@@ -339,12 +357,12 @@ service_homepage_status() {
|
||||
}
|
||||
|
||||
service_homepage_start() {
|
||||
compose_run "$HOMEPAGE_COMPOSE" up -d
|
||||
compose_in_service_dir "$HOMEPAGE_COMPOSE" up -d
|
||||
kv message "Homepage started"
|
||||
}
|
||||
|
||||
service_homepage_stop() {
|
||||
compose_run "$HOMEPAGE_COMPOSE" down
|
||||
compose_in_service_dir "$HOMEPAGE_COMPOSE" down
|
||||
kv message "Homepage stopped"
|
||||
}
|
||||
|
||||
@@ -451,7 +469,12 @@ service_chromium_status() {
|
||||
}
|
||||
|
||||
service_chromium_start() {
|
||||
nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
|
||||
if [ -z "${DISPLAY:-}" ] && command -v xvfb-run >/dev/null 2>&1; then
|
||||
nohup xvfb-run -a "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
|
||||
else
|
||||
[ -z "${DISPLAY:-}" ] && export DISPLAY=:0
|
||||
nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
|
||||
fi
|
||||
disown || true
|
||||
kv message "Chromium launched"
|
||||
}
|
||||
@@ -503,8 +526,6 @@ service_neko_start() {
|
||||
kv message "Neko compose missing"
|
||||
return 0
|
||||
fi
|
||||
local neko_dir
|
||||
neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)"
|
||||
local prof="${NEKO_PROFILE_HOST_DIR:-$ROOT_DIR/services/neko/data/chromium-profile}"
|
||||
mkdir -p "$prof"
|
||||
chmod 777 "$prof" 2>/dev/null || true
|
||||
@@ -520,18 +541,7 @@ service_neko_start() {
|
||||
export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
fi
|
||||
export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}"
|
||||
cmd="$(compose_bin)" || {
|
||||
kv message "docker compose unavailable"
|
||||
return 0
|
||||
}
|
||||
(
|
||||
cd "$neko_dir" || exit 1
|
||||
if [ "$cmd" = "docker compose" ]; then
|
||||
docker compose -f docker-compose.yml up -d
|
||||
else
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
fi
|
||||
)
|
||||
compose_in_service_dir "$NEKO_COMPOSE" up -d
|
||||
kv message "Neko stack started"
|
||||
}
|
||||
|
||||
@@ -540,20 +550,7 @@ service_neko_stop() {
|
||||
kv message "Neko compose missing"
|
||||
return 0
|
||||
fi
|
||||
local neko_dir
|
||||
neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)"
|
||||
cmd="$(compose_bin)" || {
|
||||
kv message "docker compose unavailable"
|
||||
return 0
|
||||
}
|
||||
(
|
||||
cd "$neko_dir" || exit 1
|
||||
if [ "$cmd" = "docker compose" ]; then
|
||||
docker compose -f docker-compose.yml down
|
||||
else
|
||||
docker-compose -f docker-compose.yml down
|
||||
fi
|
||||
)
|
||||
compose_in_service_dir "$NEKO_COMPOSE" down
|
||||
kv message "Neko stack stopped"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 变量由 system-stack.env 或启动前 export(勿使用 ${VAR:-default},旧版 docker-compose 会报错)
|
||||
# WebRTC:NEKO_WEBRTC_NAT1TO1 填板子局域网 IP
|
||||
version: "3.8"
|
||||
version: "3.3"
|
||||
services:
|
||||
neko:
|
||||
image: ghcr.io/m1k1o/neko/chromium:latest
|
||||
|
||||
@@ -299,6 +299,14 @@ def _is_png(data: bytes) -> bool:
|
||||
return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
|
||||
|
||||
async def android_logcat_recent(serial: str, lines: int = 200) -> str:
|
||||
"""Dump recent logcat lines (logcat -d -t N), similar to device-console tooling."""
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
n = max(1, min(int(lines), 3000))
|
||||
return await _shell(serial, f"logcat -d -t {n}", timeout=60.0)
|
||||
|
||||
|
||||
async def android_screenshot(serial: str) -> bytes:
|
||||
if not serial:
|
||||
raise ValueError("Missing Android device serial")
|
||||
|
||||
@@ -8,9 +8,11 @@ import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from typing import Any, Iterable
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -454,6 +456,65 @@ def load_stack_env() -> dict[str, str]:
|
||||
return values
|
||||
|
||||
|
||||
def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
ms = (time.monotonic() - t0) * 1000
|
||||
return {"ok": True, "reachable": True, "latency_ms": round(ms, 2), "error": ""}
|
||||
except OSError as exc:
|
||||
ms = (time.monotonic() - t0) * 1000
|
||||
return {"ok": False, "reachable": False, "latency_ms": round(ms, 2), "error": str(exc)}
|
||||
|
||||
|
||||
def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with urlopen(url, timeout=timeout) as resp:
|
||||
code = int(resp.getcode() or 0)
|
||||
ms = (time.monotonic() - t0) * 1000
|
||||
return {
|
||||
"ok": True,
|
||||
"http_code": code,
|
||||
"latency_ms": round(ms, 2),
|
||||
"reachable": True,
|
||||
"error": "",
|
||||
}
|
||||
except (URLError, OSError, ValueError) as exc:
|
||||
ms = (time.monotonic() - t0) * 1000
|
||||
return {
|
||||
"ok": False,
|
||||
"http_code": 0,
|
||||
"latency_ms": round(ms, 2),
|
||||
"reachable": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
async def stack_stream_probes() -> dict[str, Any]:
|
||||
"""本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。"""
|
||||
env = load_stack_env()
|
||||
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
|
||||
neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def run_srs() -> dict[str, Any]:
|
||||
tcp = _probe_tcp_port("127.0.0.1", srs_p)
|
||||
http = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5)
|
||||
return {"port": srs_p, "tcp": tcp, "http": http}
|
||||
|
||||
def run_neko() -> dict[str, Any]:
|
||||
tcp = _probe_tcp_port("127.0.0.1", neko_p)
|
||||
http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5)
|
||||
return {"port": neko_p, "tcp": tcp, "http": http}
|
||||
|
||||
srs_d, neko_d = await asyncio.gather(
|
||||
loop.run_in_executor(None, run_srs),
|
||||
loop.run_in_executor(None, run_neko),
|
||||
)
|
||||
return {"srs": srs_d, "neko": neko_d}
|
||||
|
||||
|
||||
def stack_summary() -> dict:
|
||||
env = load_stack_env()
|
||||
hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname()
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.android_control import adb_available, list_android_devices
|
||||
from src.control_plane import query_services, stack_summary, system_snapshot
|
||||
from src.control_plane import query_services, stack_stream_probes, stack_summary, system_snapshot
|
||||
from src.live_config import business_config, load_hub_config_snapshot, services_config_list
|
||||
from src.web_process_backend import WebProcessBackend
|
||||
|
||||
@@ -38,6 +38,7 @@ async def hub_dashboard() -> dict[str, Any]:
|
||||
else:
|
||||
devices = []
|
||||
stack = stack_summary()
|
||||
probes = await stack_stream_probes()
|
||||
|
||||
live_status: dict[str, Any] = {
|
||||
"processes": [],
|
||||
@@ -68,6 +69,7 @@ async def hub_dashboard() -> dict[str, Any]:
|
||||
return {
|
||||
"snapshot": snap,
|
||||
"stack": stack,
|
||||
"probes": probes,
|
||||
"services": services,
|
||||
"android": {
|
||||
"adb_available": adb_available(),
|
||||
|
||||
@@ -53,7 +53,12 @@ FILES = [
|
||||
"web-console/middleware.ts",
|
||||
"web-console/components/OverviewCharts.tsx",
|
||||
"web-console/components/live-product/LiveControlApp.tsx",
|
||||
"web-console/components/live-product/LiveAndroidWorkstation.tsx",
|
||||
"web-console/components/live-product/LiveYoutubeUnmannedView.tsx",
|
||||
"web-console/components/live-product/LiveTiktokHdmiView.tsx",
|
||||
"web-console/lib/panelUrls.ts",
|
||||
"web-console/lib/youtubeProChannels.ts",
|
||||
"web-console/lib/youtubeConfigUtils.ts",
|
||||
"web-console/components/console/LiveConsoleWorkspace.tsx",
|
||||
]
|
||||
|
||||
@@ -224,12 +229,18 @@ def main() -> int:
|
||||
_safe_print("远程 py_compile 失败,见日志")
|
||||
return 3
|
||||
|
||||
_log_append(
|
||||
log_path,
|
||||
"\n[提示] web_console_build 在远端执行 npm + next build,ARM 上常需 5~20 分钟;"
|
||||
"未完成前日志不会出现 exit=,属正常现象。\n",
|
||||
)
|
||||
_safe_print("远端正在构建 web-console(请耐心等待,ARM 可能较久)…")
|
||||
build_cmd = (
|
||||
f"cd {REMOTE_BASE}/web-console && "
|
||||
"(npm ci 2>/dev/null || npm install) && "
|
||||
"(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)"
|
||||
)
|
||||
code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900)
|
||||
code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=2400)
|
||||
if code_b != 0:
|
||||
_safe_print("[警告] web-console build 非 0(可能未装 Node/pnpm),完整输出见日志")
|
||||
|
||||
|
||||
597
web-console/components/live-product/LiveAndroidWorkstation.tsx
Normal file
597
web-console/components/live-product/LiveAndroidWorkstation.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
type AndroidOverview = {
|
||||
serial?: string;
|
||||
model?: string;
|
||||
brand?: string;
|
||||
android_version?: string;
|
||||
sdk?: string;
|
||||
battery?: string;
|
||||
focus?: string;
|
||||
resolution?: string;
|
||||
rotation?: string;
|
||||
screen_state?: string;
|
||||
};
|
||||
|
||||
type PkgRow = { package: string; label?: string };
|
||||
type UiNode = {
|
||||
label: string;
|
||||
text?: string;
|
||||
content_desc?: string;
|
||||
resource_id?: string;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
clickable?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
notify: (msg: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
|
||||
busy: string | null;
|
||||
androidSerial: string;
|
||||
setAndroidSerial: (s: string) => void;
|
||||
devices: DeviceRow[];
|
||||
adbAvailable: boolean;
|
||||
scrcpyUrl: string;
|
||||
androidShotUrl: string | null;
|
||||
androidShotErr: string | null;
|
||||
apkPath: string;
|
||||
setApkPath: (v: string) => void;
|
||||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
export function LiveAndroidWorkstation({
|
||||
t,
|
||||
notify,
|
||||
fetchJson,
|
||||
busy,
|
||||
androidSerial,
|
||||
setAndroidSerial,
|
||||
devices,
|
||||
adbAvailable,
|
||||
scrcpyUrl,
|
||||
androidShotUrl,
|
||||
androidShotErr,
|
||||
apkPath,
|
||||
setApkPath,
|
||||
onAndroidAction,
|
||||
}: Props) {
|
||||
const [overview, setOverview] = useState<AndroidOverview | null>(null);
|
||||
const [overviewErr, setOverviewErr] = useState<string | null>(null);
|
||||
const [pkgQuery, setPkgQuery] = useState("");
|
||||
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
|
||||
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
|
||||
const [shellOut, setShellOut] = useState("");
|
||||
const [logcatLines, setLogcatLines] = useState("300");
|
||||
const [logcatText, setLogcatText] = useState("");
|
||||
const [logcatErr, setLogcatErr] = useState<string | null>(null);
|
||||
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
|
||||
const [uiErr, setUiErr] = useState<string | null>(null);
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!androidSerial || !adbAvailable) {
|
||||
setOverview(null);
|
||||
setOverviewErr(null);
|
||||
return;
|
||||
}
|
||||
setPending("overview");
|
||||
setOverviewErr(null);
|
||||
try {
|
||||
const o = await fetchJson<AndroidOverview>(
|
||||
`/android/overview?serial=${encodeURIComponent(androidSerial)}`,
|
||||
);
|
||||
setOverview(o);
|
||||
} catch (e) {
|
||||
setOverview(null);
|
||||
setOverviewErr((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}, [adbAvailable, androidSerial, fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
const searchPackages = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
setPending("packages");
|
||||
try {
|
||||
const q = encodeURIComponent(pkgQuery.trim());
|
||||
const data = await fetchJson<{ packages: PkgRow[] }>(
|
||||
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
|
||||
);
|
||||
setPkgList(data.packages || []);
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
setPkgList([]);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runShell = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
const cmd = shellCmd.trim();
|
||||
if (!cmd) return;
|
||||
setPending("shell");
|
||||
try {
|
||||
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
|
||||
});
|
||||
setShellOut(data.output ?? data.message ?? "");
|
||||
} catch (e) {
|
||||
setShellOut((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLogcat = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
|
||||
setPending("logcat");
|
||||
setLogcatErr(null);
|
||||
try {
|
||||
const data = await fetchJson<{ text?: string }>(
|
||||
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
|
||||
);
|
||||
setLogcatText(data.text ?? "");
|
||||
} catch (e) {
|
||||
setLogcatText("");
|
||||
setLogcatErr((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUiNodes = async () => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
setPending("ui");
|
||||
setUiErr(null);
|
||||
try {
|
||||
const data = await fetchJson<{ nodes: UiNode[] }>(
|
||||
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
|
||||
);
|
||||
setUiNodes(data.nodes || []);
|
||||
} catch (e) {
|
||||
setUiNodes([]);
|
||||
setUiErr((e as Error).message);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const tapNode = async (x: number, y: number) => {
|
||||
await onAndroidAction("tap", { x, y });
|
||||
};
|
||||
|
||||
const sendKey = async (keycode: string) => {
|
||||
await onAndroidAction("key", { keycode });
|
||||
};
|
||||
|
||||
const openUrlGo = async () => {
|
||||
const u = openUrl.trim();
|
||||
if (!u || u === "https://") {
|
||||
notify(t("填写 URL", "Enter URL"));
|
||||
return;
|
||||
}
|
||||
await onAndroidAction("open_url", { url: u });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-white">{t("设备与工作台", "Device workstation")}</h3>
|
||||
{!adbAvailable ? (
|
||||
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !androidSerial}
|
||||
onClick={() => void loadOverview()}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
|
||||
>
|
||||
{t("刷新设备信息", "Refresh device info")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{devices.map((d) => (
|
||||
<button
|
||||
key={d.serial}
|
||||
type="button"
|
||||
onClick={() => setAndroidSerial(d.serial)}
|
||||
className={`rounded-xl border px-4 py-2 text-left text-xs ${
|
||||
androidSerial === d.serial
|
||||
? "border-violet-500/50 bg-violet-500/10 text-white"
|
||||
: "border-white/10 bg-black/30 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
|
||||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
|
||||
{t("设备信息", "Device info")}
|
||||
</h4>
|
||||
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
|
||||
{overview ? (
|
||||
<dl className="mt-3 space-y-1.5 font-mono text-[11px] text-zinc-400">
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">model</dt>{" "}
|
||||
<dd className="inline text-zinc-300">
|
||||
{overview.brand} {overview.model}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">Android</dt>{" "}
|
||||
<dd className="inline text-zinc-300">
|
||||
{overview.android_version} (sdk {overview.sdk})
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">display</dt>{" "}
|
||||
<dd className="inline text-zinc-300">{overview.resolution}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline text-zinc-600">rotation</dt>{" "}
|
||||
<dd className="inline text-zinc-300">{overview.rotation}</dd>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">
|
||||
{overview.battery}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
|
||||
</dl>
|
||||
) : !overviewErr ? (
|
||||
<p className="mt-2 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
|
||||
{t("快捷控制", "Quick controls")}
|
||||
</h4>
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
{t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("3")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("4")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("187")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Recents
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("wake")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Wake
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })}
|
||||
className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10"
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
|
||||
value={openUrl}
|
||||
onChange={(e) => setOpenUrl(e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void openUrlGo()}
|
||||
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
|
||||
>
|
||||
{t("打开链接", "Open URL")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Package className="h-4 w-4 text-emerald-400" />
|
||||
{t("包名检索与启动", "Packages")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("过滤 pm list packages;行内可启动或强停。", "Filter pm list packages; launch or force-stop.")}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
|
||||
value={pkgQuery}
|
||||
onChange={(e) => setPkgQuery(e.target.value)}
|
||||
placeholder="tiktok / musically / …"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void searchPackages()}
|
||||
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("搜索", "Search")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
|
||||
{pkgList.map((p) => (
|
||||
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
|
||||
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("launch_package", { package: p.package })}
|
||||
className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200"
|
||||
>
|
||||
{t("启动", "Launch")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("force_stop", { package: p.package })}
|
||||
className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200"
|
||||
>
|
||||
{t("强停", "Stop")}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!pkgList.length ? (
|
||||
<li className="py-2 text-center text-zinc-600">{t("暂无结果,先搜索", "No results")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Terminal className="h-4 w-4 text-amber-400" />
|
||||
{t("交互式 Shell(只读安全子集由你输入决定)", "ADB shell")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("等同 adb shell;勿执行不可逆命令。", "Same as adb shell; avoid destructive commands.")}
|
||||
</p>
|
||||
<textarea
|
||||
className="mt-3 min-h-[72px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
|
||||
value={shellCmd}
|
||||
onChange={(e) => setShellCmd(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runShell()}
|
||||
className="mt-2 rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
|
||||
>
|
||||
{pending === "shell" ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Run
|
||||
</span>
|
||||
) : (
|
||||
"Run"
|
||||
)}
|
||||
</button>
|
||||
{shellOut ? (
|
||||
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400">
|
||||
{shellOut}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("Logcat(最近行)", "Logcat (recent)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("执行 logcat -d -t N,适合快速排障。", "Runs logcat -d -t N for quick triage.")}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
|
||||
value={logcatLines}
|
||||
onChange={(e) => setLogcatLines(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void refreshLogcat()}
|
||||
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("拉取日志", "Pull logs")}
|
||||
</button>
|
||||
</div>
|
||||
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
|
||||
{logcatText ? (
|
||||
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500">
|
||||
{logcatText}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Layers className="h-4 w-4 text-fuchsia-400" />
|
||||
{t("界面节点(uiautomator dump)", "UI nodes")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("列出可交互节点,点击坐标模拟触控。", "Clickable nodes from UI dump; tap by coordinates.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void loadUiNodes()}
|
||||
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
|
||||
>
|
||||
{t("刷新节点树", "Refresh nodes")}
|
||||
</button>
|
||||
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
|
||||
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
|
||||
{uiNodes.map((n, i) => (
|
||||
<li
|
||||
key={`${n.center_x}-${n.center_y}-${i}`}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-zinc-200">{n.label}</span>
|
||||
{n.resource_id ? (
|
||||
<span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span>
|
||||
) : null}
|
||||
<div className="font-mono text-[10px] text-zinc-600">
|
||||
({n.center_x}, {n.center_y}){n.clickable ? " · click" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void tapNode(n.center_x, n.center_y)}
|
||||
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
|
||||
>
|
||||
Tap
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!uiNodes.length && !uiErr ? (
|
||||
<li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("安装 APK(设备上的路径)", "Install APK (path on device)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
|
||||
</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={apkPath}
|
||||
onChange={(e) => setApkPath(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !adbAvailable || !androidSerial}
|
||||
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
|
||||
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
pm install
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("实时画面(ADB 截图)", "Live screen (ADB)")}</h3>
|
||||
{scrcpyUrl ? (
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
|
||||
>
|
||||
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
|
||||
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
|
||||
)}
|
||||
</p>
|
||||
{!adbAvailable ? (
|
||||
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
|
||||
) : null}
|
||||
{androidShotErr && adbAvailable ? <p className="mt-3 text-sm text-rose-300">{androidShotErr}</p> : null}
|
||||
{androidShotUrl ? (
|
||||
<img
|
||||
alt="Android live"
|
||||
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
|
||||
src={androidShotUrl}
|
||||
/>
|
||||
) : null}
|
||||
{adbAvailable && !androidShotUrl && !androidShotErr ? (
|
||||
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{scrcpyUrl ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
|
||||
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
|
||||
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
|
||||
{t("新窗口打开", "Open tab")}
|
||||
</a>
|
||||
</div>
|
||||
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Menu,
|
||||
MonitorPlay,
|
||||
Network,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -22,10 +23,20 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import OverviewCharts from "@/components/OverviewCharts";
|
||||
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
|
||||
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
|
||||
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
type ViewKey = "dashboard" | "services" | "live" | "android" | "network" | "settings";
|
||||
type ViewKey =
|
||||
| "dashboard"
|
||||
| "services"
|
||||
| "live_youtube"
|
||||
| "live_tiktok"
|
||||
| "android"
|
||||
| "network"
|
||||
| "settings";
|
||||
|
||||
const apiBase = () =>
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
|
||||
@@ -66,6 +77,18 @@ type HubDashboard = {
|
||||
script?: string;
|
||||
}>;
|
||||
};
|
||||
probes?: {
|
||||
srs?: {
|
||||
port?: number;
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
};
|
||||
neko?: {
|
||||
port?: number;
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
@@ -225,7 +248,7 @@ export default function LiveControlApp() {
|
||||
}, [currentProc, fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProc || view !== "live") return;
|
||||
if (!currentProc || (view !== "live_youtube" && view !== "live_tiktok")) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
@@ -317,15 +340,16 @@ export default function LiveControlApp() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveUrlConfig = async () => {
|
||||
const saveUrlConfig = async (overrideContent?: string) => {
|
||||
if (!currentProc) return;
|
||||
const content = overrideContent ?? urlConfig;
|
||||
setBusy("save:url");
|
||||
try {
|
||||
const params = new URLSearchParams({ process: currentProc });
|
||||
await fetchJson(`/save_url_config?${params}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: urlConfig }),
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
notify(t("URL 配置已保存", "URL config saved"));
|
||||
} catch (e) {
|
||||
@@ -374,12 +398,20 @@ export default function LiveControlApp() {
|
||||
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
|
||||
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
|
||||
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
|
||||
{ id: "live", icon: Radio, labelZh: "无人直播", labelEn: "Live ops" },
|
||||
{ id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
|
||||
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
|
||||
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
|
||||
{ id: "network", icon: Network, labelZh: "网络", labelEn: "Network" },
|
||||
{ id: "settings", icon: Settings, labelZh: "设置", labelEn: "Settings" },
|
||||
];
|
||||
|
||||
const copyText = (text: string) => {
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => notify(t("已复制", "Copied")),
|
||||
() => notify(t("复制失败", "Copy failed")),
|
||||
);
|
||||
};
|
||||
|
||||
const onlineServices =
|
||||
dash?.services?.filter((s) => s.running && s.available).length ?? 0;
|
||||
const totalServices = dash?.services?.length ?? 0;
|
||||
@@ -557,6 +589,35 @@ export default function LiveControlApp() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dash?.probes ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(["srs", "neko"] as const).map((key) => {
|
||||
const p = dash.probes?.[key];
|
||||
const tcpOk = p?.tcp?.reachable;
|
||||
const http = p?.http;
|
||||
const title = key === "srs" ? "SRS" : "Neko Chromium";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<p className="text-xs font-semibold text-white">{title}</p>
|
||||
<p className="mt-1 font-mono text-[11px] text-zinc-500">
|
||||
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t("延迟", "RTT")}{" "}
|
||||
{http?.latency_ms != null ? `${http.latency_ms} ms` : p?.tcp?.latency_ms != null ? `${p.tcp.latency_ms} ms` : "—"}
|
||||
</p>
|
||||
{!http?.reachable && http?.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -597,13 +658,22 @@ export default function LiveControlApp() {
|
||||
<li className="text-zinc-600">{t("暂无数据", "No data")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live")}
|
||||
className="mt-4 w-full rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-xs font-semibold text-violet-200"
|
||||
>
|
||||
{t("去控制台", "Go to live ops")}
|
||||
</button>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_youtube")}
|
||||
className="rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-[11px] font-semibold text-violet-200"
|
||||
>
|
||||
YouTube
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("live_tiktok")}
|
||||
className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 py-2 text-[11px] font-semibold text-cyan-200"
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -680,192 +750,59 @@ export default function LiveControlApp() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === "live" ? (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("选择进程", "Process")}</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={currentProc}
|
||||
onChange={(e) => setCurrentProc(e.target.value)}
|
||||
>
|
||||
{entries.map((e) => (
|
||||
<option key={e.pm2} value={e.pm2}>
|
||||
{e.label} ({e.pm2})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void runProcess("start")}
|
||||
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("启动", "Start")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void runProcess("restart")}
|
||||
className="rounded-xl bg-amber-500/15 px-4 py-2 text-sm font-medium text-amber-200 ring-1 ring-amber-500/25"
|
||||
>
|
||||
{t("重启", "Restart")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void runProcess("stop")}
|
||||
className="rounded-xl bg-rose-500/15 px-4 py-2 text-sm font-medium text-rose-200 ring-1 ring-rose-500/25"
|
||||
>
|
||||
{t("停止", "Stop")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("抖音 / TikTok 拉流地址", "Douyin / TikTok URL")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("写入业务元数据(config center),推流细节仍以 URL_config.ini / PM2 为准。", "Stored in config center; streaming still uses URL_config.ini.")}
|
||||
</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={douyinUrl}
|
||||
onChange={(e) => setDouyinUrl(e.target.value)}
|
||||
placeholder="https://live.douyin.com/..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void saveBusinessMeta()}
|
||||
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{t("保存到配置中心", "Save to config hub")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">URL_config.ini</h3>
|
||||
<textarea
|
||||
className="mt-4 h-48 w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||||
value={urlConfig}
|
||||
onChange={(e) => setUrlConfig(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void saveUrlConfig()}
|
||||
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("保存 URL 配置", "Save URL config")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-center text-xs text-zinc-600">
|
||||
{t(
|
||||
"FFmpeg 自动重启:请在 PM2 ecosystem 或 systemd 中配置 restart 策略;business.json 中 ffmpeg.auto_restart 供运维工具读取。",
|
||||
"For FFmpeg auto-restart, configure PM2 or systemd; business.json flags are for automation hooks.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{view === "live_youtube" ? (
|
||||
<LiveYoutubeUnmannedView
|
||||
t={t}
|
||||
busy={!!busy}
|
||||
entries={entries}
|
||||
currentProc={currentProc}
|
||||
setCurrentProc={setCurrentProc}
|
||||
urlConfig={urlConfig}
|
||||
setUrlConfig={setUrlConfig}
|
||||
douyinUrl={douyinUrl}
|
||||
setDouyinUrl={setDouyinUrl}
|
||||
onRunProcess={(a) => void runProcess(a)}
|
||||
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
|
||||
onSaveBusinessMeta={() => void saveBusinessMeta()}
|
||||
fetchJson={fetchJson}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "live_tiktok" ? (
|
||||
<LiveTiktokHdmiView
|
||||
t={t}
|
||||
busy={!!busy}
|
||||
entries={entries}
|
||||
currentProc={currentProc}
|
||||
setCurrentProc={setCurrentProc}
|
||||
urlConfig={urlConfig}
|
||||
setUrlConfig={setUrlConfig}
|
||||
douyinUrl={douyinUrl}
|
||||
setDouyinUrl={setDouyinUrl}
|
||||
onRunProcess={(a) => void runProcess(a)}
|
||||
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
|
||||
onSaveBusinessMeta={() => void saveBusinessMeta()}
|
||||
onCopy={copyText}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "android" ? (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("设备", "Devices")}</h3>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(dash?.android?.devices || []).map((d) => (
|
||||
<button
|
||||
key={d.serial}
|
||||
type="button"
|
||||
onClick={() => setAndroidSerial(d.serial)}
|
||||
className={`rounded-xl border px-4 py-2 text-left text-xs ${
|
||||
androidSerial === d.serial
|
||||
? "border-violet-500/50 bg-violet-500/10 text-white"
|
||||
: "border-white/10 bg-black/30 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
|
||||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() =>
|
||||
void androidAction("launch_package", { package: "com.zhiliaoapp.musically" })
|
||||
}
|
||||
className="rounded-xl bg-gradient-to-r from-pink-500/30 to-violet-500/30 px-4 py-2 text-sm font-medium ring-1 ring-white/10"
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void androidAction("wake")}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm"
|
||||
>
|
||||
Wake
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("安装 APK(设备上的路径)", "Install APK (path on device)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
|
||||
</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={apkPath}
|
||||
onChange={(e) => setApkPath(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void androidAction("install_apk", { path: apkPath })}
|
||||
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
pm install
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("实时画面(ADB 截图)", "Live screen (ADB)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。",
|
||||
"~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.",
|
||||
)}
|
||||
</p>
|
||||
{!dash?.android?.adb_available ? (
|
||||
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
|
||||
) : null}
|
||||
{androidShotErr && dash?.android?.adb_available ? (
|
||||
<p className="mt-3 text-sm text-rose-300">{androidShotErr}</p>
|
||||
) : null}
|
||||
{androidShotUrl ? (
|
||||
<img
|
||||
alt="Android live"
|
||||
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
|
||||
src={androidShotUrl}
|
||||
/>
|
||||
) : null}
|
||||
{dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? (
|
||||
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{scrcpyUrl ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
|
||||
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
|
||||
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
|
||||
{t("新窗口打开", "Open tab")}
|
||||
</a>
|
||||
</div>
|
||||
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<LiveAndroidWorkstation
|
||||
t={t}
|
||||
notify={notify}
|
||||
fetchJson={fetchJson}
|
||||
busy={busy}
|
||||
androidSerial={androidSerial}
|
||||
setAndroidSerial={setAndroidSerial}
|
||||
devices={dash?.android?.devices || []}
|
||||
adbAvailable={!!dash?.android?.adb_available}
|
||||
scrcpyUrl={scrcpyUrl}
|
||||
androidShotUrl={androidShotUrl}
|
||||
androidShotErr={androidShotErr}
|
||||
apkPath={apkPath}
|
||||
setApkPath={setApkPath}
|
||||
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "network" ? (
|
||||
|
||||
203
web-console/components/live-product/LiveTiktokHdmiView.tsx
Normal file
203
web-console/components/live-product/LiveTiktokHdmiView.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Cable, Copy, Monitor } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
function isTiktokProcess(e: ProcessEntry) {
|
||||
return /tiktok/i.test(e.script) || /tiktok/i.test(e.pm2);
|
||||
}
|
||||
|
||||
type HdmiPreset = { id: string; labelZh: string; labelEn: string; spec: string; hintZh: string; hintEn: string };
|
||||
|
||||
const PRESETS: HdmiPreset[] = [
|
||||
{
|
||||
id: "720p60",
|
||||
labelZh: "720p @ 60Hz",
|
||||
labelEn: "720p @ 60Hz",
|
||||
spec: "1280x720, 59.94/60Hz, RGB/YUV 有限",
|
||||
hintZh: "板端 HDMI 输出与采集卡 EDID 对齐;OBS 画布 1280×720。",
|
||||
hintEn: "Match SoC HDMI timing to capture card EDID; OBS 1280×720.",
|
||||
},
|
||||
{
|
||||
id: "1080p30",
|
||||
labelZh: "1080p @ 30Hz",
|
||||
labelEn: "1080p @ 30Hz",
|
||||
spec: "1920x1080, 30Hz, 适合 USB 采集带宽紧张场景",
|
||||
hintZh: "USB3 采集常更稳;检查线缆长度与屏蔽。",
|
||||
hintEn: "Often more stable on USB3 capture; check cable quality.",
|
||||
},
|
||||
{
|
||||
id: "1080p60",
|
||||
labelZh: "1080p @ 60Hz",
|
||||
labelEn: "1080p @ 60Hz",
|
||||
spec: "1920x1080, 60Hz, 高刷转播",
|
||||
hintZh: "需确认采集卡与 SoC 同时支持 1080p60;过热会掉帧。",
|
||||
hintEn: "Requires 1080p60 on both ends; thermal throttling may drop frames.",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
busy: boolean;
|
||||
entries: ProcessEntry[];
|
||||
currentProc: string;
|
||||
setCurrentProc: (v: string) => void;
|
||||
urlConfig: string;
|
||||
setUrlConfig: (v: string) => void;
|
||||
douyinUrl: string;
|
||||
setDouyinUrl: (v: string) => void;
|
||||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||||
onSaveUrlConfig: (content?: string) => void;
|
||||
onSaveBusinessMeta: () => void;
|
||||
onCopy: (text: string) => void;
|
||||
};
|
||||
|
||||
export function LiveTiktokHdmiView({
|
||||
t,
|
||||
busy,
|
||||
entries,
|
||||
currentProc,
|
||||
setCurrentProc,
|
||||
urlConfig,
|
||||
setUrlConfig,
|
||||
douyinUrl,
|
||||
setDouyinUrl,
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
onSaveBusinessMeta,
|
||||
onCopy,
|
||||
}: Props) {
|
||||
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
|
||||
const [expanded, setExpanded] = useState<string | null>("1080p30");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 via-transparent to-violet-500/5 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cable className="h-5 w-5 text-cyan-300" />
|
||||
<h2 className="text-base font-semibold text-white">{t("TikTok 无人直播 · HDMI", "TikTok unmanned · HDMI")}</h2>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
{t(
|
||||
"TikTok 无多频道概念:单路转播到 HDMI。下列为常见时序与排障要点,便于你在板子与采集侧做兼容测试。",
|
||||
"Single-path HDMI relay; presets for timing compatibility tests.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{PRESETS.map((p) => {
|
||||
const open = expanded === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setExpanded(open ? null : p.id)}
|
||||
className={`rounded-2xl border p-4 text-left transition ${
|
||||
open ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/[0.08] bg-zinc-950/50 hover:border-white/15"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Monitor className="h-4 w-4 text-cyan-400" />
|
||||
{t(p.labelZh, p.labelEn)}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[11px] text-zinc-400">{p.spec}</p>
|
||||
{open ? <p className="mt-2 text-xs text-zinc-500">{t(p.hintZh, p.hintEn)}</p> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("TikTok 进程", "TikTok process")}</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={currentProc}
|
||||
onChange={(e) => setCurrentProc(e.target.value)}
|
||||
>
|
||||
{(tkEntries.length ? tkEntries : entries).map((e) => (
|
||||
<option key={e.pm2} value={e.pm2}>
|
||||
{e.label} ({e.pm2})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onRunProcess("start")}
|
||||
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("启动", "Start")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onRunProcess("restart")}
|
||||
className="rounded-xl bg-amber-500/15 px-4 py-2 text-sm font-medium text-amber-200 ring-1 ring-amber-500/25"
|
||||
>
|
||||
{t("重启", "Restart")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onRunProcess("stop")}
|
||||
className="rounded-xl bg-rose-500/15 px-4 py-2 text-sm font-medium text-rose-200 ring-1 ring-rose-500/25"
|
||||
>
|
||||
{t("停止", "Stop")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("拉流地址(业务元数据)", "Source URL (hub)")}</h3>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={douyinUrl}
|
||||
onChange={(e) => setDouyinUrl(e.target.value)}
|
||||
placeholder="https://live.douyin.com/..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onSaveBusinessMeta()}
|
||||
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{t("保存", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">URL_config.ini</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onClick={() => onCopy(urlConfig)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制全文", "Copy all")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-4 h-40 w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||||
value={urlConfig}
|
||||
onChange={(e) => setUrlConfig(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onSaveUrlConfig()}
|
||||
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("保存 URL 配置", "Save URL config")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
626
web-console/components/live-product/LiveYoutubeUnmannedView.tsx
Normal file
626
web-console/components/live-product/LiveYoutubeUnmannedView.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
buildYoutubeIniFromFields,
|
||||
extractDouyinLiveUrls,
|
||||
parseYoutubeIniFields,
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
} from "@/lib/youtubeConfigUtils";
|
||||
import {
|
||||
defaultPm2NameForChannel,
|
||||
loadYoutubeProChannels,
|
||||
newChannelId,
|
||||
saveYoutubeProChannels,
|
||||
type YoutubeProChannel,
|
||||
} from "@/lib/youtubeProChannels";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
function isYoutubeProcess(e: ProcessEntry) {
|
||||
return /youtube/i.test(e.script) || /youtube/i.test(e.pm2);
|
||||
}
|
||||
|
||||
const MODE_KEY = "live-hub-youtube-mode-v1";
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
busy: boolean;
|
||||
entries: ProcessEntry[];
|
||||
currentProc: string;
|
||||
setCurrentProc: (v: string) => void;
|
||||
urlConfig: string;
|
||||
setUrlConfig: (v: string) => void;
|
||||
douyinUrl: string;
|
||||
setDouyinUrl: (v: string) => void;
|
||||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||||
onSaveUrlConfig: (content?: string) => void;
|
||||
onSaveBusinessMeta: () => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export function LiveYoutubeUnmannedView({
|
||||
t,
|
||||
busy,
|
||||
entries,
|
||||
currentProc,
|
||||
setCurrentProc,
|
||||
urlConfig,
|
||||
setUrlConfig,
|
||||
douyinUrl,
|
||||
setDouyinUrl,
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
onSaveBusinessMeta,
|
||||
fetchJson,
|
||||
}: Props) {
|
||||
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
|
||||
const processOptions = ytEntries.length ? ytEntries : entries;
|
||||
|
||||
const [mode, setMode] = useState<"single" | "pro">("single");
|
||||
const [channels, setChannels] = useState<YoutubeProChannel[]>([]);
|
||||
const [activeCh, setActiveCh] = useState<string>("");
|
||||
const [youtubeIniText, setYoutubeIniText] = useState("");
|
||||
const [ytFields, setYtFields] = useState<YoutubeIniFields>({
|
||||
key: "",
|
||||
rtmps: true,
|
||||
bitrate: "",
|
||||
fastAudio: false,
|
||||
});
|
||||
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
|
||||
const [ytIniLoading, setYtIniLoading] = useState(false);
|
||||
const [proUrlDraft, setProUrlDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const m = window.localStorage.getItem(MODE_KEY);
|
||||
if (m === "pro" || m === "single") setMode(m);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setModePersist = (m: "single" | "pro") => {
|
||||
setMode(m);
|
||||
if (m === "pro") {
|
||||
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
|
||||
if (ch) {
|
||||
const u = (ch.urlLines ?? "").trim();
|
||||
setProUrlDraft(u || urlConfig);
|
||||
}
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(MODE_KEY, m);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const list = loadYoutubeProChannels();
|
||||
const initial =
|
||||
list.length > 0
|
||||
? list
|
||||
: (() => {
|
||||
const id = newChannelId();
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name: "线路 1",
|
||||
streamKey: "",
|
||||
urlLines: "",
|
||||
pm2Name: defaultPm2NameForChannel(id),
|
||||
notes: "",
|
||||
},
|
||||
];
|
||||
})();
|
||||
setChannels(initial);
|
||||
if (!list.length) saveYoutubeProChannels(initial);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (channels.length && !activeCh) setActiveCh(channels[0].id);
|
||||
}, [channels, activeCh]);
|
||||
|
||||
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
}, []);
|
||||
|
||||
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
||||
|
||||
const loadYoutubeIni = useCallback(async () => {
|
||||
if (!currentProc) return;
|
||||
setYtIniLoading(true);
|
||||
setYtIniStatus(null);
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
`/get_config?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
const text = data.content ?? "";
|
||||
setYoutubeIniText(text);
|
||||
setYtFields(parseYoutubeIniFields(text));
|
||||
} catch (e) {
|
||||
setYtIniStatus((e as Error).message);
|
||||
} finally {
|
||||
setYtIniLoading(false);
|
||||
}
|
||||
}, [currentProc, fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProc) return;
|
||||
void loadYoutubeIni();
|
||||
}, [currentProc, loadYoutubeIni]);
|
||||
|
||||
const updateActive = (patch: Partial<YoutubeProChannel>) => {
|
||||
if (!active) return;
|
||||
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
const saveYoutubeIniToServer = async (content: string) => {
|
||||
if (!currentProc) {
|
||||
setYtIniStatus(t("请选择进程", "Pick a process"));
|
||||
return;
|
||||
}
|
||||
setYtIniLoading(true);
|
||||
setYtIniStatus(null);
|
||||
try {
|
||||
await fetchJson("/save_config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
setYoutubeIniText(content);
|
||||
setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved"));
|
||||
} catch (e) {
|
||||
setYtIniStatus((e as Error).message);
|
||||
} finally {
|
||||
setYtIniLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFieldsAndSave = async () => {
|
||||
const body = buildYoutubeIniFromFields(ytFields);
|
||||
await saveYoutubeIniToServer(body);
|
||||
};
|
||||
|
||||
const syncFieldsFromText = () => {
|
||||
setYtFields(parseYoutubeIniFields(youtubeIniText));
|
||||
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
|
||||
};
|
||||
|
||||
const keySuffixUi = useMemo(() => {
|
||||
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
|
||||
const k = active?.streamKey ?? "";
|
||||
return parseYoutubeStreamKeySuffix(`key = ${k}`);
|
||||
}, [mode, ytFields.key, active?.streamKey]);
|
||||
|
||||
const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
|
||||
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
|
||||
|
||||
const saveProUrlAndServer = async () => {
|
||||
if (!active) return;
|
||||
updateActive({ urlLines: proUrlDraft });
|
||||
setUrlConfig(proUrlDraft);
|
||||
onSaveUrlConfig(proUrlDraft);
|
||||
};
|
||||
|
||||
const pushProStreamKeyToServer = async () => {
|
||||
if (!active) return;
|
||||
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
|
||||
const f: YoutubeIniFields = {
|
||||
key: (active.streamKey ?? "").trim(),
|
||||
rtmps: base.rtmps,
|
||||
bitrate: base.bitrate,
|
||||
fastAudio: base.fastAudio,
|
||||
};
|
||||
const body = buildYoutubeIniFromFields(f);
|
||||
await saveYoutubeIniToServer(body);
|
||||
updateActive({ streamKey: f.key });
|
||||
};
|
||||
|
||||
const lock = busy || ytIniLoading;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-red-500/30 to-violet-600/30 ring-1 ring-white/10">
|
||||
<MonitorPlay className="h-5 w-5 text-red-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white">
|
||||
{t("YouTube 无人直播", "YouTube unmanned live")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{t(
|
||||
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
|
||||
"Desktop-style live control: single or Pro lanes, no account login.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex rounded-xl border border-white/10 bg-black/30 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModePersist("single")}
|
||||
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
|
||||
mode === "single" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{t("单频道", "Single")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModePersist("pro")}
|
||||
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
|
||||
mode === "pro" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{t("多频道 Pro", "Multi Pro")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex items-center gap-2 text-amber-200/90">
|
||||
<Radio className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
|
||||
</div>
|
||||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("进程(PM2)", "PM2 process")}
|
||||
</label>
|
||||
<select
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
|
||||
value={currentProc}
|
||||
onChange={(e) => setCurrentProc(e.target.value)}
|
||||
>
|
||||
{processOptions.map((e) => (
|
||||
<option key={e.pm2} value={e.pm2}>
|
||||
{e.label} ({e.pm2})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === "pro" && active ? (
|
||||
<p className="mt-2 font-mono text-[11px] text-cyan-300/80">
|
||||
Pro {t("建议独立进程名", "suggested")}: {active.pm2Name || defaultPm2NameForChannel(active.id)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
|
||||
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
|
||||
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
|
||||
] as const
|
||||
).map(([a, label, cls]) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess(a)}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
|
||||
key {t("尾码(参考)", "suffix")}: {keySuffixUi || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "single" ? (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("编辑 config/youtube.ini(与桌面端一致字段)。不使用 Google 登录。", "Edits config/youtube.ini. No Google OAuth.")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="block text-xs text-zinc-400">{t("串流密钥", "Stream key")}</label>
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm font-mono text-zinc-200"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={ytFields.key}
|
||||
onChange={(e) => setYtFields((f) => ({ ...f, key: e.target.value }))}
|
||||
placeholder="xxxx-xxxx-xxxx-xxxx-xxxx"
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
RTMPS
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
|
||||
value={ytFields.rtmps ? "1" : "0"}
|
||||
onChange={(e) => setYtFields((f) => ({ ...f, rtmps: e.target.value === "1" }))}
|
||||
>
|
||||
<option value="1">{t("是(默认)", "Yes")}</option>
|
||||
<option value="0">{t("否 → RTMP", "No")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{t("比特率 kbps", "Bitrate kbps")}
|
||||
<input
|
||||
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||||
value={ytFields.bitrate}
|
||||
onChange={(e) => setYtFields((f) => ({ ...f, bitrate: e.target.value }))}
|
||||
placeholder="4000"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 pt-6 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-white/20 bg-black/40"
|
||||
checked={ytFields.fastAudio}
|
||||
onChange={(e) => setYtFields((f) => ({ ...f, fastAudio: e.target.checked }))}
|
||||
/>
|
||||
fast_audio({t("轻量音频", "lighter audio")})
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void loadYoutubeIni()}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${ytIniLoading ? "animate-spin" : ""}`} />
|
||||
{t("重新读取", "Reload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void applyFieldsAndSave()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t("保存 youtube.ini", "Save youtube.ini")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={syncFieldsFromText}
|
||||
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-400"
|
||||
>
|
||||
{t("从原文同步表单", "Parse raw")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="mt-4 rounded-xl border border-white/5 bg-black/20 p-3">
|
||||
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级:youtube.ini 原文", "Raw youtube.ini")}</summary>
|
||||
<textarea
|
||||
className="mt-2 h-36 w-full resize-y rounded-lg border border-white/10 bg-black/50 p-3 font-mono text-[11px] text-zinc-400"
|
||||
value={youtubeIniText}
|
||||
onChange={(e) => setYoutubeIniText(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
className="mt-2 rounded-lg bg-cyan-500/15 px-3 py-1.5 text-xs text-cyan-200"
|
||||
onClick={() => void saveYoutubeIniToServer(youtubeIniText)}
|
||||
>
|
||||
{t("保存原文", "Save raw")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
{ytIniStatus ? <p className="mt-2 font-mono text-xs text-zinc-500">{ytIniStatus}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("Pro 线路列表", "Pro channels")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-500/30"
|
||||
onClick={() => {
|
||||
const id = newChannelId();
|
||||
const nc: YoutubeProChannel = {
|
||||
id,
|
||||
name: `${t("线路", "Ch")} ${channels.length + 1}`,
|
||||
streamKey: "",
|
||||
urlLines: "",
|
||||
pm2Name: defaultPm2NameForChannel(id),
|
||||
notes: "",
|
||||
};
|
||||
persistChannels([...channels, nc]);
|
||||
setActiveCh(id);
|
||||
setProUrlDraft("");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("新增线路", "Add")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{channels.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (active && mode === "pro") {
|
||||
persistChannels(
|
||||
channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)),
|
||||
);
|
||||
}
|
||||
const u = (c.urlLines ?? "").trim();
|
||||
setProUrlDraft(u || urlConfig);
|
||||
setActiveCh(c.id);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2 text-left text-xs ${
|
||||
activeCh === c.id ? "border-violet-500/50 bg-violet-500/10 text-white" : "border-white/10 bg-black/30 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-zinc-200">{c.name}</div>
|
||||
<div className="font-mono text-[10px] text-zinc-600">{c.pm2Name || defaultPm2NameForChannel(c.id)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
|
||||
value={active.name}
|
||||
onChange={(e) => updateActive({ name: e.target.value })}
|
||||
placeholder={t("线路显示名", "Channel name")}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||||
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
|
||||
onChange={(e) => updateActive({ pm2Name: e.target.value })}
|
||||
placeholder="youtube2__…"
|
||||
/>
|
||||
<label className="block text-xs text-zinc-500">{t("串流密钥(本线路)", "Stream key for this lane")}</label>
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||||
spellCheck={false}
|
||||
value={active.streamKey ?? ""}
|
||||
onChange={(e) => updateActive({ streamKey: e.target.value })}
|
||||
placeholder="key / rtmp(s) URL"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void pushProStreamKeyToServer()}
|
||||
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-200/70">
|
||||
{t(
|
||||
"服务器仅一份 youtube.ini:推送会覆盖;多路并行请在板上为每路配置独立 PM2 与独立 config 目录,或轮流推送。",
|
||||
"Server has one youtube.ini; overwrite. For true parallel lanes use separate PM2 + config dirs on the host.",
|
||||
)}
|
||||
</p>
|
||||
<textarea
|
||||
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
|
||||
value={active.notes ?? ""}
|
||||
onChange={(e) => updateActive({ notes: e.target.value })}
|
||||
placeholder={t("备注", "Notes")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || channels.length < 2}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
const next = channels.filter((c) => c.id !== active.id);
|
||||
persistChannels(next);
|
||||
setActiveCh(next[0]?.id || "");
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("删除线路", "Remove")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
|
||||
<textarea
|
||||
className="mt-4 min-h-[160px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||||
value={proUrlDraft}
|
||||
onChange={(e) => setProUrlDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="https://live.douyin.com/..."
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => {
|
||||
setProUrlDraft(urlConfig);
|
||||
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
|
||||
}}
|
||||
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
|
||||
>
|
||||
{t("与全局 URL 同步", "Sync from global")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void saveProUrlAndServer()}
|
||||
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("保存到服务器并记入本线路", "Save to server + lane")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (metadata)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON,供运维脚本读取。", "business.json for automation.")}</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={douyinUrl}
|
||||
onChange={(e) => setDouyinUrl(e.target.value)}
|
||||
placeholder="https://live.douyin.com/..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onSaveBusinessMeta()}
|
||||
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{t("保存到配置中心", "Save to hub")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{mode === "pro"
|
||||
? t("单频道模式在此编辑全局文件;Pro 线路请用上方「当前线路」块保存。", "Pro lanes use the lane editor above.")
|
||||
: t("一行一个直播间地址。", "One URL per line.")}
|
||||
</p>
|
||||
{mode === "single" ? (
|
||||
<>
|
||||
<textarea
|
||||
className="mt-4 min-h-[200px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
|
||||
value={urlConfig}
|
||||
onChange={(e) => setUrlConfig(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onSaveUrlConfig()}
|
||||
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
|
||||
>
|
||||
{t("保存 URL 配置", "Save URL config")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
|
||||
)}
|
||||
{douyinUrls.length > 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
|
||||
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
|
||||
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
|
||||
{douyinUrls.map((u) => (
|
||||
<li key={u}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
web-console/lib/youtubeConfigUtils.ts
Normal file
105
web-console/lib/youtubeConfigUtils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/** 对齐 d2yexeapp electron `youtubeConfigUtils.ts` 的浏览器侧解析(无 OAuth) */
|
||||
|
||||
export function parseYoutubeStreamKeySuffix(iniOrKeyLine: string): string {
|
||||
for (const line of iniOrKeyLine.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith("#") || !t) continue;
|
||||
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i);
|
||||
if (m) {
|
||||
const v = m[1].trim().split(/[#;]/)[0].trim();
|
||||
if (v && !v.toLowerCase().startsWith("rtmp")) return v.length > 8 ? v.slice(-8) : v;
|
||||
if (v.includes("live2/")) {
|
||||
const parts = v.split("/");
|
||||
const last = parts[parts.length - 1] || "";
|
||||
return last.length > 8 ? last.slice(-8) : last;
|
||||
}
|
||||
}
|
||||
}
|
||||
const one = iniOrKeyLine.trim();
|
||||
if (one && !one.includes("\n") && !one.toLowerCase().startsWith("rtmp"))
|
||||
return one.length > 8 ? one.slice(-8) : one;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractDouyinLiveUrls(urlIni: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const line of urlIni.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith("#")) continue;
|
||||
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue;
|
||||
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i);
|
||||
if (full) {
|
||||
out.push(full[1].replace(/^http:\/\//i, "https://"));
|
||||
continue;
|
||||
}
|
||||
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i);
|
||||
if (rel) {
|
||||
out.push(`https://live.douyin.com/${rel[1]}`);
|
||||
continue;
|
||||
}
|
||||
const n = t.match(/(\d{8,})/);
|
||||
if (n) out.push(`https://live.douyin.com/${n[1]}`);
|
||||
}
|
||||
return Array.from(new Set(out)).slice(0, 16);
|
||||
}
|
||||
|
||||
export type YoutubeIniFields = {
|
||||
key: string;
|
||||
rtmps: boolean;
|
||||
bitrate: string;
|
||||
fastAudio: boolean;
|
||||
};
|
||||
|
||||
export function parseYoutubeIniFields(content: string): YoutubeIniFields {
|
||||
let key = "";
|
||||
let rtmps = true;
|
||||
let bitrate = "";
|
||||
let fastAudio = false;
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inYoutube = false;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (/^\[youtube\]/i.test(t)) {
|
||||
inYoutube = true;
|
||||
continue;
|
||||
}
|
||||
if (/^\[/.test(t)) {
|
||||
inYoutube = false;
|
||||
continue;
|
||||
}
|
||||
if (!inYoutube) continue;
|
||||
if (!t || t.startsWith("#") || t.startsWith(";")) continue;
|
||||
const eq = t.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const k = t.slice(0, eq).trim().toLowerCase();
|
||||
const v = t
|
||||
.slice(eq + 1)
|
||||
.trim()
|
||||
.split(/[#;]/)[0]
|
||||
.trim();
|
||||
if (k === "key" || k === "stream_key") key = v;
|
||||
else if (k === "rtmps") rtmps = !/^(0|false|否)/i.test(v);
|
||||
else if (k === "bitrate") bitrate = v;
|
||||
else if (k === "fast_audio") fastAudio = /^(1|true|yes|是|开|on)/i.test(v);
|
||||
}
|
||||
return { key, rtmps, bitrate, fastAudio };
|
||||
}
|
||||
|
||||
export function buildYoutubeIniFromFields(f: YoutubeIniFields): string {
|
||||
const bitrateLine = f.bitrate.trim()
|
||||
? `bitrate = ${f.bitrate.trim()}`
|
||||
: "# bitrate = 4000";
|
||||
return `[youtube]
|
||||
# YouTube 推流 key(工作室 → 直播 → 串流密钥)
|
||||
key = ${f.key.trim()}
|
||||
|
||||
# 使用 RTMPS;填 否 则 RTMP
|
||||
rtmps = ${f.rtmps ? "是" : "否"}
|
||||
|
||||
# 视频比特率 kbps(可选)
|
||||
${bitrateLine}
|
||||
|
||||
# 轻量音频链,减轻 CPU:1 / 是
|
||||
fast_audio = ${f.fastAudio ? "1" : "0"}
|
||||
`;
|
||||
}
|
||||
71
web-console/lib/youtubeProChannels.ts
Normal file
71
web-console/lib/youtubeProChannels.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type YoutubeProChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 备忘尾码(旧版) */
|
||||
streamKeySuffix?: string;
|
||||
/** 完整串流密钥或 rtmp(s) URL */
|
||||
streamKey?: string;
|
||||
/** 本线路 URL_config 草稿(多路本地编排) */
|
||||
urlLines?: string;
|
||||
/** 建议 PM2 名,如 youtube2__xxx */
|
||||
pm2Name?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "live-hub-youtube-pro-v2";
|
||||
|
||||
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
|
||||
if (!x || typeof x !== "object") return null;
|
||||
const id = String(x.id || "").trim();
|
||||
const name = String(x.name || "").trim();
|
||||
if (!id || !name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
streamKeySuffix: typeof x.streamKeySuffix === "string" ? x.streamKeySuffix : undefined,
|
||||
streamKey: typeof x.streamKey === "string" ? x.streamKey : undefined,
|
||||
urlLines: typeof x.urlLines === "string" ? x.urlLines : undefined,
|
||||
pm2Name: typeof x.pm2Name === "string" ? x.pm2Name : undefined,
|
||||
notes: typeof x.notes === "string" ? x.notes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function safeParse(raw: string | null): YoutubeProChannel[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map((x) => migrateRow(x as Record<string, unknown>)).filter((c): c is YoutubeProChannel => c !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadYoutubeProChannels(): YoutubeProChannel[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
let raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
raw = window.localStorage.getItem("live-hub-youtube-pro-v1");
|
||||
if (raw) {
|
||||
const legacy = safeParse(raw);
|
||||
if (legacy.length) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeParse(raw);
|
||||
}
|
||||
|
||||
export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels));
|
||||
}
|
||||
|
||||
export function newChannelId(): string {
|
||||
return `ch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function defaultPm2NameForChannel(channelId: string): string {
|
||||
return `youtube2__${channelId}`;
|
||||
}
|
||||
13
web.py
13
web.py
@@ -17,6 +17,7 @@ from src.android_control import (
|
||||
adb_available,
|
||||
android_action,
|
||||
android_device_overview,
|
||||
android_logcat_recent,
|
||||
android_packages,
|
||||
android_screenshot,
|
||||
android_ui_nodes,
|
||||
@@ -285,6 +286,18 @@ async def get_android_overview(serial: str = Query(..., description="Android dev
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@app.get("/android/logcat")
|
||||
async def get_android_logcat(
|
||||
serial: str = Query(..., description="Android device serial"),
|
||||
lines: int = Query(200, ge=1, le=3000, description="Number of recent log lines"),
|
||||
):
|
||||
try:
|
||||
text = await android_logcat_recent(serial, lines=lines)
|
||||
return {"text": text}
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@app.get("/android/screenshot")
|
||||
async def get_android_screenshot(serial: str = Query(..., description="Android device serial")):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user