1543 lines
76 KiB
TypeScript
1543 lines
76 KiB
TypeScript
"use client";
|
||
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import {
|
||
Activity,
|
||
ArrowUpRight,
|
||
ChevronRight,
|
||
CirclePlay,
|
||
Clock,
|
||
Cpu,
|
||
HardDrive,
|
||
Languages,
|
||
Loader2,
|
||
Menu,
|
||
MonitorPlay,
|
||
Moon,
|
||
Network,
|
||
RefreshCw,
|
||
Server,
|
||
Settings,
|
||
Smartphone,
|
||
Sparkles,
|
||
Sun,
|
||
Wifi,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
|
||
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 type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
|
||
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
|
||
import { fetchJsonResponse } from "@/lib/fetchJson";
|
||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||
|
||
type Lang = "zh" | "en";
|
||
type PortalTheme = "dark" | "light";
|
||
type ViewKey =
|
||
| "dashboard"
|
||
| "services"
|
||
| "live_youtube"
|
||
| "live_tiktok"
|
||
| "android"
|
||
| "network"
|
||
| "settings";
|
||
|
||
const apiBase = () =>
|
||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
|
||
|
||
function tr(lang: Lang, zh: string, en: string) {
|
||
return lang === "zh" ? zh : en;
|
||
}
|
||
|
||
type HubDashboard = {
|
||
snapshot?: {
|
||
cpu_load?: { "1m"?: number; "5m"?: number; "15m"?: number };
|
||
memory?: { total?: number; available?: number; free?: number };
|
||
disk?: { total?: number; free?: number; used?: number };
|
||
netdata?: { available?: boolean; version?: string };
|
||
uptime_seconds?: number;
|
||
};
|
||
hardware?: Record<string, unknown>;
|
||
stack?: {
|
||
hostname?: string;
|
||
mdns_host?: string;
|
||
machine?: string;
|
||
kernel?: string;
|
||
python?: string;
|
||
platform?: string;
|
||
ips?: string[];
|
||
quick_links?: Record<string, string>;
|
||
neko_login?: {
|
||
user_login?: string;
|
||
user_password?: string;
|
||
admin_login?: string;
|
||
admin_password?: string;
|
||
note_zh?: string;
|
||
};
|
||
};
|
||
services?: Array<{
|
||
service_id: string;
|
||
label: string;
|
||
description: string;
|
||
available: boolean;
|
||
running: boolean;
|
||
status: string;
|
||
detail?: string;
|
||
url?: string;
|
||
}>;
|
||
android?: {
|
||
adb_available: boolean;
|
||
devices: Array<{ serial: string; model?: string; state: string; transport?: string }>;
|
||
};
|
||
live?: {
|
||
backend_mode?: string;
|
||
processes?: Array<{
|
||
pm2: string;
|
||
label?: string;
|
||
process_status?: string;
|
||
script?: string;
|
||
}>;
|
||
};
|
||
network?: {
|
||
interfaces?: Array<{
|
||
name?: string;
|
||
rx_bytes?: number;
|
||
tx_bytes?: number;
|
||
rx_packets?: number;
|
||
tx_packets?: number;
|
||
speed_mbps?: number | null;
|
||
operstate?: string;
|
||
}>;
|
||
};
|
||
live_events?: {
|
||
pm2_tail?: string[];
|
||
process_digest?: Array<{ pm2?: string; label?: string; status?: string }>;
|
||
};
|
||
hdmi?: {
|
||
status?: string;
|
||
can_stream_hdmi?: boolean;
|
||
preferred_device?: string;
|
||
video_devices?: string[];
|
||
usb_capture_matches?: string[];
|
||
issues?: string[];
|
||
suggested_ffmpeg_cmd?: string;
|
||
};
|
||
probes?: {
|
||
srs?: {
|
||
port?: number;
|
||
api_port?: number;
|
||
api_ok?: boolean;
|
||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||
tcp_api?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||
http_ui?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||
};
|
||
neko?: {
|
||
port?: number;
|
||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||
};
|
||
};
|
||
};
|
||
|
||
type SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
|
||
|
||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||
type ProcessMonitorRow = ProcessEntry & {
|
||
process_status?: string;
|
||
business_status?: string;
|
||
business_note?: string;
|
||
is_pushing?: boolean;
|
||
};
|
||
|
||
function formatUptime(sec?: number) {
|
||
if (sec == null || !Number.isFinite(sec)) return "—";
|
||
const s = Math.max(0, Math.floor(sec));
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
if (d > 0) return `${d}d ${h}h`;
|
||
if (h > 0) return `${h}h ${m}m`;
|
||
return `${m}m`;
|
||
}
|
||
|
||
function formatBytes(value?: number) {
|
||
if (!value) return "—";
|
||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||
let n = value;
|
||
let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) {
|
||
n /= 1024;
|
||
i += 1;
|
||
}
|
||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||
}
|
||
|
||
function statusPill(status: string) {
|
||
if (status === "online" || status === "running")
|
||
return "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30";
|
||
if (status === "configured")
|
||
return "bg-sky-500/15 text-sky-200 ring-1 ring-sky-500/25";
|
||
if (status === "skipped")
|
||
return "bg-slate-500/15 text-slate-300 ring-1 ring-slate-500/30";
|
||
if (status === "stopped" || status === "not_found")
|
||
return "bg-zinc-500/15 text-zinc-400 ring-1 ring-zinc-500/25";
|
||
if (status === "error") return "bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/35";
|
||
return "bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/30";
|
||
}
|
||
|
||
function statusLabel(status: string, t: (zh: string, en: string) => string) {
|
||
const normalized = (status || "").trim().toLowerCase();
|
||
if (normalized === "online" || normalized === "running") return t("运行中", "Running");
|
||
if (normalized === "configured") return t("已配置", "Configured");
|
||
if (normalized === "not_found") return t("未注册", "Unregistered");
|
||
if (normalized === "stopped") return t("已停止", "Stopped");
|
||
if (normalized === "error") return t("异常", "Error");
|
||
return status || "—";
|
||
}
|
||
|
||
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);
|
||
const [dashErr, setDashErr] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [toast, setToast] = useState<string | null>(null);
|
||
|
||
const [entries, setEntries] = useState<ProcessEntry[]>([]);
|
||
const [processRows, setProcessRows] = useState<ProcessMonitorRow[]>([]);
|
||
const [currentProc, setCurrentProc] = useState("");
|
||
const [urlConfig, setUrlConfig] = useState("");
|
||
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
|
||
const [androidSerial, setAndroidSerial] = useState("");
|
||
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
|
||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||
const [showStreamProbes, setShowStreamProbes] = useState(true);
|
||
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
|
||
const processMonitorInFlightRef = useRef<Promise<void> | null>(null);
|
||
|
||
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
|
||
|
||
const notify = useCallback((msg: string) => {
|
||
setToast(msg);
|
||
window.setTimeout(() => setToast(null), 2800);
|
||
}, []);
|
||
|
||
const fetchJson = useCallback(
|
||
async <T,>(path: string, init?: RequestInit): Promise<T> => {
|
||
return fetchJsonResponse<T>(`${base}${path}`, init);
|
||
},
|
||
[base],
|
||
);
|
||
|
||
const refreshDashboard = useCallback(async () => {
|
||
if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
|
||
const task = (async () => {
|
||
try {
|
||
const d = await fetchJson<HubDashboard>("/hub/dashboard");
|
||
setDash(d);
|
||
setDashErr(null);
|
||
const devs = d.android?.devices || [];
|
||
if (devs.length && !androidSerial) {
|
||
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
|
||
setAndroidSerial((ready ?? devs[0]).serial);
|
||
}
|
||
} catch (e) {
|
||
setDashErr((e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
dashboardInFlightRef.current = null;
|
||
}
|
||
})();
|
||
dashboardInFlightRef.current = task;
|
||
return task;
|
||
}, [androidSerial, fetchJson]);
|
||
|
||
const processMonitorPath = useMemo(() => {
|
||
const params = new URLSearchParams({ light: "1" });
|
||
if (view === "live_youtube") params.set("family", "youtube");
|
||
else if (view === "live_tiktok") params.set("family", "tiktok");
|
||
return `/process_monitor?${params.toString()}`;
|
||
}, [view]);
|
||
|
||
const refreshProcessMonitor = useCallback(async () => {
|
||
if (processMonitorInFlightRef.current) return processMonitorInFlightRef.current;
|
||
const task = (async () => {
|
||
try {
|
||
const data = await fetchJson<{ entries?: ProcessMonitorRow[] }>(processMonitorPath);
|
||
const list = data.entries || [];
|
||
setProcessRows(list);
|
||
setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label })));
|
||
setCurrentProc((prev) => {
|
||
if (!list.length) return prev;
|
||
if (prev && list.some((row) => row.pm2 === prev)) return prev;
|
||
return list[0].pm2;
|
||
});
|
||
} catch {
|
||
/* ignore */
|
||
} finally {
|
||
processMonitorInFlightRef.current = null;
|
||
}
|
||
})();
|
||
processMonitorInFlightRef.current = task;
|
||
return task;
|
||
}, [fetchJson, processMonitorPath]);
|
||
|
||
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);
|
||
if (localStorage.getItem("livehub.showStreamProbes") === "0") setShowStreamProbes(false);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refreshDashboard();
|
||
}, [refreshDashboard]);
|
||
|
||
useEffect(() => {
|
||
void refreshProcessMonitor();
|
||
}, [refreshProcessMonitor]);
|
||
|
||
useEffect(() => {
|
||
if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return;
|
||
const id = window.setInterval(() => {
|
||
void refreshDashboard();
|
||
}, 10000);
|
||
return () => clearInterval(id);
|
||
}, [autoRefreshEnabled, refreshDashboard, view]);
|
||
|
||
useEffect(() => {
|
||
if (!autoRefreshEnabled) return;
|
||
const id = window.setInterval(() => {
|
||
void refreshProcessMonitor();
|
||
}, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000);
|
||
return () => clearInterval(id);
|
||
}, [autoRefreshEnabled, refreshProcessMonitor, view]);
|
||
|
||
useEffect(() => {
|
||
if (!currentProc || view !== "live_youtube") return;
|
||
void (async () => {
|
||
try {
|
||
const data = await fetchJson<{ content?: string }>(
|
||
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
|
||
);
|
||
setUrlConfig(data.content || "");
|
||
} catch {
|
||
/* 保留当前草稿,避免临时读取失败把编辑器清空 */
|
||
}
|
||
})();
|
||
}, [currentProc, fetchJson, view]);
|
||
|
||
useEffect(() => {
|
||
if (view !== "settings") return;
|
||
void (async () => {
|
||
try {
|
||
const cfg = await fetchJson<Record<string, unknown>>("/hub/config");
|
||
setHubCfgSnapshot(cfg);
|
||
} catch {
|
||
setHubCfgSnapshot(null);
|
||
}
|
||
})();
|
||
}, [fetchJson, view]);
|
||
|
||
const ql = useMemo(() => {
|
||
const raw = dash?.stack?.quick_links || {};
|
||
const o: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (/^https?:\/\//i.test(v)) o[k] = normalizeBrowserReachableHttpUrl(v);
|
||
else o[k] = v;
|
||
}
|
||
return o;
|
||
}, [dash?.stack?.quick_links]);
|
||
|
||
const runService = async (
|
||
service: string,
|
||
action: "install" | "start" | "stop" | "restart" | "uninstall" | "upgrade",
|
||
) => {
|
||
setBusy(`svc:${service}`);
|
||
try {
|
||
const data = await fetchJson<{ message?: string; status?: string; detail?: string }>("/service_action", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ service, action }),
|
||
});
|
||
if (data.status === "error") {
|
||
notify(data.detail || data.message || t("操作失败", "Action failed"));
|
||
} else {
|
||
notify(data.message || "OK");
|
||
}
|
||
await refreshDashboard();
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const runProcess = async (action: "start" | "stop" | "restart", processOverride?: string) => {
|
||
const proc = (processOverride ?? currentProc).trim();
|
||
if (!proc) return;
|
||
setBusy(`proc:${action}:${proc}`);
|
||
try {
|
||
const path = `/${action}`;
|
||
const data = await fetchJson<{ output?: string; message?: string }>(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ process: proc }),
|
||
});
|
||
notify(String(data.output || data.message || "OK"));
|
||
await refreshProcessMonitor();
|
||
if (view === "live_youtube" || view === "live_tiktok") {
|
||
void refreshDashboard();
|
||
} else {
|
||
await refreshDashboard();
|
||
}
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const saveUrlConfig = async (overrideContent?: string, processOverride?: string) => {
|
||
const proc = (processOverride ?? currentProc).trim();
|
||
if (!proc) return;
|
||
const content = overrideContent ?? urlConfig;
|
||
setBusy("save:url");
|
||
try {
|
||
const params = new URLSearchParams({ process: proc });
|
||
await fetchJson(`/save_url_config?${params}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
notify(t("URL 配置已保存", "URL config saved"));
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
throw e;
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const androidAction = async (action: string, payload: Record<string, unknown> = {}) => {
|
||
if (!androidSerial) {
|
||
notify(t("请选择设备", "Pick a device"));
|
||
return;
|
||
}
|
||
setBusy(`adb:${action}`);
|
||
try {
|
||
const data = await fetchJson<{ message?: string }>("/android/action", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action, serial: androidSerial, payload }),
|
||
});
|
||
notify(data.message || "OK");
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
|
||
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
|
||
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
|
||
{ id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
|
||
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
|
||
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
|
||
{ id: "network", icon: Network, labelZh: "网络", labelEn: "Network" },
|
||
{ id: "settings", icon: Settings, labelZh: "设置", labelEn: "Settings" },
|
||
];
|
||
|
||
const copyText = (text: string) => {
|
||
void navigator.clipboard.writeText(text).then(
|
||
() => notify(t("已复制", "Copied")),
|
||
() => notify(t("复制失败", "Copy failed")),
|
||
);
|
||
};
|
||
|
||
const onlineServices =
|
||
dash?.services?.filter((s) => s.running && s.available).length ?? 0;
|
||
const totalServices = dash?.services?.length ?? 0;
|
||
|
||
return (
|
||
<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
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="fixed bottom-6 left-1/2 z-[100] max-w-md -translate-x-1/2 rounded-2xl border border-white/10 bg-zinc-950/95 px-5 py-3 text-center text-sm text-zinc-100 shadow-2xl shadow-black/50 backdrop-blur-xl"
|
||
>
|
||
{toast}
|
||
</motion.div>
|
||
) : null}
|
||
|
||
<div className="relative z-10 flex min-h-screen">
|
||
{/* Desktop sidebar */}
|
||
<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] ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
|
||
live.local
|
||
</p>
|
||
<p className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
|
||
{t("无人直播系统", "Unmanned Live System")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<nav className="mt-10 flex flex-1 flex-col gap-1">
|
||
{navItems.map((item) => {
|
||
const Icon = item.icon;
|
||
const active = view === item.id;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
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 ${
|
||
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 ${
|
||
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 ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`} />
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</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 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 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 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={`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>
|
||
<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 Promise.all([refreshDashboard(), refreshProcessMonitor()])}
|
||
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">
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={view}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -6 }}
|
||
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
||
>
|
||
{view === "dashboard" ? (
|
||
<div className="space-y-6">
|
||
{dashErr ? (
|
||
<div className="rounded-2xl border border-rose-500/25 bg-rose-950/20 p-4 text-sm text-rose-200">
|
||
{t("无法加载仪表盘:", "Dashboard error: ")}
|
||
{dashErr}
|
||
</div>
|
||
) : null}
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.02 }}
|
||
className={`rounded-2xl border p-2 backdrop-blur-xl ${
|
||
portalTheme === "light"
|
||
? "border-slate-200/90 bg-white/80 shadow-sm shadow-slate-200/30"
|
||
: "border-white/[0.07] bg-zinc-950/30"
|
||
}`}
|
||
>
|
||
<OverviewCharts snapshot={dash?.snapshot ?? null} theme={portalTheme} tr={t} />
|
||
</motion.div>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.05 }}
|
||
className={`rounded-2xl border p-3 shadow-lg ${
|
||
portalTheme === "light"
|
||
? "border-fuchsia-200/80 bg-gradient-to-br from-fuchsia-50/90 via-white to-cyan-50/70 shadow-fuchsia-100/40"
|
||
: "border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 shadow-fuchsia-900/10"
|
||
}`}
|
||
>
|
||
<LiveStudioScene3D tr={t} />
|
||
</motion.div>
|
||
|
||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||
{[
|
||
{
|
||
icon: Cpu,
|
||
label: t("CPU 1m", "CPU 1m"),
|
||
value: dash?.snapshot?.cpu_load?.["1m"]?.toFixed(2) ?? "—",
|
||
sub: t("负载均值", "Load avg"),
|
||
},
|
||
{
|
||
icon: Activity,
|
||
label: t("内存", "Memory"),
|
||
value: formatBytes(dash?.snapshot?.memory?.available),
|
||
sub: `/ ${formatBytes(dash?.snapshot?.memory?.total)}`,
|
||
},
|
||
{
|
||
icon: HardDrive,
|
||
label: t("磁盘可用", "Disk free"),
|
||
value: formatBytes(dash?.snapshot?.disk?.free),
|
||
sub: `/ ${formatBytes(dash?.snapshot?.disk?.total)}`,
|
||
},
|
||
{
|
||
icon: Server,
|
||
label: t("服务在线", "Services up"),
|
||
value: `${onlineServices}/${totalServices || "—"}`,
|
||
sub: t("基础服务层", "Infra layer"),
|
||
},
|
||
{
|
||
icon: Clock,
|
||
label: t("运行时间", "Uptime"),
|
||
value: formatUptime(dash?.snapshot?.uptime_seconds),
|
||
sub: t("自开机", "Since boot"),
|
||
},
|
||
].map((card, i) => (
|
||
<motion.div
|
||
key={card.label}
|
||
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: -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-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>
|
||
|
||
{dash?.hardware && typeof dash.hardware === "object" ? (
|
||
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
|
||
<h3 className="text-base font-semibold text-white">
|
||
{t("系统硬件与软件能力", "Hardware & software profile")}
|
||
</h3>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{t(
|
||
"来自 hardware_probe;用于直播/解码策略参考。",
|
||
"From hardware_probe; guides decode/streaming posture.",
|
||
)}
|
||
</p>
|
||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-indigo-300/90">
|
||
{t("平台", "Platform")}
|
||
</p>
|
||
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">arch</dt>
|
||
<dd className="text-right text-zinc-200">{String(dash.hardware.arch ?? "—")}</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">kernel</dt>
|
||
<dd className="text-right text-zinc-200">{String(dash.hardware.kernel ?? dash.stack?.kernel ?? "—")}</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">Python</dt>
|
||
<dd className="text-right text-zinc-200">{String(dash.stack?.python ?? "—")}</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">mDNS</dt>
|
||
<dd className="text-right text-cyan-200/90">{dash.stack?.mdns_host ?? "—"}</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">IP</dt>
|
||
<dd className="text-right text-zinc-300">{(dash.stack?.ips || []).join(" · ") || "—"}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-fuchsia-300/90">GPU / NPU</p>
|
||
{(() => {
|
||
const gpu = dash.hardware.gpu as
|
||
| { present?: boolean; driver?: string; cards?: string[]; render_nodes?: string[] }
|
||
| undefined;
|
||
const npu = dash.hardware.npu as { present?: boolean; devices?: string[] } | undefined;
|
||
return (
|
||
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
|
||
<div>
|
||
<dt className="text-zinc-600">GPU</dt>
|
||
<dd className="mt-0.5 text-zinc-200">
|
||
{gpu?.present ? t("已检测", "present") : t("未检测", "absent")}
|
||
{gpu?.driver ? ` · ${gpu.driver}` : ""}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-600">render</dt>
|
||
<dd className="mt-0.5 break-all text-zinc-500">
|
||
{(gpu?.render_nodes || []).join(", ") || "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-600">NPU</dt>
|
||
<dd className="mt-0.5 break-all text-zinc-500">
|
||
{npu?.present ? (npu.devices || []).join(", ") : t("—", "—")}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4 lg:col-span-2">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-emerald-300/90">
|
||
ffmpeg / 建议策略
|
||
</p>
|
||
{(() => {
|
||
const acc = (dash.hardware.ffmpeg_hwaccels as string[] | undefined) || [];
|
||
const rec = dash.hardware.recommendation as
|
||
| {
|
||
video_decoder?: string;
|
||
hdmi_player?: string;
|
||
browser_hwaccel?: string;
|
||
stability_mode?: string;
|
||
degradation_reasons?: string[];
|
||
}
|
||
| undefined;
|
||
return (
|
||
<div className="space-y-2 text-[11px] text-zinc-400">
|
||
<p>
|
||
<span className="text-zinc-600">hwaccels: </span>
|
||
<span className="font-mono text-emerald-200/90">{acc.length ? acc.join(", ") : "—"}</span>
|
||
</p>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<p>
|
||
<span className="text-zinc-600">{t("解码", "Decoder")}: </span>
|
||
<span className="text-zinc-200">{rec?.video_decoder ?? "—"}</span>
|
||
</p>
|
||
<p>
|
||
<span className="text-zinc-600">HDMI: </span>
|
||
<span className="text-zinc-200">{rec?.hdmi_player ?? "—"}</span>
|
||
</p>
|
||
<p>
|
||
<span className="text-zinc-600">Browser: </span>
|
||
<span className="text-zinc-200">{rec?.browser_hwaccel ?? "—"}</span>
|
||
</p>
|
||
<p>
|
||
<span className="text-zinc-600">{t("稳定模式", "Stability")}: </span>
|
||
<span className="text-amber-200/90">{rec?.stability_mode ?? "—"}</span>
|
||
</p>
|
||
</div>
|
||
{(rec?.degradation_reasons || []).length ? (
|
||
<ul className="mt-2 list-inside list-disc space-y-1 text-[10px] text-amber-200/80">
|
||
{(rec?.degradation_reasons || []).map((r) => (
|
||
<li key={r}>{r}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{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("登录", "sign-in")}</h3>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{dash.stack.neko_login.note_zh ||
|
||
t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
|
||
</p>
|
||
<p className="mt-2 text-[11px] text-amber-200/85">
|
||
{t(
|
||
"重要:两栏分开填——昵称随意,密码必须是环境变量里的口令(默认 12345678)。用错密码会像一直加载;勿把「用户名/密码」写在一行。",
|
||
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
|
||
)}
|
||
</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("昵称示例", "Display Name Example")}</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("连接口令", "Access 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 Display Name Example")}</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("管理口令", "Admin 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">
|
||
{t(
|
||
"x86 默认使用 Neko 的 Chrome 镜像,ARM 默认使用 Chromium 镜像;默认保留管理员会话、开发者工具和 Tampermonkey 持久化。本栈未启用 Firefox 版 Neko。",
|
||
"x86 defaults to Neko’s Chrome image, ARM defaults to Chromium, and admin access, DevTools, plus persistent Tampermonkey stay enabled. This stack does not use Firefox Neko.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{dash?.network?.interfaces?.length ? (
|
||
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.07] to-transparent p-5">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||
<Wifi className="h-4 w-4 text-cyan-300" />
|
||
{t("网络流量(累计)", "Network I/O (cumulative)")}
|
||
</h3>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{t("自开机以来各接口收发字节;非实时速率。", "Per-interface RX/TX since boot (not a live bitrate).")}
|
||
</p>
|
||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{(dash.network.interfaces || []).slice(0, 6).map((iface) => (
|
||
<div
|
||
key={iface.name}
|
||
className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 font-mono text-[11px]"
|
||
>
|
||
<p className="text-xs font-semibold text-cyan-200/90">{iface.name}</p>
|
||
<p className="mt-2 text-zinc-500">
|
||
{t("链路", "Link")}: {iface.operstate || "unknown"} <span className="text-zinc-600">·</span>{" "}
|
||
{iface.speed_mbps ? `${iface.speed_mbps} Mbps` : t("未知速率", "unknown speed")}
|
||
</p>
|
||
<p className="mt-2 text-zinc-500">
|
||
↓ {formatBytes(iface.rx_bytes)} <span className="text-zinc-600">·</span>{" "}
|
||
{iface.rx_packets?.toLocaleString() ?? "—"} pkt
|
||
</p>
|
||
<p className="mt-0.5 text-zinc-500">
|
||
↑ {formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
|
||
{iface.tx_packets?.toLocaleString() ?? "—"} pkt
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{(dash?.live_events?.pm2_tail?.length || dash?.live_events?.process_digest?.length) ? (
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className="rounded-2xl border border-amber-500/15 bg-zinc-950/40 p-5">
|
||
<h3 className="text-sm font-semibold text-white">{t("直播 / PM2 事件", "Live / PM2 feed")}</h3>
|
||
<p className="mt-1 text-[11px] text-zinc-500">{t("PM2 合并日志尾部", "PM2 merged log tail")}</p>
|
||
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.05] bg-black/40 p-3 font-mono text-[10px] leading-relaxed text-zinc-400">
|
||
{(dash?.live_events?.pm2_tail || []).join("\n") || t("暂无 PM2 输出", "No PM2 output")}
|
||
</pre>
|
||
</div>
|
||
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/40 p-5">
|
||
<h3 className="text-sm font-semibold text-white">{t("进程快照", "Process digest")}</h3>
|
||
<ul className="mt-3 space-y-2">
|
||
{(dash?.live_events?.process_digest || []).map((row) => (
|
||
<li
|
||
key={row.pm2}
|
||
className="flex items-center justify-between gap-2 rounded-lg border border-white/[0.05] bg-black/30 px-3 py-2 text-xs"
|
||
>
|
||
<span className="truncate text-zinc-300">{row.label || row.pm2}</span>
|
||
<span
|
||
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ${statusPill(row.status || "")}`}
|
||
>
|
||
{statusLabel(row.status || "", t)}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/50 to-fuchsia-500/[0.06] p-5 shadow-lg shadow-violet-500/5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
|
||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
|
||
{dash?.live?.backend_mode || "—"}
|
||
</span>
|
||
</div>
|
||
<ul className="mt-4 space-y-2.5">
|
||
{(dash?.live?.processes || []).slice(0, 10).map((p) => (
|
||
<li
|
||
key={p.pm2}
|
||
className="group flex items-center gap-3 rounded-xl border border-white/[0.08] bg-black/35 px-3 py-2.5 transition hover:border-violet-400/25 hover:bg-black/45"
|
||
>
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-[10px] font-bold text-violet-100 ring-1 ring-white/10">
|
||
LIVE
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-zinc-100">{p.label || p.pm2}</p>
|
||
<p className="truncate font-mono text-[10px] text-zinc-600">{p.script || p.pm2}</p>
|
||
</div>
|
||
<span className={`shrink-0 rounded-lg px-2.5 py-1 text-[10px] font-semibold ${statusPill(p.process_status || "")}`}>
|
||
{statusLabel(p.process_status || "", t)}
|
||
</span>
|
||
</li>
|
||
))}
|
||
{!dash?.live?.processes?.length ? (
|
||
<li className="rounded-xl border border-dashed border-white/10 py-8 text-center text-xs text-zinc-600">
|
||
{t("暂无直播进程", "No live processes")}
|
||
</li>
|
||
) : null}
|
||
</ul>
|
||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("live_youtube")}
|
||
className="rounded-xl border border-violet-400/35 bg-violet-500/15 py-2.5 text-[11px] font-semibold text-violet-100 shadow-inner shadow-black/20 transition hover:bg-violet-500/25"
|
||
>
|
||
YouTube
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("live_tiktok")}
|
||
className="rounded-xl border border-cyan-400/35 bg-cyan-500/15 py-2.5 text-[11px] font-semibold text-cyan-100 shadow-inner shadow-black/20 transition hover:bg-cyan-500/25"
|
||
>
|
||
TikTok
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{dash?.probes && showStreamProbes ? (
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{dash.probes.srs ? (
|
||
(() => {
|
||
const sp = dash.probes?.srs as SrsProbe | undefined;
|
||
const tcpOk = sp?.tcp?.reachable;
|
||
const tcpApiOk = sp?.tcp_api?.reachable;
|
||
const httpUi = sp?.http_ui;
|
||
const http = sp?.http;
|
||
const apiPort = sp?.api_port;
|
||
const apiHealthy = sp?.api_ok ?? (http?.http_code === 200);
|
||
const uiCode = httpUi?.http_code;
|
||
return (
|
||
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs font-semibold text-white">SRS</p>
|
||
<span
|
||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||
apiHealthy
|
||
? "bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-500/30"
|
||
: "bg-rose-500/15 text-rose-200 ring-1 ring-rose-500/25"
|
||
}`}
|
||
>
|
||
{apiHealthy ? t("API 正常", "API OK") : t("API 异常", "API down")}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 font-mono text-[11px] text-zinc-500">
|
||
:{sp?.port ?? "—"} / API :{apiPort ?? "—"} · TCP {tcpOk ? "OK" : "—"} · API TCP{" "}
|
||
{tcpApiOk ? "OK" : "—"}
|
||
</p>
|
||
<p className="mt-1 font-mono text-[11px] text-zinc-500">
|
||
API HTTP {http?.http_code ?? "—"} · {t("根路径", "root")} {uiCode ?? "—"}
|
||
{uiCode === 404
|
||
? t("(无首页不影响推流)", " (no index is OK for streaming)")
|
||
: null}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{t("延迟", "RTT")}{" "}
|
||
{http?.latency_ms != null
|
||
? `${http.latency_ms} ms`
|
||
: sp?.tcp?.latency_ms != null
|
||
? `${sp.tcp.latency_ms} ms`
|
||
: "—"}
|
||
</p>
|
||
{!apiHealthy && http?.error ? (
|
||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()
|
||
) : null}
|
||
{dash.probes.neko ? (
|
||
(() => {
|
||
const p = dash.probes?.neko;
|
||
const tcpOk = p?.tcp?.reachable;
|
||
const http = p?.http;
|
||
return (
|
||
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
|
||
<p className="text-xs font-semibold text-white">Neko Chromium</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{t(
|
||
"Docker 内 Chromium(非 Chrome 品牌);arm64/x86 同一镜像。",
|
||
"Chromium in Docker (not Chrome branding); multi-arch.",
|
||
)}
|
||
</p>
|
||
<p className="mt-2 font-mono text-[11px] text-zinc-500">
|
||
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
{t("延迟", "RTT")}{" "}
|
||
{http?.latency_ms != null
|
||
? `${http.latency_ms} ms`
|
||
: p?.tcp?.latency_ms != null
|
||
? `${p.tcp.latency_ms} ms`
|
||
: "—"}
|
||
</p>
|
||
{!http?.reachable && http?.error ? (
|
||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{view === "services" ? (
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{(dash?.services || []).map((s, i) => (
|
||
<motion.div
|
||
key={s.service_id}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: i * 0.04 }}
|
||
whileHover={{ y: -2 }}
|
||
className="rounded-2xl border border-white/[0.07] bg-gradient-to-b from-white/[0.05] to-transparent p-5"
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<h3 className="font-semibold text-white">{s.label}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500 line-clamp-2">{s.description}</p>
|
||
</div>
|
||
<span className={`shrink-0 rounded-full px-2.5 py-1 text-[10px] font-semibold ${statusPill(s.running ? "online" : s.status)}`}>
|
||
{s.running ? t("运行中", "Running") : s.status}
|
||
</span>
|
||
</div>
|
||
{s.detail ? (
|
||
<p
|
||
className={`mt-2 text-[11px] leading-snug line-clamp-6 ${
|
||
s.status === "skipped" ? "text-slate-300/90" : "text-amber-200/85"
|
||
}`}
|
||
>
|
||
{s.detail}
|
||
</p>
|
||
) : null}
|
||
{s.service_id === "shellcrash" && s.available && !s.running ? (
|
||
<p className="mt-2 text-[11px] text-zinc-500">
|
||
{t(
|
||
"stopped 为常态:仅当需要透明代理 / 规则分流时再点 Start(或 systemctl enable --now shellcrash)。",
|
||
"Stopped is normal; Start only when you need the proxy (or enable shellcrash.service).",
|
||
)}
|
||
</p>
|
||
) : null}
|
||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||
{s.url ? (
|
||
<a
|
||
href={normalizeBrowserReachableHttpUrl(s.url)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="rounded-lg bg-violet-500/15 px-3 py-1.5 text-xs font-medium text-violet-200 ring-1 ring-violet-500/25"
|
||
>
|
||
{t("打开面板", "Open panel")}
|
||
</a>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "start")}
|
||
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-200 ring-1 ring-emerald-500/30 disabled:opacity-30"
|
||
>
|
||
Start
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "install")}
|
||
className="rounded-lg bg-sky-500/15 px-3 py-1.5 text-xs font-medium text-sky-200 ring-1 ring-sky-500/25 disabled:opacity-30"
|
||
>
|
||
Install
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "restart")}
|
||
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-200 ring-1 ring-amber-500/25 disabled:opacity-30"
|
||
>
|
||
Restart
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "upgrade")}
|
||
className="rounded-lg bg-indigo-500/15 px-3 py-1.5 text-xs font-medium text-indigo-200 ring-1 ring-indigo-500/25 disabled:opacity-30"
|
||
>
|
||
Upgrade
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "stop")}
|
||
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-200 ring-1 ring-rose-500/25 disabled:opacity-30"
|
||
>
|
||
Stop
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
|
||
title={
|
||
s.service_id === "android_gateway" && s.status === "skipped"
|
||
? t("单网口无需网关", "Not needed on single NIC")
|
||
: !s.available
|
||
? s.detail || t("当前不可操作", "Unavailable")
|
||
: undefined
|
||
}
|
||
onClick={() => void runService(s.service_id, "uninstall")}
|
||
className="rounded-lg bg-zinc-500/15 px-3 py-1.5 text-xs font-medium text-zinc-200 ring-1 ring-zinc-500/25 disabled:opacity-30"
|
||
>
|
||
Uninstall
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
{!dash?.services?.length ? (
|
||
<p className="text-sm text-zinc-500">{t("暂无服务数据", "No services")}</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{view === "live_youtube" ? (
|
||
<LiveYoutubeUnmannedView
|
||
t={t}
|
||
busy={busy}
|
||
entries={entries}
|
||
currentProc={currentProc}
|
||
setCurrentProc={setCurrentProc}
|
||
urlConfig={urlConfig}
|
||
setUrlConfig={setUrlConfig}
|
||
onRunProcess={(a, pm) => void runProcess(a, pm)}
|
||
onSaveUrlConfig={(c, pm) => saveUrlConfig(c, pm)}
|
||
fetchJson={fetchJson}
|
||
liveProcesses={processRows.length ? processRows : (dash?.live?.processes ?? [])}
|
||
notify={notify}
|
||
quickLinks={ql}
|
||
onNavigate={(target: PortalNavTarget) => setView(target)}
|
||
/>
|
||
) : null}
|
||
|
||
{view === "live_tiktok" ? (
|
||
<LiveTiktokHdmiView
|
||
t={t}
|
||
busy={!!busy}
|
||
entries={entries}
|
||
currentProc={currentProc}
|
||
setCurrentProc={setCurrentProc}
|
||
urlConfig={urlConfig}
|
||
setUrlConfig={setUrlConfig}
|
||
onRunProcess={(a) => void runProcess(a)}
|
||
onSaveUrlConfig={(c) => saveUrlConfig(c)}
|
||
onReplayGoLive={async (replayUrl, label) => {
|
||
const safe = label.replace(/[,,|]/g, "_");
|
||
await saveUrlConfig(`原画,${replayUrl},主播: ${safe}`);
|
||
await runProcess("start");
|
||
}}
|
||
onCopy={copyText}
|
||
fetchJson={fetchJson}
|
||
apiPrefix={base}
|
||
quickLinks={ql}
|
||
hdmiStatus={dash?.hdmi}
|
||
notify={notify}
|
||
onNavigate={(target: PortalNavTarget) => setView(target)}
|
||
/>
|
||
) : null}
|
||
|
||
{view === "android" ? (
|
||
<LiveAndroidWorkstation
|
||
t={t}
|
||
notify={notify}
|
||
fetchJson={fetchJson}
|
||
apiBase={base}
|
||
busy={busy}
|
||
androidSerial={androidSerial}
|
||
setAndroidSerial={setAndroidSerial}
|
||
devices={dash?.android?.devices || []}
|
||
adbAvailable={!!dash?.android?.adb_available}
|
||
apkPath={apkPath}
|
||
setApkPath={setApkPath}
|
||
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
|
||
onCopy={copyText}
|
||
onRefreshDevices={() => void refreshDashboard()}
|
||
/>
|
||
) : null}
|
||
|
||
{view === "network" ? (
|
||
<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 ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
|
||
{t(
|
||
"透明代理与规则分流直接在本页完成,无需跳转;流媒体与其它系统服务请在「服务」页管理。",
|
||
"Proxy rules and YAML editing are embedded here. Use Services for SRS/Neko/Chromium, etc.",
|
||
)}
|
||
</p>
|
||
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||
{[
|
||
{ tx: t("互联网", "Internet"), c: "from-cyan-400 to-blue-500" },
|
||
{ tx: "ShellCrash", c: "from-violet-500 to-fuchsia-500" },
|
||
{ tx: t("本机 / 安卓", "Host / Android"), c: "from-emerald-400 to-teal-500" },
|
||
].map((n, i) => (
|
||
<div key={n.tx} className="flex items-center gap-4">
|
||
<div
|
||
className={`flex h-16 w-28 items-center justify-center rounded-2xl bg-gradient-to-br ${n.c} text-xs font-bold text-white shadow-lg`}
|
||
>
|
||
{n.tx}
|
||
</div>
|
||
{i < 2 ? (
|
||
<ChevronRight
|
||
className={`hidden h-5 w-5 sm:block ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<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"
|
||
}`}
|
||
>
|
||
<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 ${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">
|
||
<div>
|
||
<p className="text-sm font-medium text-white">{t("总览自动刷新", "Auto-refresh dashboard")}</p>
|
||
<p className="text-[11px] text-zinc-500">10s</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5 accent-violet-500"
|
||
checked={autoRefreshEnabled}
|
||
onChange={(e) => {
|
||
const on = e.target.checked;
|
||
setAutoRefreshEnabled(on);
|
||
try {
|
||
localStorage.setItem("livehub.autorefresh", on ? "1" : "0");
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
notify(on ? t("已开启刷新", "Auto refresh on") : t("已暂停刷新", "Paused"));
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-white">{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}</p>
|
||
<p className="text-[11px] text-zinc-500">{t("关闭可精简首页", "Hide stream probe cards")}</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5 accent-violet-500"
|
||
checked={showStreamProbes}
|
||
onChange={(e) => {
|
||
const on = e.target.checked;
|
||
setShowStreamProbes(on);
|
||
try {
|
||
localStorage.setItem("livehub.showStreamProbes", on ? "1" : "0");
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/40 p-5">
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("配置快照", "Config snapshot")}</p>
|
||
<p className="mt-2 font-mono text-[11px] text-zinc-400">
|
||
{String((hubCfgSnapshot as { config_root?: string })?.config_root || "—")}
|
||
</p>
|
||
<p className="mt-3 text-sm text-zinc-300">
|
||
{t("主机名", "Hostname")}:{" "}
|
||
<span className="font-mono text-violet-200">
|
||
{(() => {
|
||
const sys = hubCfgSnapshot?.system as { identity?: { hostname_alias?: string } } | undefined;
|
||
const a = sys?.identity?.hostname_alias?.trim();
|
||
if (a) return a;
|
||
const h = dash?.stack?.mdns_host || "";
|
||
return h.replace(/\.local$/i, "") || "—";
|
||
})()}
|
||
</span>
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{(hubCfgSnapshot?.services as Array<{ id?: string; enabled?: boolean }> | undefined)?.slice(0, 8).map((svc) => (
|
||
<span
|
||
key={svc.id}
|
||
className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${
|
||
svc.enabled ? "bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-500/25" : "bg-zinc-500/15 text-zinc-400"
|
||
}`}
|
||
>
|
||
{svc.id} {svc.enabled ? "ON" : "off"}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<a
|
||
href="/console?tab=config"
|
||
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)", "Full config center (files / JSON)")}
|
||
<ArrowUpRight className="h-4 w-4" />
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile drawer */}
|
||
<AnimatePresence>
|
||
{mobileNav ? (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm lg:hidden"
|
||
onClick={() => setMobileNav(false)}
|
||
>
|
||
<motion.aside
|
||
initial={{ x: -280 }}
|
||
animate={{ x: 0 }}
|
||
exit={{ x: -280 }}
|
||
transition={{ type: "spring", damping: 28, stiffness: 320 }}
|
||
className="absolute left-0 top-0 h-full w-[min(88vw,280px)] border-r border-white/10 bg-zinc-950 p-4 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex justify-end">
|
||
<button type="button" onClick={() => setMobileNav(false)} className="rounded-lg p-2">
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
<nav className="mt-4 flex flex-col gap-1">
|
||
{navItems.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setView(item.id);
|
||
setMobileNav(false);
|
||
}}
|
||
className="flex items-center gap-3 rounded-xl px-3 py-3 text-left text-sm text-zinc-300"
|
||
>
|
||
<Icon className="h-4 w-4 text-violet-400" />
|
||
{t(item.labelZh, item.labelEn)}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</motion.aside>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|