This commit is contained in:
eric
2026-03-28 02:19:40 -05:00
parent 504877a969
commit 58a0cac7db
16 changed files with 7136 additions and 0 deletions

BIN
web-console/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-deep: #060912;
--bg-card: rgba(16, 22, 36, 0.78);
--accent: #22d3ee;
--accent2: #a78bfa;
--danger: #f43f5e;
--ok: #34d399;
--text: #e8edf7;
--muted: #94a3b8;
}
body {
color: var(--text);
background: var(--bg-deep);
min-height: 100vh;
font-family: var(--font-geist-sans), system-ui, sans-serif;
}
::selection {
background: rgba(34, 211, 238, 0.25);
}
@layer utilities {
.glass {
@apply border border-white/[0.08] shadow-2xl shadow-black/40;
background: var(--bg-card);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.glow-text {
text-shadow: 0 0 32px rgba(34, 211, 238, 0.28);
}
.btn-focus {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[#060912];
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 12px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.animate-toast-in {
animation: toast-in 0.35s ease-out both;
}
@keyframes pulse-soft {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
.animate-pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}
/* 自定义滚动条,日志区更易读 */
.log-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.35) transparent;
}
.log-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.log-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.35);
border-radius: 6px;
}

View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "直播推流控制台",
description: "YouTube / TikTok / HDMI 推流进程与配置管理",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<div
className="fixed inset-0 -z-10 opacity-90"
style={{
background:
"radial-gradient(ellipse 80% 50% at 50% -20%, rgba(34,211,238,0.22), transparent), radial-gradient(ellipse 60% 40% at 100% 0%, rgba(167,139,250,0.18), transparent), radial-gradient(ellipse 50% 30% at 0% 100%, rgba(59,130,246,0.12), transparent)",
}}
/>
{children}
</body>
</html>
);
}

791
web-console/app/page.tsx Normal file
View File

