'codex'
This commit is contained in:
BIN
web-console/app/favicon.ico
Normal file
BIN
web-console/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
web-console/app/fonts/GeistMonoVF.woff
Normal file
BIN
web-console/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
web-console/app/fonts/GeistVF.woff
Normal file
BIN
web-console/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
85
web-console/app/globals.css
Normal file
85
web-console/app/globals.css
Normal 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;
|
||||
}
|
||||
42
web-console/app/layout.tsx
Normal file
42
web-console/app/layout.tsx
Normal 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
791
web-console/app/page.tsx
Normal 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">
|
||||
左侧下拉框选「抖音→YouTube」或「TikTok」等。需要填密钥、直播间地址时,在右侧编辑后点「保存」。
|
||||
</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,且显示器已接好。OBS 里选「自定义」推流,服务器填下面给出的 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*.py、youtube*.py、douyin_youtube*.py 或 obs 脚本(Linux 用 .sh,Windows 用 .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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user