Files
2026-03-28 02:31:33 -05:00

807 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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">
Windows / Ubuntu <code className="rounded bg-black/40 px-1.5 py-0.5">git pull</code> /{" "}
<code className="rounded bg-black/40 px-1.5 py-0.5">start.bat</code> {" "}
<code className="rounded bg-black/40 px-1.5 py-0.5">./start.sh</code>{" "}
<code className="rounded bg-black/40 px-1.5 py-0.5">dev.bat</code> /{" "}
<code className="rounded bg-black/40 px-1.5 py-0.5">./dev.sh</code> Pythonffmpegnpm
SRS{" "}
<code className="text-cyan-200/90">config/runtime.env.example</code> {" "}
<code className="text-cyan-200/90">config/runtime.env</code> {" "}
<code className="text-cyan-200/90">PORT</code>Docker <code className="text-cyan-200/90">8101</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">
<strong className="text-slate-300"> YouTube / YouTube</strong>
TikTokOBS
</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>
SRS <code className="text-amber-200/90">10080</code> Web OBS
SRT
</p>
<p className="rounded-xl border border-slate-600/30 bg-slate-950/40 px-4 py-3 text-xs text-slate-400">
<strong className="text-slate-300">访</strong>
<code className="text-slate-300">0.0.0.0</code> WiFi/访{" "}
<code className="text-slate-300">http://这台机器的IP:端口/</code> 即可(注意防火墙放行端口)。
</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>Docker {" "}
<span className="font-mono text-slate-500">8101</span>{" "}
<span className="font-mono text-slate-500">config/runtime.env.example</span>
</p>
</footer>
</main>
);
}