@@ -0,0 +1,791 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
type Entry = { pm2: string; script: string; label: string };
const apiBase = () =>
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
function stem(script: string) {
const base = script.split(/[/\\]/).pop() || script;
const i = base.lastIndexOf(".");
return i > 0 ? base.slice(0, i) : base;
}
function isYoutubeScript(script: string) {
if (!/\.py$/i.test(script)) return false;
const s = stem(script).toLowerCase();
return s.startsWith("youtube") || s.startsWith("douyin_youtube");
}
function isTiktokScript(script: string) {
return /\.py$/i.test(script) && stem(script).toLowerCase().startsWith("tiktok");
}
function isObsScript(script: string) {
return stem(script).toLowerCase().startsWith("obs");
}
function isWebScript(script: string) {
return stem(script).toLowerCase() === "web";
}
function statusMeta(processStatus: string): {
dot: string;
text: string;
pillClass: string;
} {
switch (processStatus) {
case "online":
return {
dot: "bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]",
text: "运行中",
pillClass: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
};
case "stopped":
return {
dot: "bg-slate-500",
text: "已停止",
pillClass: "border-slate-500/30 bg-slate-500/10 text-slate-300",
};
case "errored":
return {
dot: "bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.45)]",
text: "异常",
pillClass: "border-rose-500/30 bg-rose-500/10 text-rose-200",
};
case "launching":
case "starting":
return {
dot: "bg-amber-400 animate-pulse-soft",
text: "启动中",
pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200",
};
case "waiting restart":
return {
dot: "bg-amber-400",
text: "等待重启",
pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200",
};
case "not_found":
return {
dot: "bg-violet-400",
text: "未启动过",
pillClass: "border-violet-500/30 bg-violet-500/10 text-violet-200",
};
case "invalid":
return {
dot: "bg-slate-600",
text: "选择无效",
pillClass: "border-slate-600/40 bg-slate-800/50 text-slate-400",
};
default:
return {
dot: "bg-slate-500",
text: "检测中…",
pillClass: "border-slate-500/25 bg-slate-800/40 text-slate-400",
};
}
}
function hintForEntry(script: string): string {
if (isObsScript(script))
return "用于从本机 SRS 拉流并在接好显示器的机器上全屏播放HDMI。请先保证 SRS 已启动,再点「启动」。";
if (stem(script).toLowerCase().startsWith("douyin_youtube"))
return "抖音直播转推到 YouTube先填好右侧 youtube.ini密钥和 URL_config直播间地址保存后再启动。";
if (isYoutubeScript(script))
return "YouTube 相关推流:检查 youtube.ini 与 URL_config保存后启动。";
if (isTiktokScript(script))
return "多平台录制/监测:在 URL_config 里填写直播间地址,保存后启动。";
if (isWebScript(script))
return "这是网页控制台本身。一般不要用这里停掉自己;其它机器上可用 PM2 管理。";
return "选中后使用左侧按钮启动或停止,右侧查看日志。";
}
const obsDefault =
"srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10";
export default function Home() {
const [entries, setEntries] = useState<Entry[]>([]);
const [backend, setBackend] = useState<string>("");
const [platform, setPlatform] = useState<string>("");
const [current, setCurrent] = useState("");
const [processStatus, setProcessStatus] = useState("unknown");
const [rawStatus, setRawStatus] = useState("");
const [recentLog, setRecentLog] = useState("");
const [recentErr, setRecentErr] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [bootLoading, setBootLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [logTab, setLogTab] = useState<"out" | "err" | "split">("split");
const [helpOpen, setHelpOpen] = useState(true);
const [confirmStop, setConfirmStop] = useState(false);
const [youtubeIni, setYoutubeIni] = useState("");
const [urlIni, setUrlIni] = useState("");
const [cfgYoutubeStatus, setCfgYoutubeStatus] = useState("就绪");
const [cfgUrlStatus, setCfgUrlStatus] = useState("就绪");
const base = apiBase();
const ent = useMemo(
() => entries.find((e) => e.pm2 === current) || null,
[entries, current],
);
const script = ent?.script || "";
const showYoutube = ent && isYoutubeScript(script);
const showUrl = ent && (isYoutubeScript(script) || isTiktokScript(script));
const showObs = ent && isObsScript(script);
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3200);
}, []);
const fetchJson = useCallback(
async (path: string) => {
const res = await fetch(`${base}${path}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
[base],
);
const refreshBackendInfo = useCallback(async () => {
try {
const info = await fetchJson("/server_info?refresh=true");
setBackend(info.process_backend || "");
setPlatform(info.platform || "");
showToast(
info.process_backend === "pm2"
? "已切换为 PM2 管理"
: "已切换为本地进程模式(未检测到 PM2",
);
} catch {
showToast("刷新后端信息失败");
}
}, [fetchJson, showToast]);
useEffect(() => {
(async () => {
setBootLoading(true);
setLoadError(null);
try {
const [mon, info] = await Promise.all([
fetchJson("/process_monitor"),
fetchJson("/server_info"),
]);
const list: Entry[] = (mon.entries || []).map((e: Entry) => ({
script: e.script,
label: e.label || e.script,
pm2: e.pm2 || stem(e.script),
}));
setEntries(list);
setBackend(info.process_backend || "");
setPlatform(info.platform || "");
setCurrent((prev) => prev || list[0]?.pm2 || "");
} catch {
setLoadError(
"连不上后端。请确认已运行 web.sh / web.bat并用浏览器打开同一地址开发时可先起 uvicorn 再 npm run dev。",
);
} finally {
setBootLoading(false);
}
})();
}, [fetchJson]);
const refreshStatus = useCallback(async () => {
if (!current) return;
try {
const data = await fetchJson(`/status?process=${encodeURIComponent(current)}`);
setProcessStatus(data.process_status || "unknown");
setRawStatus(data.raw_status || "");
setRecentLog(data.recent_log || "");
setRecentErr(data.recent_error || "");
} catch {
setProcessStatus("unknown");
}
}, [current, fetchJson]);
useEffect(() => {
if (!current || !autoRefresh) return;
const t = setInterval(refreshStatus, 1000);
refreshStatus();
return () => clearInterval(t);
}, [current, autoRefresh, refreshStatus]);
useEffect(() => {
if (current && !autoRefresh) void refreshStatus();
}, [current, autoRefresh, refreshStatus]);
const loadYoutube = useCallback(async () => {
if (!current) return;
setCfgYoutubeStatus("加载中…");
try {
const data = await fetchJson(`/get_config?process=${encodeURIComponent(current)}`);
setYoutubeIni(data.content || "");
setCfgYoutubeStatus("已从服务器加载");
} catch (e) {
setCfgYoutubeStatus(`失败: ${(e as Error).message}`);
}
}, [current, fetchJson]);
const saveYoutube = async () => {
if (!current) return;
setCfgYoutubeStatus("保存中…");
try {
const res = await fetch(
`${base}/save_config?process=${encodeURIComponent(current)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: youtubeIni }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
setCfgYoutubeStatus(data.message || "已保存到服务器");
showToast("youtube.ini 已保存");
} catch (e) {
setCfgYoutubeStatus(`失败: ${(e as Error).message}`);
}
};
const loadUrl = useCallback(async () => {
if (!current) return;
setCfgUrlStatus("加载中…");
try {
const data = await fetchJson(`/get_url_config?process=${encodeURIComponent(current)}`);
setUrlIni(data.content || "");
setCfgUrlStatus("已从服务器加载");
} catch (e) {
setCfgUrlStatus(`失败: ${(e as Error).message}`);
}
}, [current, fetchJson]);
const saveUrl = async () => {
if (!current) return;
setCfgUrlStatus("保存中…");
try {
const res = await fetch(
`${base}/save_url_config?process=${encodeURIComponent(current)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: urlIni }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
setCfgUrlStatus(data.message || "已保存到服务器");
showToast("URL 配置已保存");
} catch (e) {
setCfgUrlStatus(`失败: ${(e as Error).message}`);
}
};
useEffect(() => {
if (!current) return;
if (showYoutube) void loadYoutube();
if (showUrl) void loadUrl();
}, [current, showYoutube, showUrl, loadYoutube, loadUrl]);
const runAction = async (action: string) => {
if (!current) return;
setBusy(action);
try {
const res = await fetch(`${base}/${action}?process=${encodeURIComponent(current)}`);
const data = await res.json();
if (!res.ok) {
showToast(String(data.output || data.message || "操作失败"));
return;
}
if (action !== "status") {
const msg =
data.output ?? data.pm2_output ?? data.message ?? "已完成";
showToast(typeof msg === "string" ? msg : JSON.stringify(msg));
setTimeout(refreshStatus, 1200);
} else {
setProcessStatus(data.process_status || "unknown");
setRawStatus(data.raw_status || "");
setRecentLog(data.recent_log || "");
setRecentErr(data.recent_error || "");
}
} catch (e) {
showToast((e as Error).message);
} finally {
setBusy(null);
}
};
const onStopClick = () => {
if (isWebScript(script)) {
showToast("这是控制台进程,停止后网页会失效,请谨慎操作");
}
setConfirmStop(true);
};
const confirmStopRun = async () => {
setConfirmStop(false);
await runAction("stop");
};
const copyObs = async () => {
try {
await navigator.clipboard.writeText(obsDefault);
showToast("已复制到剪贴板");
} catch {
showToast("复制失败,请手动选中文字复制");
}
};
const st = statusMeta(processStatus);
return (
<main className="mx-auto min-h-screen max-w-6xl px-4 pb-16 pt-8 sm:px-6 lg:px-10">
{toast && (
<div
role="status"
className="animate-toast-in fixed bottom-6 left-1/2 z-[100] max-w-[min(90vw,28rem)] -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-center text-sm leading-snug text-cyan-50 shadow-xl shadow-cyan-950/40 backdrop-blur-md"
>
{toast}
</div>
)}
{confirmStop && (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="stop-title"
>
<div className="glass w-full max-w-md rounded-3xl p-6 shadow-2xl">
<h2 id="stop-title" className="text-lg font-semibold text-white">
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-400">
ffmpeg
</p>
{ent && (
<p className="mt-3 rounded-xl bg-white/5 px-3 py-2 font-mono text-xs text-slate-300">
{ent.label} · {ent.script}
</p>
)}
<div className="mt-6 flex flex-wrap justify-end gap-3">
<button
type="button"
className="btn-focus rounded-xl border border-white/15 bg-white/5 px-5 py-2.5 text-sm text-slate-200 transition hover:bg-white/10"
onClick={() => setConfirmStop(false)}
>
</button>
<button
type="button"
className="btn-focus rounded-xl bg-gradient-to-r from-rose-600 to-rose-500 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-rose-900/30 transition hover:from-rose-500 hover:to-rose-400"
onClick={() => void confirmStopRun()}
>
</button>
</div>
</div>
</div>
)}
<header className="mb-8 flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-2xl">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
·
</p>
<h1 className="text-3xl font-bold tracking-tight text-white glow-text sm:text-4xl">
</h1>
<p className="mt-3 text-sm leading-relaxed text-slate-400">
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center lg:flex-col lg:items-end">
<div className="flex flex-wrap justify-end gap-2">
{platform && (
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 font-mono text-[11px] text-slate-400">
{platform}
</span>
)}
{backend && (
<span
className="rounded-full border border-violet-500/25 bg-violet-500/10 px-3 py-1.5 text-xs text-violet-200"
title="有 PM2 时用 PM2没有则由网页直接拉起进程"
>
{backend === "pm2"
? "PM2 管理"
: backend === "local"
? "本地进程(无 PM2"
: backend}
</span>
)}
</div>
<button
type="button"
onClick={() => void refreshBackendInfo()}
className="btn-focus self-end rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs text-slate-300 transition hover:border-cyan-500/30 hover:text-white"
title="重装 PM2 后可点这里重新识别"
>
PM2
</button>
</div>
</header>
<section className="mb-8 overflow-hidden rounded-3xl border border-cyan-500/15 bg-gradient-to-br from-cyan-950/40 via-slate-900/30 to-violet-950/25">
<button
type="button"
onClick={() => setHelpOpen((o) => !o)}
className="btn-focus flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition hover:bg-white/[0.04]"
>
<span className="text-sm font-semibold text-cyan-100/95">
</span>
<span className="text-cyan-400/80">{helpOpen ? "收起 ▲" : "展开 ▼"}</span>
</button>
{helpOpen && (
<div className="space-y-4 border-t border-white/10 px-5 py-5 text-sm leading-relaxed text-slate-300">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<p className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-cyan-500/20 text-sm font-bold text-cyan-300">
1
</p>
<p className="font-medium text-white"></p>
<p className="mt-1 text-xs text-slate-400">
<code className="rounded bg-black/40 px-1.5 py-0.5 text-cyan-200/90">web.sh</code>{" "}
Windows <code className="rounded bg-black/40 px-1.5 py-0.5">web.bat</code>
</p>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<p className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-violet-500/20 text-sm font-bold text-violet-300">
2
</p>
<p className="font-medium text-white"></p>
<p className="mt-1 text-xs text-slate-400">
YouTubeTikTok
</p>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<p className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/20 text-sm font-bold text-emerald-300">
3
</p>
<p className="font-medium text-white"></p>
<p className="mt-1 text-xs text-slate-400">
绿
</p>
</div>
</div>
<p className="rounded-xl border border-amber-500/20 bg-amber-950/20 px-4 py-3 text-xs text-amber-100/90">
<strong className="text-amber-200">HDMI / OBS</strong>
SRSOBS SRT
</p>
</div>
)}
</section>
{bootLoading && (
<div className="glass flex flex-col items-center justify-center gap-4 rounded-3xl py-24">
<div className="h-10 w-10 rounded-full border-2 border-cyan-400/30 border-t-cyan-400 animate-spin" />
<p className="text-sm text-slate-400"></p>
</div>
)}
{!bootLoading && loadError && (
<div className="glass rounded-3xl border border-rose-500/25 bg-rose-950/20 p-8 text-center">
<p className="text-base font-medium text-rose-200"></p>
<p className="mx-auto mt-3 max-w-lg text-sm leading-relaxed text-slate-400">
{loadError}
</p>
<button
type="button"
className="btn-focus mt-6 rounded-full bg-cyan-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-cyan-500"
onClick={() => window.location.reload()}
>
</button>
</div>
)}
{!bootLoading && !loadError && !entries.length && (
<div className="glass rounded-3xl p-10 text-center">
<p className="text-slate-300"></p>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-500">
tiktok*.pyyoutube*.pydouyin_youtube*.py obs Linux .shWindows .bat
</p>
</div>
)}
{!bootLoading && !loadError && entries.length > 0 && (
<div className="grid gap-6 lg:grid-cols-12 lg:gap-8">
<aside className="glass rounded-3xl p-6 lg:col-span-4 xl:col-span-3">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
</label>
<select
className="btn-focus mt-2 w-full cursor-pointer rounded-2xl border border-white/12 bg-slate-950/60 px-4 py-3.5 text-sm text-white outline-none transition hover:border-cyan-500/35"
value={current}
onChange={(e) => setCurrent(e.target.value)}
>
{entries.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} {e.script}
</option>
))}
</select>
{ent && (
<p className="mt-4 rounded-2xl border border-white/6 bg-black/25 p-3 text-xs leading-relaxed text-slate-400">
{hintForEntry(ent.script)}
</p>
)}
<div className="mt-5 flex items-center gap-3">
<span className={`h-3 w-3 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${st.pillClass}`}
>
{st.text}
</span>
{ent && (
<span className="truncate font-mono text-[11px] text-slate-500">
{ent.pm2}
</span>
)}
</div>
<label className="mt-3 flex cursor-pointer items-center gap-2 text-xs text-slate-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="h-3.5 w-3.5 rounded border-white/20 bg-slate-900 text-cyan-500 focus:ring-cyan-500/40"
/>
</label>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-2">
<button
type="button"
disabled={!!busy}
title="启动当前选中的程序"
onClick={() => void runAction("start")}
className="btn-focus col-span-2 rounded-2xl bg-gradient-to-r from-emerald-600 to-cyan-600 py-3 text-sm font-semibold text-white shadow-lg shadow-cyan-900/25 transition hover:from-emerald-500 hover:to-cyan-500 disabled:opacity-45"
>
{busy === "start" ? "正在启动…" : "启动"}
</button>
<button
type="button"
disabled={!!busy}
title="安全停止,会结束相关 ffmpeg"
onClick={onStopClick}
className="btn-focus rounded-2xl border border-rose-500/35 bg-rose-950/30 py-3 text-sm font-medium text-rose-100 transition hover:bg-rose-900/40 disabled:opacity-45"
>
{busy === "stop" ? "停止中…" : "停止"}
</button>
<button
type="button"
disabled={!!busy}
title="先停再起"
onClick={() => void runAction("restart")}
className="btn-focus rounded-2xl border border-amber-500/30 bg-amber-950/20 py-3 text-sm font-medium text-amber-100 transition hover:bg-amber-900/30 disabled:opacity-45"
>
{busy === "restart" ? "重启中…" : "重启"}
</button>
<button
type="button"
disabled={!!busy}
title="立即拉一次状态与日志"
onClick={() => void runAction("status")}
className="btn-focus col-span-2 rounded-2xl border border-white/12 bg-white/[0.05] py-2.5 text-sm text-slate-200 transition hover:bg-white/10 disabled:opacity-45"
>
{busy === "status" ? "刷新中…" : "手动刷新状态"}
</button>
</div>
</aside>
<section className="glass rounded-3xl p-6 lg:col-span-8 xl:col-span-9">
<div className="mb-5 flex flex-col gap-3 border-b border-white/8 pb-5 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-white"></h2>
<div className="flex flex-wrap gap-2 rounded-2xl border border-white/10 bg-black/20 p-1">
{(
[
["split", "双栏"],
["out", "仅运行"],
["err", "仅错误"],
] as const
).map(([id, lab]) => (
<button
key={id}
type="button"
onClick={() => setLogTab(id)}
className={`rounded-xl px-3 py-1.5 text-xs font-medium transition ${
logTab === id
? "bg-cyan-500/25 text-cyan-100"
: "text-slate-500 hover:text-slate-300"
}`}
>
{lab}
</button>
))}
</div>
</div>
{showObs && (
<div className="mb-5 rounded-2xl border border-violet-500/25 bg-violet-950/25 p-5">
<h3 className="text-sm font-semibold text-violet-100">
OBS HDMI OBS
</h3>
<p className="mt-2 text-xs leading-relaxed text-slate-400">
OBS SRS {" "}
<code className="text-violet-200/90">127.0.0.1</code> IP
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<code className="log-scroll flex-1 overflow-x-auto rounded-xl border border-white/10 bg-black/45 px-4 py-3 font-mono text-[11px] leading-relaxed text-emerald-300/95">
{obsDefault}
</code>
<button
type="button"
onClick={() => void copyObs()}
className="btn-focus shrink-0 rounded-xl bg-violet-600 px-5 py-3 text-sm font-medium text-white hover:bg-violet-500 sm:w-auto"
>
</button>
</div>
</div>
)}
{showYoutube && (
<div className="mb-5 rounded-2xl border border-cyan-500/20 bg-cyan-950/15 p-5">
<h3 className="text-sm font-semibold text-cyan-100">
YouTube youtube.ini
</h3>
<p className="mt-1 text-xs text-slate-500">
</p>
<textarea
value={youtubeIni}
onChange={(e) => setYoutubeIni(e.target.value)}
className="log-scroll mt-3 min-h-[8rem] w-full resize-y rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-xs leading-relaxed text-slate-200 outline-none transition focus:border-cyan-500/40 focus:ring-1 focus:ring-cyan-500/30"
spellCheck={false}
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void loadYoutube()}
className="btn-focus rounded-xl border border-cyan-500/35 bg-cyan-950/30 px-4 py-2 text-xs font-medium text-cyan-100 hover:bg-cyan-900/35"
>
</button>
<button
type="button"
onClick={() => void saveYoutube()}
className="btn-focus rounded-xl bg-cyan-600 px-4 py-2 text-xs font-semibold text-white hover:bg-cyan-500"
>
</button>
<span className="text-xs text-slate-500">{cfgYoutubeStatus}</span>
</div>
</div>
)}
{showUrl && (
<div className="mb-5 rounded-2xl border border-violet-500/20 bg-violet-950/15 p-5">
<h3 className="text-sm font-semibold text-violet-100">
URL_config.ini
</h3>
<p className="mt-1 text-xs text-slate-500">
</p>
<textarea
value={urlIni}
onChange={(e) => setUrlIni(e.target.value)}
className="log-scroll mt-3 min-h-[7rem] w-full resize-y rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-xs leading-relaxed text-slate-200 outline-none transition focus:border-violet-500/40 focus:ring-1 focus:ring-violet-500/30"
spellCheck={false}
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void loadUrl()}
className="btn-focus rounded-xl border border-violet-500/35 bg-violet-950/30 px-4 py-2 text-xs font-medium text-violet-100 hover:bg-violet-900/35"
>
</button>
<button
type="button"
onClick={() => void saveUrl()}
className="btn-focus rounded-xl bg-violet-600 px-4 py-2 text-xs font-semibold text-white hover:bg-violet-500"
>
</button>
<span className="text-xs text-slate-500">{cfgUrlStatus}</span>
</div>
</div>
)}
<div
className={
logTab === "split"
? "grid gap-4 lg:grid-cols-2"
: "grid gap-4"
}
>
{(logTab === "split" || logTab === "out") && (
<div>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-emerald-400/90">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
</h3>
<pre className="log-scroll max-h-[min(50vh,22rem)] overflow-auto rounded-2xl border border-emerald-500/15 bg-black/55 p-4 font-mono text-[11px] leading-relaxed text-emerald-100/95">
{recentLog?.trim() ? recentLog : "暂无输出(若刚启动,可等几秒或点「手动刷新状态」)"}
</pre>
</div>
)}
{(logTab === "split" || logTab === "err") && (
<div>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-rose-400/90">
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
</h3>
<pre className="log-scroll max-h-[min(50vh,22rem)] overflow-auto rounded-2xl border border-rose-500/15 bg-black/55 p-4 font-mono text-[11px] leading-relaxed text-rose-100/95">
{recentErr?.trim() ? recentErr : "暂无错误(若推流失败,这里通常会有红字提示)"}
</pre>
</div>
)}
</div>
<details className="group mt-6 rounded-2xl border border-white/8 bg-black/20 open:border-white/15">
<summary className="cursor-pointer list-none px-4 py-3 text-xs text-slate-500 transition group-open:text-slate-300 [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-center gap-2">
PM2 /
<span className="text-slate-600 group-open:hidden"></span>
<span className="hidden text-slate-600 group-open:inline"></span>
</span>
</summary>
<pre className="log-scroll max-h-52 overflow-auto border-t border-white/8 p-4 font-mono text-[10px] leading-relaxed text-slate-500">
{rawStatus || "—"}
</pre>
</details>
</section>
</div>
)}
<footer className="mt-12 border-t border-white/8 pt-8 text-center text-[11px] leading-relaxed text-slate-600">
<p>
</p>
<p className="mt-2 text-slate-700">
<span className="font-mono text-slate-500">./start.sh</span> {" "}
<span className="font-mono text-slate-500">start.bat</span>{" "}
<span className="font-mono text-slate-500">./dev.sh</span> /{" "}
<span className="font-mono text-slate-500">npm run dev</span>
</p>
</footer>
</main>
);
}