's'
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react";
|
||||
import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
@@ -84,9 +84,14 @@ export function LiveAndroidWorkstation({
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [adbInputText, setAdbInputText] = useState("");
|
||||
const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host");
|
||||
const [mirrorReloadKey, setMirrorReloadKey] = useState(0);
|
||||
const [logcatLive, setLogcatLive] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/";
|
||||
const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy";
|
||||
const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy";
|
||||
const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy";
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
|
||||
@@ -157,26 +162,36 @@ export function LiveAndroidWorkstation({
|
||||
}
|
||||
};
|
||||
|
||||
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 refreshLogcat = useCallback(
|
||||
async (opts?: { silent?: boolean }) => {
|
||||
if (!androidSerial) {
|
||||
return;
|
||||
}
|
||||
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
|
||||
const silent = !!opts?.silent;
|
||||
if (!silent) 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 {
|
||||
if (!silent) setPending(null);
|
||||
}
|
||||
},
|
||||
[androidSerial, fetchJson, logcatLines],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logcatLive || !androidSerial || !adbAvailable) return;
|
||||
const id = window.setInterval(() => void refreshLogcat({ silent: true }), 3000);
|
||||
void refreshLogcat({ silent: true });
|
||||
return () => clearInterval(id);
|
||||
}, [logcatLive, androidSerial, adbAvailable, refreshLogcat]);
|
||||
|
||||
const loadUiNodes = async () => {
|
||||
if (!androidSerial) {
|
||||
@@ -260,10 +275,10 @@ export function LiveAndroidWorkstation({
|
||||
))}
|
||||
</div>
|
||||
{adbAvailable && devices.length === 0 ? (
|
||||
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs text-amber-200/90">
|
||||
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs leading-relaxed text-amber-200/90">
|
||||
{t(
|
||||
"未检测到已授权设备:请 USB 连接并开启「USB 调试」,手机上允许本计算机调试。",
|
||||
"No authorized device: enable USB debugging and authorize this computer.",
|
||||
"未检测到已授权设备:双头 USB 接板子时请开「USB 调试」并授权;Redroid / 容器安卓可先 `adb connect IP:5555` 再刷新本页。",
|
||||
"No device: enable USB debugging for dual-USB to the board; for Redroid use adb connect IP:5555 then refresh.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -293,22 +308,40 @@ export function LiveAndroidWorkstation({
|
||||
>
|
||||
WebUSB · Panda
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/PandaTestGrid/panda-web-scrcpy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto self-center text-[11px] text-zinc-500 underline-offset-2 hover:text-violet-300 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 self-center">
|
||||
<a
|
||||
href={LINK_SCRCPY}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-emerald-300"
|
||||
>
|
||||
scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href={LINK_WS_SCRCPY}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-violet-300"
|
||||
>
|
||||
ws-scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href={LINK_PANDA_GH}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-cyan-300"
|
||||
>
|
||||
panda-web-scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{mirrorMode === "host" ? (
|
||||
<div>
|
||||
<div className="space-y-2 border-b border-white/5 px-4 py-3">
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
<p className="text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"手机接在 live 板子 USB 时使用本页:先在 ws-scrcpy 里选设备并连接;黑屏多为未连 WebSocket / 屏幕休眠——可先 Wake 再刷新或新窗口打开面板。",
|
||||
"USB to the board: pick device in ws-scrcpy; black screen often means WS down or screen off—Wake, refresh, or open panel in a new tab.",
|
||||
"板载 USB:投屏协议来自开源 scrcpy(见上方仓库链接);浏览器内常用 ws-scrcpy。黑屏时先 Wake,再点「重载投屏」或新窗口打开面板。",
|
||||
"Board USB: scrcpy protocol; browser uses ws-scrcpy. Black screen: Wake, reload mirror, or open panel in new tab.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -323,19 +356,30 @@ export function LiveAndroidWorkstation({
|
||||
</button>
|
||||
) : null}
|
||||
{scrcpyUrl ? (
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
|
||||
>
|
||||
{t("新窗口打开 :5000", "Open :5000 tab")}
|
||||
</a>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorReloadKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-zinc-200"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("重载投屏", "Reload mirror")}
|
||||
</button>
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
|
||||
>
|
||||
{t("新窗口打开", "New tab")}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{scrcpyUrl ? (
|
||||
<iframe
|
||||
key={`ws-scrcpy-${mirrorReloadKey}`}
|
||||
title="ws-scrcpy"
|
||||
src={scrcpyUrl}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
@@ -349,15 +393,15 @@ export function LiveAndroidWorkstation({
|
||||
<div>
|
||||
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"「Panda / WebUSB」在浏览器里直连手机:手机必须插在您正在用浏览器看页面的这台电脑的 USB 上(走 WebUSB,不是板子上的 ADB)。官方演示见 ",
|
||||
"Panda uses WebUSB: phone must plug into the PC running this browser, not the board. Demo: ",
|
||||
"Panda / WebUSB:手机必须插在您正在打开本页的电脑 USB 上(与板载 ADB 无关)。与官方 scrcpy 协议生态同源,适合本地调试。仓库:",
|
||||
"Panda WebUSB: phone plugs into the PC running this browser. Same scrcpy-protocol family. Repo: ",
|
||||
)}
|
||||
<a className="text-cyan-300 hover:underline" href={PANDA_WEB_SCRCPY} target="_blank" rel="noreferrer">
|
||||
panda-web-scrcpy
|
||||
<a className="text-cyan-300 hover:underline" href={LINK_PANDA_GH} target="_blank" rel="noreferrer">
|
||||
PandaTestGrid/panda-web-scrcpy
|
||||
</a>
|
||||
。
|
||||
</p>
|
||||
<iframe
|
||||
key={`panda-${mirrorReloadKey}`}
|
||||
title="panda-web-scrcpy"
|
||||
src={PANDA_WEB_SCRCPY}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
@@ -680,10 +724,25 @@ export function LiveAndroidWorkstation({
|
||||
value={logcatLines}
|
||||
onChange={(e) => setLogcatLines(e.target.value)}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-[11px] text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-white/20 bg-black/40"
|
||||
checked={logcatLive}
|
||||
onChange={(e) => setLogcatLive(e.target.checked)}
|
||||
/>
|
||||
{t("每 3 秒自动刷新", "Auto every 3s")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void refreshLogcat()}
|
||||
onClick={() => {
|
||||
if (!androidSerial) {
|
||||
notify(t("请选择设备", "Pick a device"));
|
||||
return;
|
||||
}
|
||||
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")}
|
||||
|
||||
@@ -176,6 +176,8 @@ export default function LiveControlApp() {
|
||||
const [currentProc, setCurrentProc] = useState("");
|
||||
const [urlConfig, setUrlConfig] = useState("");
|
||||
const [douyinUrl, setDouyinUrl] = useState("");
|
||||
const [businessYoutubeKey, setBusinessYoutubeKey] = useState("");
|
||||
const [syncYoutubeIni, setSyncYoutubeIni] = useState(false);
|
||||
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
|
||||
const [androidSerial, setAndroidSerial] = useState("");
|
||||
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
|
||||
@@ -268,9 +270,13 @@ export default function LiveControlApp() {
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const b = await fetchJson<{ streams?: { douyin_input_url?: string } }>("/hub/business/douyinyoutube");
|
||||
const b = await fetchJson<{
|
||||
streams?: { douyin_input_url?: string; youtube_rtmp_key?: string };
|
||||
}>("/hub/business/douyinyoutube");
|
||||
const u = b.streams?.douyin_input_url;
|
||||
if (u) setDouyinUrl(u);
|
||||
const k = b.streams?.youtube_rtmp_key;
|
||||
if (k !== undefined && k !== null) setBusinessYoutubeKey(k);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -366,12 +372,23 @@ export default function LiveControlApp() {
|
||||
const saveBusinessMeta = async () => {
|
||||
setBusy("save:meta");
|
||||
try {
|
||||
await fetchJson("/hub/business/douyinyoutube", {
|
||||
const data = await fetchJson<{ message?: string }>("/hub/business/douyinyoutube", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ streams: { douyin_input_url: douyinUrl } }),
|
||||
body: JSON.stringify({
|
||||
streams: {
|
||||
douyin_input_url: douyinUrl,
|
||||
youtube_rtmp_key: businessYoutubeKey,
|
||||
},
|
||||
sync_youtube_ini: syncYoutubeIni,
|
||||
}),
|
||||
});
|
||||
notify(t("业务元数据已同步", "Business metadata saved"));
|
||||
const detail = (data.message || "").trim();
|
||||
notify(
|
||||
detail.length > 0 && detail.length < 280
|
||||
? detail
|
||||
: t("业务元数据已同步", "Business metadata saved"),
|
||||
);
|
||||
} catch (e) {
|
||||
notify((e as Error).message);
|
||||
} finally {
|
||||
@@ -1104,6 +1121,10 @@ export default function LiveControlApp() {
|
||||
setUrlConfig={setUrlConfig}
|
||||
douyinUrl={douyinUrl}
|
||||
setDouyinUrl={setDouyinUrl}
|
||||
businessYoutubeKey={businessYoutubeKey}
|
||||
setBusinessYoutubeKey={setBusinessYoutubeKey}
|
||||
syncYoutubeIni={syncYoutubeIni}
|
||||
setSyncYoutubeIni={setSyncYoutubeIni}
|
||||
onRunProcess={(a) => void runProcess(a)}
|
||||
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
|
||||
onSaveBusinessMeta={() => void saveBusinessMeta()}
|
||||
@@ -1122,6 +1143,10 @@ export default function LiveControlApp() {
|
||||
setUrlConfig={setUrlConfig}
|
||||
douyinUrl={douyinUrl}
|
||||
setDouyinUrl={setDouyinUrl}
|
||||
businessYoutubeKey={businessYoutubeKey}
|
||||
setBusinessYoutubeKey={setBusinessYoutubeKey}
|
||||
syncYoutubeIni={syncYoutubeIni}
|
||||
setSyncYoutubeIni={setSyncYoutubeIni}
|
||||
onRunProcess={(a) => void runProcess(a)}
|
||||
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
|
||||
onSaveBusinessMeta={() => void saveBusinessMeta()}
|
||||
|
||||
241
web-console/components/live-product/LiveProcessLiveLogCard.tsx
Normal file
241
web-console/components/live-product/LiveProcessLiveLogCard.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { extractDouyinRoomHints } from "@/lib/youtubeConfigUtils";
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
type StatusPayload = {
|
||||
process_status?: string;
|
||||
recent_log?: string;
|
||||
recent_error?: string;
|
||||
};
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return `${m}m ${s}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
const mm = m % 60;
|
||||
return `${h}h ${mm}m`;
|
||||
}
|
||||
|
||||
function statusLine(t: TFn, raw: string | undefined): string {
|
||||
const s = (raw || "unknown").toLowerCase();
|
||||
if (s === "online" || s === "running") return t("运行中(PM2/进程在线)", "Running (process online)");
|
||||
if (s === "stopped" || s === "stopping") return t("已停止", "Stopped");
|
||||
if (s === "not_found" || s === "errored") return t("未运行或未找到", "Not running / not found");
|
||||
if (s === "invalid") return t("无效进程名", "Invalid process");
|
||||
return raw || "—";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
currentProc: string;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
/** 用于源站房间线索(与 Electron 一致) */
|
||||
urlListText: string;
|
||||
/** YouTube 密钥尾码;非 YouTube 进程可传空并关 showKeyRow */
|
||||
keySuffixUi: string;
|
||||
showKeyRow?: boolean;
|
||||
pollMs?: number;
|
||||
};
|
||||
|
||||
export function LiveProcessLiveLogCard({
|
||||
t,
|
||||
currentProc,
|
||||
fetchJson,
|
||||
urlListText,
|
||||
keySuffixUi,
|
||||
showKeyRow = true,
|
||||
pollMs = 1000,
|
||||
}: Props) {
|
||||
const [processStatus, setProcessStatus] = useState<string>("");
|
||||
const [recentLog, setRecentLog] = useState("");
|
||||
const [recentError, setRecentError] = useState("");
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [liveSince, setLiveSince] = useState<number | null>(null);
|
||||
const [nowTick, setNowTick] = useState(0);
|
||||
const outRef = useRef<HTMLPreElement>(null);
|
||||
const errRef = useRef<HTMLPreElement>(null);
|
||||
const prevOnlineRef = useRef(false);
|
||||
|
||||
const douyinHints = extractDouyinRoomHints(urlListText);
|
||||
|
||||
const pull = useCallback(async () => {
|
||||
if (!currentProc) return;
|
||||
try {
|
||||
const data = await fetchJson<StatusPayload>(
|
||||
`/status?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
const ps = data.process_status || "unknown";
|
||||
setProcessStatus(ps);
|
||||
setRecentLog(data.recent_log || "");
|
||||
setRecentError(data.recent_error || "");
|
||||
const online = /^(online|running)$/i.test(ps);
|
||||
if (online && !prevOnlineRef.current) {
|
||||
setLiveSince(Date.now());
|
||||
}
|
||||
if (!online) {
|
||||
setLiveSince(null);
|
||||
}
|
||||
prevOnlineRef.current = online;
|
||||
} catch {
|
||||
setProcessStatus("error");
|
||||
}
|
||||
}, [currentProc, fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
void pull();
|
||||
}, [pull]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || !currentProc) return;
|
||||
const id = window.setInterval(() => void pull(), pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [paused, currentProc, pull, pollMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") void pull();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, [pull]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveSince) return;
|
||||
const id = window.setInterval(() => setNowTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [liveSince]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const el = outRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [recentLog, paused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const el = errRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [recentError, paused]);
|
||||
|
||||
const durationLabel =
|
||||
liveSince == null
|
||||
? t("—(未检测到在线)", "— (not online)")
|
||||
: fmtDuration(Math.max(0, Math.floor((Date.now() - liveSince) / 1000)));
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-emerald-500/20 bg-gradient-to-b from-emerald-500/[0.07] to-zinc-950/80 p-6 ring-1 ring-white/[0.04]">
|
||||
<span className="sr-only" aria-hidden>
|
||||
{nowTick}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("当前直播日志", "Live process log")}</h3>
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
|
||||
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void pull()}
|
||||
className="inline-flex items-center gap-1 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]"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("立即拉取", "Refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs ring-1 ${
|
||||
paused
|
||||
? "bg-amber-500/20 text-amber-100 ring-amber-500/35"
|
||||
: "border border-white/10 bg-black/30 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||
{paused ? t("继续自动刷新", "Resume") : t("暂停自动刷新", "Pause")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-white/[0.06] bg-black/25 p-4">
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-zinc-500">{t("进程状态", "Status")}</span>
|
||||
<span className="font-medium text-zinc-200">{statusLine(t, processStatus)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-zinc-500">{t("本次在线时长(估算)", "Online duration (est.)")}</span>
|
||||
<span className="font-mono text-cyan-200/90">{durationLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showKeyRow ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="text-zinc-500">{t("YouTube key 尾码(参考)", "YT key suffix")}</span>
|
||||
<span className="font-mono text-zinc-300">{keySuffixUi || "—"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{douyinHints.length > 0 ? (
|
||||
<div className="text-xs">
|
||||
<span className="text-zinc-500">{t("源站房间线索", "Douyin room hints")}</span>
|
||||
<span className="ml-2 inline-flex flex-wrap gap-1">
|
||||
{douyinHints.map((id, i) => (
|
||||
<a
|
||||
key={`${id}-${i}`}
|
||||
href={`https://live.douyin.com/${id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-violet-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
{id}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
{t("(地址列表中未解析到抖音房间线索)", "(No Douyin room id in URL list)")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{processStatus !== "not_found" && processStatus !== "invalid" ? (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400/90">
|
||||
{t("进程输出(stdout / 合并日志)", "Process output")}
|
||||
</div>
|
||||
<pre
|
||||
ref={outRef}
|
||||
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-emerald-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-emerald-100/95"
|
||||
>
|
||||
{recentLog.trim() || t("(暂无)", "(empty)")}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-rose-400/90">
|
||||
{t("异常信息(stderr)", "Stderr")}
|
||||
</div>
|
||||
<pre
|
||||
ref={errRef}
|
||||
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-rose-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-rose-100/95"
|
||||
>
|
||||
{recentError.trim() || t("(暂无)", "(empty)")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-xs text-zinc-500">
|
||||
{t("进程未在 PM2 / 本地注册表中运行,日志区域已隐藏。", "Process not running — log panes hidden.")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Cable, Copy, Monitor, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
@@ -53,6 +55,10 @@ type Props = {
|
||||
setUrlConfig: (v: string) => void;
|
||||
douyinUrl: string;
|
||||
setDouyinUrl: (v: string) => void;
|
||||
businessYoutubeKey: string;
|
||||
setBusinessYoutubeKey: (v: string) => void;
|
||||
syncYoutubeIni: boolean;
|
||||
setSyncYoutubeIni: (v: boolean) => void;
|
||||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||||
onSaveUrlConfig: (content?: string) => void;
|
||||
onSaveBusinessMeta: () => void;
|
||||
@@ -70,6 +76,10 @@ export function LiveTiktokHdmiView({
|
||||
setUrlConfig,
|
||||
douyinUrl,
|
||||
setDouyinUrl,
|
||||
businessYoutubeKey,
|
||||
setBusinessYoutubeKey,
|
||||
syncYoutubeIni,
|
||||
setSyncYoutubeIni,
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
onSaveBusinessMeta,
|
||||
@@ -272,14 +282,37 @@ export function LiveTiktokHdmiView({
|
||||
</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 (hub metadata)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON。", "Writes business JSON.")}</p>
|
||||
<h3 className="text-sm font-semibold text-white">{t("业务元数据(配置中心)", "Hub business metadata")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("与 YouTube 页共用一份 business JSON;HDMI/TikTok 场景可只填抖音 URL。", "Shared business JSON with YouTube page.")}
|
||||
</p>
|
||||
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
className="mt-1 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/..."
|
||||
/>
|
||||
<label className="mt-3 block text-xs text-zinc-400">
|
||||
{t("YouTube 串流密钥(声明式,可选)", "YouTube key (optional declarative)")}
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={businessYoutubeKey}
|
||||
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
|
||||
placeholder="xxxx-xxxx…"
|
||||
/>
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-white/20 bg-black/40"
|
||||
checked={syncYoutubeIni}
|
||||
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
|
||||
/>
|
||||
<span>{t("同时写入 config/youtube.ini 的 key=", "Also sync key= to config/youtube.ini")}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
@@ -328,6 +361,15 @@ export function LiveTiktokHdmiView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveProcessLiveLogCard
|
||||
t={t}
|
||||
currentProc={currentProc}
|
||||
fetchJson={fetchJson}
|
||||
urlListText={urlConfig}
|
||||
keySuffixUi=""
|
||||
showKeyRow={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
} from "@/lib/youtubeConfigUtils";
|
||||
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
|
||||
import {
|
||||
defaultPm2NameForChannel,
|
||||
loadYoutubeProChannels,
|
||||
@@ -37,6 +38,10 @@ type Props = {
|
||||
setUrlConfig: (v: string) => void;
|
||||
douyinUrl: string;
|
||||
setDouyinUrl: (v: string) => void;
|
||||
businessYoutubeKey: string;
|
||||
setBusinessYoutubeKey: (v: string) => void;
|
||||
syncYoutubeIni: boolean;
|
||||
setSyncYoutubeIni: (v: boolean) => void;
|
||||
onRunProcess: (a: "start" | "stop" | "restart") => void;
|
||||
onSaveUrlConfig: (content?: string) => void;
|
||||
onSaveBusinessMeta: () => void;
|
||||
@@ -53,6 +58,10 @@ export function LiveYoutubeUnmannedView({
|
||||
setUrlConfig,
|
||||
douyinUrl,
|
||||
setDouyinUrl,
|
||||
businessYoutubeKey,
|
||||
setBusinessYoutubeKey,
|
||||
syncYoutubeIni,
|
||||
setSyncYoutubeIni,
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
onSaveBusinessMeta,
|
||||
@@ -169,7 +178,8 @@ export function LiveYoutubeUnmannedView({
|
||||
setYtIniLoading(true);
|
||||
setYtIniStatus(null);
|
||||
try {
|
||||
await fetchJson("/save_config", {
|
||||
const params = new URLSearchParams({ process: currentProc });
|
||||
await fetchJson(`/save_config?${params}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
@@ -236,8 +246,8 @@ export function LiveYoutubeUnmannedView({
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{t(
|
||||
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
|
||||
"Desktop-style live control: single or Pro lanes, no account login.",
|
||||
"对齐 d2y 桌面懒人包「直播」页:单路 / Pro、无 Google 登录;Linux 推荐 PM2 或 systemd 托管下方所选进程。底部为实时日志(轮询 PM2/本地日志文件)。",
|
||||
"Like d2y desktop: single/Pro, no Google login; on Linux use PM2/systemd for the selected process. Bottom: live logs from PM2/local log files.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -497,12 +507,20 @@ export function LiveYoutubeUnmannedView({
|
||||
{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>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-rose-500/45 bg-rose-950/35 p-3 ring-1 ring-rose-500/25"
|
||||
>
|
||||
<p className="text-xs font-semibold text-rose-100/95">
|
||||
{t("覆盖警告", "Overwrite warning")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-rose-100/80">
|
||||
{t(
|
||||
"服务器上通常只有一份 youtube.ini:点此推送会立刻覆盖其中 key=;多路并行请在板上为每路使用独立 PM2 与独立 config 目录,或轮流推送后再开播。",
|
||||
"Hosts usually have a single youtube.ini: this overwrites key=. For parallel lanes use separate PM2 + config dirs per lane, or push then start one at a time.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<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 ?? ""}
|
||||
@@ -561,14 +579,45 @@ export function LiveYoutubeUnmannedView({
|
||||
)}
|
||||
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold text-white">{t("业务元数据(配置中心)", "Hub business metadata")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"写入 business/douyinyoutube.json;可与下方选项同步到仓库 config/youtube.ini。",
|
||||
"Writes business/douyinyoutube.json; optional sync to config/youtube.ini.",
|
||||
)}
|
||||
</p>
|
||||
<label className="mt-4 block text-xs text-zinc-400">{t("抖音直播间 URL", "Douyin live URL")}</label>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
className="mt-1 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/..."
|
||||
/>
|
||||
<label className="mt-3 block text-xs text-zinc-400">
|
||||
{t("YouTube 串流密钥(声明式 youtube_rtmp_key)", "YouTube stream key (declarative)")}
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 font-mono text-sm text-zinc-200"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={businessYoutubeKey}
|
||||
onChange={(e) => setBusinessYoutubeKey(e.target.value)}
|
||||
placeholder="xxxx-xxxx… 或完整 rtmp(s) URL"
|
||||
/>
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-white/20 bg-black/40"
|
||||
checked={syncYoutubeIni}
|
||||
onChange={(e) => setSyncYoutubeIni(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
"同时写入本仓库(进程工作目录)下的 config/youtube.ini 中的 key=(需 Web 已配置 repo 根目录)",
|
||||
"Also write key= to repo config/youtube.ini (FastAPI repo root must match this deployment).",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
@@ -607,6 +656,15 @@ export function LiveYoutubeUnmannedView({
|
||||
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LiveProcessLiveLogCard
|
||||
t={t}
|
||||
currentProc={currentProc}
|
||||
fetchJson={fetchJson}
|
||||
urlListText={mode === "single" ? urlConfig : proUrlDraft}
|
||||
keySuffixUi={keySuffixUi}
|
||||
showKeyRow
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user