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

@@ -191,8 +191,15 @@ function Button({
);
}
export type LiveConsoleWorkspaceProps = {
/** 嵌入门户「网络」页:只渲染 ShellCrash 面板,不改写地址栏、不显示顶栏/Tab/底栏 */
embeddedShellCrash?: boolean;
/** 与门户首页语言同步 */
portalLang?: Lang;
};
/** 全功能控制台:默认从门户 `/` 进入后打开 `/console`。 */
export function LiveConsoleWorkspace() {
export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: LiveConsoleWorkspaceProps) {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
const [theme, setTheme] = useState<ThemeMode>("dark");
@@ -448,6 +455,16 @@ export function LiveConsoleWorkspace() {
}, [fetchJson, refreshAndroidDevices, refreshServices]);
useEffect(() => {
if (embeddedShellCrash) {
setTab("shellcrash");
if (portalLang === "zh" || portalLang === "en") {
setLang(portalLang);
} else {
const hub = window.localStorage.getItem("live-hub-lang");
if (hub === "zh" || hub === "en") setLang(hub);
}
return;
}
const params = new URLSearchParams(window.location.search);
const pathTab = pathnameToTab(window.location.pathname);
const nextTab = params.get("tab");
@@ -459,16 +476,23 @@ export function LiveConsoleWorkspace() {
else if (storedLang === "zh" || storedLang === "en") setLang(storedLang);
const storedTheme = window.localStorage.getItem("live-console-theme");
if (storedTheme === "light" || storedTheme === "dark") setTheme(storedTheme);
}, []);
}, [embeddedShellCrash, portalLang]);
useEffect(() => {
if (embeddedShellCrash) return;
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem("live-console-theme", theme);
}, [theme]);
}, [theme, embeddedShellCrash]);
useEffect(() => {
if (!embeddedShellCrash) return;
if (portalLang === "zh" || portalLang === "en") setLang(portalLang);
}, [embeddedShellCrash, portalLang]);
useEffect(() => {
if (embeddedShellCrash) return;
syncUrl(tab, lang);
}, [lang, syncUrl, tab]);
}, [embeddedShellCrash, lang, syncUrl, tab]);
useEffect(() => {
void boot();
@@ -483,7 +507,7 @@ export function LiveConsoleWorkspace() {
useEffect(() => {
if (booting || error) return;
if (tab !== "overview" && tab !== "modules") return;
if (tab !== "overview" && tab !== "modules" && tab !== "shellcrash") return;
void refreshServices();
const timer = window.setInterval(() => void refreshServices(), 10000);
return () => clearInterval(timer);
@@ -809,102 +833,116 @@ export function LiveConsoleWorkspace() {
return (
<>
<div className="border-b border-white/[0.08] bg-slate-950/80 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-2 px-4 py-2.5 text-xs sm:px-6 lg:px-10">
<a
className="font-medium text-amber-400/95 transition hover:text-amber-300"
href="/"
>
{tr("返回门户首页", "Back to portal")}
</a>
<span className="text-slate-500">
{tr("无人直播 · 本页需与 API 同域(推荐 live.local:8001", "Unmanned live · same origin as API (live.local:8001)")}
</span>
{!embeddedShellCrash ? (
<div className="border-b border-white/[0.08] bg-slate-950/80 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-2 px-4 py-2.5 text-xs sm:px-6 lg:px-10">
<a className="font-medium text-amber-400/95 transition hover:text-amber-300" href="/">
{tr("返回门户首页", "Back to portal")}
</a>
<span className="text-slate-500">
{tr("无人直播 · 本页需与 API 同域(推荐 live.local:8001", "Unmanned live · same origin as API (live.local:8001)")}
</span>
</div>
</div>
</div>
<main className="safe-pb-nav mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-6 sm:px-6 lg:px-10">
) : null}
<main
className={
embeddedShellCrash
? "mx-auto min-h-0 max-w-7xl px-1 pb-4 pt-1 sm:px-3"
: "safe-pb-nav mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-6 sm:px-6 lg:px-10"
}
>
{toast ? (
<div className="animate-toast-in fixed bottom-24 left-1/2 z-50 max-w-[min(92vw,28rem)] -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-center text-sm text-cyan-50 shadow-lg md:bottom-6 [html[data-theme=light]_&]:border-cyan-600/25 [html[data-theme=light]_&]:bg-white/95 [html[data-theme=light]_&]:text-cyan-950">
{toast}
</div>
) : null}
<header className="mb-8 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
{tr("控制平面 · ARM Linux 开发板", "Control plane · ARM Linux SBC")}
</p>
<h1 className="mt-2 text-3xl font-bold text-[color:var(--text)] sm:text-4xl">
{tr("无人直播控制中心", "Unmanned live control center")}
</h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr(
"ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI 采集Neko / Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP采集卡可走视频进 Linux。各模块独立可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Neko/Chromium for browser; USB ADB to AOSP plus optional capture card. Modules are isolated.",
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="meta-chip max-w-full">
{(stack?.mdns_host || "live.local") +
" · " +
(stack?.machine || "unknown") +
" · " +
(stack?.kernel || "unknown")}
{!embeddedShellCrash ? (
<header className="mb-8 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
{tr("控制平面 · ARM Linux 开发板", "Control plane · ARM Linux SBC")}
</p>
<h1 className="mt-2 text-3xl font-bold text-[color:var(--text)] sm:text-4xl">
{tr("无人直播控制中心", "Unmanned live control center")}
</h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr(
"ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI 采集;Neko / Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP,采集卡可走视频进 Linux。各模块独立可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Neko/Chromium for browser; USB ADB to AOSP plus optional capture card. Modules are isolated.",
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="meta-chip max-w-full">
{(stack?.mdns_host || "live.local") +
" · " +
(stack?.machine || "unknown") +
" · " +
(stack?.kernel || "unknown")}
</div>
<Button
className={lang === "zh" ? "border border-cyan-400/50 bg-cyan-500/10" : "border border-white/12 bg-black/20"}
onClick={() => setLang("zh")}
>
</Button>
<Button
className={lang === "en" ? "border border-cyan-400/50 bg-cyan-500/10" : "border border-white/12 bg-black/20"}
onClick={() => setLang("en")}
>
EN
</Button>
<Button
className={theme === "light" ? "border border-amber-400/50 bg-amber-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("light")}
title={tr("日间主题", "Light theme")}
>
{tr("☀️ 日间", "☀️ Light")}
</Button>
<Button
className={theme === "dark" ? "border border-indigo-400/50 bg-indigo-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("dark")}
title={tr("夜间主题", "Dark theme")}
>
{tr("🌙 夜间", "🌙 Dark")}
</Button>
<Button className="border border-white/12 bg-black/20" onClick={() => void boot()}>
{tr("刷新", "Refresh")}
</Button>
</div>
<Button
className={lang === "zh" ? "border border-cyan-400/50 bg-cyan-500/10" : "border border-white/12 bg-black/20"}
onClick={() => setLang("zh")}
>
</Button>
<Button
className={lang === "en" ? "border border-cyan-400/50 bg-cyan-500/10" : "border border-white/12 bg-black/20"}
onClick={() => setLang("en")}
>
EN
</Button>
<Button
className={theme === "light" ? "border border-amber-400/50 bg-amber-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("light")}
title={tr("日间主题", "Light theme")}
>
{tr("☀️ 日间", "☀️ Light")}
</Button>
<Button
className={theme === "dark" ? "border border-indigo-400/50 bg-indigo-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("dark")}
title={tr("夜间主题", "Dark theme")}
>
{tr("🌙 夜间", "🌙 Dark")}
</Button>
<Button className="border border-white/12 bg-black/20" onClick={() => void boot()}>
{tr("刷新", "Refresh")}
</Button>
</div>
</div>
<nav className="tab-strip" aria-label={tr("主导航", "Primary navigation")}>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`tab-btn ${tab === item ? "tab-btn-active" : ""}`}
onClick={() => setTab(item)}
>
{{
overview: tr("总览", "Overview"),
business: tr("直播业务", "Live ops"),
modules: tr("系统服务", "Services"),
shellcrash: "ShellCrash",
android: tr("安卓设备", "Android"),
config: tr("配置中心", "Config"),
}[item]}
</button>
))}
</nav>
</header>
<nav className="tab-strip" aria-label={tr("主导航", "Primary navigation")}>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`tab-btn ${tab === item ? "tab-btn-active" : ""}`}
onClick={() => setTab(item)}
>
{{
overview: tr("总览", "Overview"),
business: tr("直播业务", "Live ops"),
modules: tr("系统服务", "Services"),
shellcrash: "ShellCrash",
android: tr("安卓设备", "Android"),
config: tr("配置中心", "Config"),
}[item]}
</button>
))}
</nav>
</header>
) : (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.08] pb-3">
<h2 className="text-base font-semibold text-[color:var(--text)]">ShellCrash</h2>
<Button className="border border-white/12 bg-black/20" onClick={() => void boot()}>
{tr("刷新", "Refresh")}
</Button>
</div>
)}
{booting ? (
<div className="glass rounded-3xl py-24 text-center text-slate-400">
@@ -1546,36 +1584,38 @@ export function LiveConsoleWorkspace() {
) : null}
</main>
<nav
className="fixed bottom-0 left-0 right-0 z-40 flex justify-around border-t border-white/15 bg-black/75 py-1.5 backdrop-blur-lg [html[data-theme=light]_&]:border-slate-200/60 [html[data-theme=light]_&]:bg-white/90 md:hidden"
style={{ paddingBottom: "max(0.35rem, env(safe-area-inset-bottom))" }}
aria-label={tr("主导航", "Main nav")}
>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`btn-focus flex min-w-0 flex-1 flex-col items-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-medium ${
tab === item ? "text-cyan-300 [html[data-theme=light]_&]:text-cyan-700" : "text-slate-400 [html[data-theme=light]_&]:text-slate-600"
}`}
onClick={() => setTab(item)}
>
<span className="text-lg leading-none" aria-hidden>
{tabEmoji[item]}
</span>
<span className="truncate">
{{
overview: tr("总览", "Home"),
business: tr("业务", "Biz"),
modules: tr("模块", "Mods"),
shellcrash: "SC",
android: tr("安卓", "Droid"),
config: tr("配置", "Cfg"),
}[item]}
</span>
</button>
))}
</nav>
{!embeddedShellCrash ? (
<nav
className="fixed bottom-0 left-0 right-0 z-40 flex justify-around border-t border-white/15 bg-black/75 py-1.5 backdrop-blur-lg [html[data-theme=light]_&]:border-slate-200/60 [html[data-theme=light]_&]:bg-white/90 md:hidden"
style={{ paddingBottom: "max(0.35rem, env(safe-area-inset-bottom))" }}
aria-label={tr("主导航", "Main nav")}
>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`btn-focus flex min-w-0 flex-1 flex-col items-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-medium ${
tab === item ? "text-cyan-300 [html[data-theme=light]_&]:text-cyan-700" : "text-slate-400 [html[data-theme=light]_&]:text-slate-600"
}`}
onClick={() => setTab(item)}
>
<span className="text-lg leading-none" aria-hidden>
{tabEmoji[item]}
</span>
<span className="truncate">
{{
overview: tr("总览", "Home"),
business: tr("业务", "Biz"),
modules: tr("模块", "Mods"),
shellcrash: "SC",
android: tr("安卓", "Droid"),
config: tr("配置", "Cfg"),
}[item]}
</span>
</button>
))}
</nav>
) : null}
</>
);
}