This commit is contained in:
eric
2026-03-29 01:17:00 -05:00
parent af927c6dca
commit 8bce529255
10 changed files with 775 additions and 300 deletions

View File

@@ -9,27 +9,32 @@ import {
Clock,
Cpu,
HardDrive,
Languages,
Loader2,
Menu,
MonitorPlay,
Moon,
Network,
RefreshCw,
Server,
Settings,
Smartphone,
Sparkles,
Sun,
Wifi,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
type PortalTheme = "dark" | "light";
type ViewKey =
| "dashboard"
| "services"
@@ -64,7 +69,13 @@ type HubDashboard = {
platform?: string;
ips?: string[];
quick_links?: Record<string, string>;
neko_login?: { user_password?: string; admin_password?: string; note_zh?: string };
neko_login?: {
user_login?: string;
user_password?: string;
admin_login?: string;
admin_password?: string;
note_zh?: string;
};
neko_firefox_enabled?: boolean;
};
services?: Array<{
@@ -164,6 +175,7 @@ function statusPill(status: string) {
export default function LiveControlApp() {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
const [portalTheme, setPortalTheme] = useState<PortalTheme>("dark");
const [view, setView] = useState<ViewKey>("dashboard");
const [mobileNav, setMobileNav] = useState(false);
const [dash, setDash] = useState<HubDashboard | null>(null);
@@ -215,12 +227,23 @@ export default function LiveControlApp() {
useEffect(() => {
const s = window.localStorage.getItem("live-hub-lang");
if (s === "zh" || s === "en") setLang(s);
const th = window.localStorage.getItem("live-hub-theme");
if (th === "light" || th === "dark") setPortalTheme(th);
}, []);
useEffect(() => {
window.localStorage.setItem("live-hub-lang", lang);
}, [lang]);
useEffect(() => {
document.documentElement.setAttribute("data-theme", portalTheme);
try {
window.localStorage.setItem("live-hub-theme", portalTheme);
} catch {
/* ignore */
}
}, [portalTheme]);
useEffect(() => {
try {
if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false);
@@ -315,7 +338,7 @@ export default function LiveControlApp() {
const runProcess = async (action: "start" | "stop" | "restart", processOverride?: string) => {
const proc = (processOverride ?? currentProc).trim();
if (!proc) return;
setBusy(`proc:${action}`);
setBusy(`proc:${action}:${proc}`);
try {
const path = `/${action}`;
const data = await fetchJson<{ output?: string; message?: string }>(path, {
@@ -394,10 +417,29 @@ export default function LiveControlApp() {
const totalServices = dash?.services?.length ?? 0;
return (
<div className="lp-root min-h-screen bg-[#050508] text-zinc-100 selection:bg-violet-500/30">
<div className="lp-noise pointer-events-none fixed inset-0 z-0 opacity-[0.22]" aria-hidden />
<div className="lp-aurora pointer-events-none fixed -left-1/4 top-0 h-[42rem] w-[42rem] rounded-full bg-violet-600/20 blur-[120px]" aria-hidden />
<div className="lp-aurora2 pointer-events-none fixed bottom-0 right-0 h-[36rem] w-[36rem] rounded-full bg-cyan-500/10 blur-[100px]" aria-hidden />
<div
className={`lp-root min-h-screen transition-colors duration-300 ${
portalTheme === "light"
? "bg-slate-100 text-slate-900 selection:bg-violet-400/25"
: "bg-[#050508] text-zinc-100 selection:bg-violet-500/30"
}`}
>
<div
className={`lp-noise pointer-events-none fixed inset-0 z-0 ${portalTheme === "light" ? "opacity-[0.06]" : "opacity-[0.22]"}`}
aria-hidden
/>
<div
className={`lp-aurora pointer-events-none fixed -left-1/4 top-0 h-[42rem] w-[42rem] rounded-full blur-[120px] ${
portalTheme === "light" ? "bg-violet-400/15" : "bg-violet-600/20"
}`}
aria-hidden
/>
<div
className={`lp-aurora2 pointer-events-none fixed bottom-0 right-0 h-[36rem] w-[36rem] rounded-full blur-[100px] ${
portalTheme === "light" ? "bg-cyan-400/10" : "bg-cyan-500/10"
}`}
aria-hidden
/>
{toast ? (
<motion.div
@@ -411,17 +453,25 @@ export default function LiveControlApp() {
<div className="relative z-10 flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/[0.06] bg-zinc-950/40 px-4 py-6 backdrop-blur-2xl lg:flex">
<aside
className={`hidden w-64 shrink-0 flex-col border-r px-4 py-6 backdrop-blur-2xl lg:flex ${
portalTheme === "light" ? "border-slate-200/90 bg-white/70" : "border-white/[0.06] bg-zinc-950/40"
}`}
>
<div className="flex items-center gap-2 px-2">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 shadow-lg shadow-violet-500/25">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-zinc-500">Live Hub</p>
<p className="text-sm font-semibold text-white">{t("超级业务中心", "Control Center")}</p>
<p className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
Live Hub
</p>
<p className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
{t("超级业务中心", "Control Center")}
</p>
</div>
</div>
<nav className="mt-10 flex flex-col gap-1">
<nav className="mt-10 flex flex-1 flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = view === item.id;
@@ -431,78 +481,121 @@ export default function LiveControlApp() {
type="button"
onClick={() => setView(item.id)}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition ${
active
? "bg-white/[0.08] text-white shadow-inner shadow-white/5"
: "text-zinc-500 hover:bg-white/[0.04] hover:text-zinc-200"
portalTheme === "light"
? active
? "bg-violet-100/90 text-slate-900 shadow-inner shadow-violet-200/50"
: "text-slate-600 hover:bg-slate-200/60 hover:text-slate-900"
: active
? "bg-white/[0.08] text-white shadow-inner shadow-white/5"
: "text-zinc-500 hover:bg-white/[0.04] hover:text-zinc-200"
}`}
>
<Icon className={`h-4 w-4 ${active ? "text-violet-300" : "text-zinc-600"}`} />
<Icon
className={`h-4 w-4 ${
portalTheme === "light"
? active
? "text-violet-600"
: "text-slate-400"
: active
? "text-violet-300"
: "text-zinc-600"
}`}
/>
{t(item.labelZh, item.labelEn)}
{active ? <ChevronRight className="ml-auto h-4 w-4 text-zinc-600" /> : null}
{active ? (
<ChevronRight className={`ml-auto h-4 w-4 ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`} />
) : null}
</button>
);
})}
</nav>
<div className="mt-auto space-y-2 border-t border-white/[0.06] pt-6">
<a
href="/console"
className="flex items-center justify-between rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-xs text-zinc-400 transition hover:border-violet-500/30 hover:text-zinc-200"
>
{t("高级控制台", "Advanced console")}
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
<div className="flex gap-1 rounded-lg bg-black/30 p-1">
<button
type="button"
className={`flex-1 rounded-md py-1.5 text-xs font-medium ${lang === "zh" ? "bg-white/10 text-white" : "text-zinc-600"}`}
onClick={() => setLang("zh")}
>
中文
</button>
<button
type="button"
className={`flex-1 rounded-md py-1.5 text-xs font-medium ${lang === "en" ? "bg-white/10 text-white" : "text-zinc-600"}`}
onClick={() => setLang("en")}
>
EN
</button>
</div>
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-white/[0.06] bg-zinc-950/70 px-4 py-3 backdrop-blur-xl lg:px-8">
<div className="flex items-center gap-3">
<header
className={`sticky top-0 z-20 flex items-center justify-between gap-3 border-b px-4 py-3 backdrop-blur-xl lg:px-8 ${
portalTheme === "light"
? "border-slate-200/80 bg-white/75 text-slate-900"
: "border-white/[0.06] bg-zinc-950/70 text-white"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className="rounded-xl border border-white/10 bg-white/5 p-2 lg:hidden"
className={`rounded-xl border p-2 lg:hidden ${portalTheme === "light" ? "border-slate-200 bg-white/80" : "border-white/10 bg-white/5"}`}
onClick={() => setMobileNav(true)}
aria-label="Menu"
>
<Menu className="h-5 w-5" />
</button>
<div>
<h1 className="text-lg font-semibold tracking-tight text-white lg:text-xl">
<div className="min-w-0">
<h1
className={`truncate text-lg font-semibold tracking-tight lg:text-xl ${
portalTheme === "light" ? "text-slate-900" : "text-white"
}`}
>
{t(
navItems.find((n) => n.id === view)?.labelZh || "",
navItems.find((n) => n.id === view)?.labelEn || "",
)}
</h1>
<p className="text-xs text-zinc-500">
{dash?.stack?.mdns_host || "live.local"} · {t("暗黑", "Dark")} · {t("自动刷新", "Auto refresh")}
<p className={`truncate text-xs ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
{dash?.stack?.mdns_host || "live.local"} ·{" "}
{portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {t("自动刷新", "Auto refresh")}
</p>
</div>
</div>
<button
type="button"
disabled={!!busy}
onClick={() => void refreshDashboard()}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-medium text-zinc-300 transition hover:bg-white/[0.07] disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t("刷新", "Refresh")}
</button>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
<button
type="button"
onClick={() => setPortalTheme((prev) => (prev === "dark" ? "light" : "dark"))}
className={`rounded-xl border p-2 transition ${
portalTheme === "light"
? "border-slate-200 bg-white text-amber-500 shadow-sm"
: "border-white/10 bg-white/[0.06] text-violet-200"
}`}
title={portalTheme === "dark" ? t("切换到日间", "Switch to light") : t("切换到夜间", "Switch to dark")}
aria-label={portalTheme === "dark" ? t("日间模式", "Light mode") : t("夜间模式", "Dark mode")}
aria-pressed={portalTheme === "light"}
>
{portalTheme === "dark" ? <Moon className="h-5 w-5" strokeWidth={2} /> : <Sun className="h-5 w-5" strokeWidth={2} />}
</button>
<button
type="button"
onClick={() => setLang((prev) => (prev === "zh" ? "en" : "zh"))}
className={`relative rounded-xl border p-2 transition ${
portalTheme === "light"
? "border-slate-200 bg-white text-cyan-600 shadow-sm"
: "border-white/10 bg-white/[0.06] text-cyan-200"
}`}
title={lang === "zh" ? "English" : "中文"}
aria-label={t("切换语言", "Toggle language")}
aria-pressed={lang === "en"}
>
<Languages className="h-5 w-5" strokeWidth={2} />
<span
className={`absolute -bottom-0.5 -right-0.5 flex h-4 min-w-[1rem] items-center justify-center rounded px-0.5 text-[9px] font-bold leading-none ${
portalTheme === "light" ? "bg-cyan-100 text-cyan-800" : "bg-cyan-500/30 text-cyan-100"
}`}
>
{lang === "zh" ? "中" : "A"}
</span>
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void refreshDashboard()}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-medium transition disabled:opacity-40 ${
portalTheme === "light"
? "border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
: "border-white/10 bg-white/[0.04] text-zinc-300 hover:bg-white/[0.07]"
}`}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t("刷新", "Refresh")}</span>
</button>
</div>
</header>
<main className="flex-1 px-4 py-6 lg:px-8 lg:py-8">
@@ -522,7 +615,7 @@ export default function LiveControlApp() {
{dashErr}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{[
{
icon: Cpu,
@@ -560,14 +653,14 @@ export default function LiveControlApp() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ y: -3 }}
className="group relative overflow-hidden rounded-2xl border border-white/[0.07] bg-gradient-to-br from-white/[0.06] to-transparent p-5 shadow-xl shadow-black/20"
whileHover={{ y: -2 }}
className="group relative overflow-hidden rounded-xl border border-white/[0.07] bg-gradient-to-br from-white/[0.05] to-transparent p-3 shadow-lg shadow-black/20"
>
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-violet-500/10 blur-2xl transition group-hover:bg-violet-500/20" />
<card.icon className="h-5 w-5 text-violet-300/90" />
<p className="mt-4 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{card.label}</p>
<p className="mt-1 font-mono text-2xl font-semibold text-white">{loading ? "…" : card.value}</p>
<p className="mt-1 text-xs text-zinc-500">{card.sub}</p>
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full bg-violet-500/10 blur-xl transition group-hover:bg-violet-500/18" />
<card.icon className="h-4 w-4 text-violet-300/90" />
<p className="mt-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{card.label}</p>
<p className="mt-0.5 font-mono text-lg font-semibold leading-tight text-white">{loading ? "…" : card.value}</p>
<p className="mt-0.5 text-[10px] text-zinc-500">{card.sub}</p>
</motion.div>
))}
</div>
@@ -699,19 +792,23 @@ export default function LiveControlApp() {
{dash?.stack?.neko_login ? (
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
<h3 className="text-sm font-semibold text-white">Neko {t("登录口令", "passwords")}</h3>
<h3 className="text-sm font-semibold text-white">Neko {t("登录", "sign-in")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{dash.stack.neko_login.note_zh ||
t("Chromium :9200 与 Firefox :9201(若启用)共用下列口令。", "Chromium :9200 and Firefox :9201 share these.")}
t("Chromium :9200 与 Firefox :9201(若启用)共用下列账号策略。", "Chromium :9200 and Firefox :9201 share the same policy.")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("用户", "User")}</p>
<p className="mt-1 text-fuchsia-200">{dash.stack.neko_login.user_password ?? "—"}</p>
<p className="mt-1 text-fuchsia-200">{dash.stack.neko_login.user_login ?? "live"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("口令", "Password")}</p>
<p className="mt-0.5 text-fuchsia-100">{dash.stack.neko_login.user_password ?? "—"}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("管理员", "Admin")}</p>
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_password ?? "—"}</p>
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_login ?? "admin"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("口令", "Password")}</p>
<p className="mt-0.5 text-violet-100">{dash.stack.neko_login.admin_password ?? "—"}</p>
</div>
</div>
<p className="mt-3 text-[10px] text-zinc-600">
@@ -1069,7 +1166,7 @@ export default function LiveControlApp() {
{view === "live_youtube" ? (
<LiveYoutubeUnmannedView
t={t}
busy={!!busy}
busy={busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
@@ -1078,6 +1175,8 @@ export default function LiveControlApp() {
onRunProcess={(a, pm) => void runProcess(a, pm)}
onSaveUrlConfig={(c) => saveUrlConfig(c)}
fetchJson={fetchJson}
liveProcesses={dash?.live?.processes ?? []}
notify={notify}
/>
) : null}
@@ -1116,16 +1215,24 @@ export default function LiveControlApp() {
) : null}
{view === "network" ? (
<div className="mx-auto max-w-3xl space-y-6">
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-violet-300" />
<div className="mx-auto max-w-7xl space-y-6">
<div
className={`rounded-2xl border p-6 ${
portalTheme === "light"
? "border-violet-200/90 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50/80"
: "border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5"
}`}
>
<h3
className={`flex items-center gap-2 text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}
>
<Wifi className={`h-4 w-4 ${portalTheme === "light" ? "text-violet-600" : "text-violet-300"}`} />
{t("ShellCrash 网络面板", "ShellCrash panel")}
</h3>
<p className="mt-2 text-xs text-zinc-400">
<p className={`mt-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t(
"此页仅用于透明代理 / 规则分流。点击下方进入 ShellCrash 专属控制台;流媒体与其它服务请在「服务」页管理。",
"Proxy rules only. Open ShellCrash below; use Services for SRS/Neko/etc.",
"透明代理与规则分流直接在本页完成,无需跳转;流媒体与其它系统服务请在「服务」页管理。",
"Proxy rules and YAML editing are embedded here. Use Services for SRS/Neko/etc.",
)}
</p>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
@@ -1140,25 +1247,31 @@ export default function LiveControlApp() {
>
{n.tx}
</div>
{i < 2 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" /> : null}
{i < 2 ? (
<ChevronRight
className={`hidden h-5 w-5 sm:block ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`}
/>
) : null}
</div>
))}
</div>
</div>
<a
href="/shellcrash"
className="flex items-center justify-between rounded-2xl border border-violet-400/30 bg-gradient-to-r from-violet-500/15 to-fuchsia-500/10 px-5 py-4 text-sm font-semibold text-violet-100 shadow-lg shadow-violet-500/10 transition hover:border-violet-400/50"
<div
className={`overflow-hidden rounded-2xl border shadow-xl ${
portalTheme === "light" ? "border-slate-200/90 bg-white shadow-slate-200/40" : "border-white/[0.08] bg-zinc-950/50 shadow-black/30"
}`}
>
{t("打开 ShellCrash 控制台", "Open ShellCrash console")}
<ArrowUpRight className="h-4 w-4" />
</a>
<div className="max-h-[min(78vh,56rem)] min-h-[22rem] overflow-y-auto overflow-x-hidden">
<LiveConsoleWorkspace embeddedShellCrash portalLang={lang} />
</div>
</div>
</div>
) : null}
{view === "settings" ? (
<div className="mx-auto max-w-lg space-y-6">
<p className="text-sm text-zinc-400">
{t("常用选项(本机偏好);写入 JSON 配置请用高级控制台。", "Local preferences; use Advanced console for JSON.")}
<p className={`text-sm ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t("常用选项(本机偏好);完整文件与 JSON 编辑见下方链接。", "Local preferences; full file/JSON editor via the link below.")}
</p>
<div className="space-y-4 rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
<label className="flex cursor-pointer items-center justify-between gap-4">
@@ -1235,9 +1348,13 @@ export default function LiveControlApp() {
</div>
<a
href="/console?tab=config"
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] py-3 text-sm text-violet-200 transition hover:bg-white/[0.08]"
className={`inline-flex w-full items-center justify-center gap-2 rounded-xl border py-3 text-sm transition ${
portalTheme === "light"
? "border-slate-200 bg-slate-50 text-violet-700 hover:bg-slate-100"
: "border-white/10 bg-white/[0.05] text-violet-200 hover:bg-white/[0.08]"
}`}
>
{t("高级控制台 · 文件与 JSON", "Advanced console · files / JSON")}
{t("完整配置中心(文件 / JSON)", "Full config center (files / JSON)")}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>