This commit is contained in:
eric
2026-03-28 17:16:38 -05:00
parent 37ab25911a
commit 46fb194a02
20 changed files with 962 additions and 331 deletions

View 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>
);
}