Files
gitlab-instance-0a899031_do…/web-console/components/console/LiveConsoleWorkspace.tsx
2026-03-29 03:01:43 -05:00

1622 lines
84 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
type ThemeMode = "dark" | "light";
type TabKey = "overview" | "business" | "modules" | "shellcrash" | "android" | "config";
type LoadAvg = { "1m"?: number; "5m"?: number; "15m"?: number };
type Capacity = { total?: number; free?: number; used?: number; available?: number };
type ProcessEntry = { pm2: string; script: string; label: string };
type ProcessStatusResponse = {
process_status?: string;
recent_log?: string;
recent_error?: string;
output?: string;
pm2_output?: string;
message?: string;
};
type ManagedRoot = {
root_id: string;
label: string;
description: string;
path: string;
exists: boolean;
allow_create: boolean;
allow_delete: boolean;
recursive: boolean;
default_file?: string | null;
file_count: number;
};
type ManagedFile = { path: string; size: number; mtime: number };
type StackSummary = {
hostname?: string;
mdns_host?: string;
machine?: string;
kernel?: string;
ips?: string[];
dashboard_url?: string;
control_url?: string;
quick_links?: Record<string, string>;
};
type ServiceItem = {
service_id: string;
label: string;
description: string;
available: boolean;
running: boolean;
status: string;
detail?: string;
url?: string;
host?: string;
port?: string;
message?: string;
};
type SystemSnapshot = {
cpu_load?: LoadAvg;
disk?: Capacity;
memory?: Capacity;
netdata?: { available?: boolean; version?: string };
};
type HardwareProfile = {
arch?: string;
ffmpeg_hwaccels?: string[];
gpu?: { present?: boolean; driver?: string };
npu?: { present?: boolean; devices?: string[] };
recommendation?: {
video_decoder?: string;
hdmi_player?: string;
stability_mode?: string;
};
};
type AndroidDevice = {
serial: string;
state: string;
usb?: string;
product?: string;
model?: string;
device?: string;
};
type AndroidOverview = {
serial?: string;
model?: string;
brand?: string;
android_version?: string;
sdk?: string;
battery?: string;
focus?: string;
resolution?: string;
rotation?: string;
screen_state?: string;
};
type AndroidNode = {
label: string;
text?: string;
content_desc?: string;
resource_id?: string;
class_name?: string;
package?: string;
bounds?: string;
center_x: number;
center_y: number;
clickable?: boolean;
focusable?: boolean;
scrollable?: boolean;
enabled?: boolean;
};
type AndroidPackage = {
package: string;
label?: string;
};
type PreviewMode = "tap" | "double_tap" | "long_press";
const TABS: TabKey[] = ["overview", "business", "modules", "shellcrash", "android", "config"];
const SHELLCRASH_ROOT_IDS = new Set(["shellcrash-configs", "shellcrash-yamls", "shellcrash-jsons"]);
const apiBase = () =>
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
function formatBytes(value?: number) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let amount = value;
let index = 0;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
return `${amount.toFixed(amount >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function statusTone(status: string) {
if (status === "online") return "border-emerald-500/30 bg-emerald-500/10 text-emerald-200";
if (status === "stopped") return "border-slate-500/30 bg-slate-500/10 text-slate-300";
if (status === "configured") return "border-violet-500/30 bg-violet-500/10 text-violet-200";
if (status === "unavailable") return "border-slate-700/30 bg-slate-800/50 text-slate-500";
if (status === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-200";
return "border-cyan-500/30 bg-cyan-500/10 text-cyan-200";
}
function isTabKey(value: string | null): value is TabKey {
return value !== null && (TABS as string[]).includes(value);
}
function pathnameToTab(pathname: string): TabKey | null {
const normalized = pathname.replace(/\/+$/, "") || "/";
if (normalized === "/shellcrash") return "shellcrash";
if (normalized === "/android") return "android";
return null;
}
function tabPath(tab: TabKey) {
if (tab === "shellcrash") return "/shellcrash";
if (tab === "android") return "/android";
return "/console";
}
function parseResolution(resolution?: string) {
const match = resolution?.match(/(\d+)x(\d+)/);
if (!match) return null;
return { width: Number(match[1]), height: Number(match[2]) };
}
function Button({
children,
className,
disabled,
onClick,
title,
}: {
children: React.ReactNode;
className?: string;
disabled?: boolean;
onClick?: () => void;
title?: string;
}) {
return (
<button
type="button"
className={`btn-focus rounded-xl px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40 ${className || ""}`}
disabled={disabled}
onClick={onClick}
title={title}
>
{children}
</button>
);
}
export type LiveConsoleWorkspaceProps = {
/** 嵌入门户「网络」页:只渲染 ShellCrash 面板,不改写地址栏、不显示顶栏/Tab/底栏 */
embeddedShellCrash?: boolean;
/** 与门户首页语言同步 */
portalLang?: Lang;
};
/** 全功能控制台:默认从门户 `/` 进入后打开 `/console`。 */
export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: LiveConsoleWorkspaceProps) {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
const [theme, setTheme] = useState<ThemeMode>("dark");
const [tab, setTab] = useState<TabKey>("overview");
const tr = useCallback((zh: string, en: string) => (lang === "zh" ? zh : en), [lang]);
const [booting, setBooting] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [entries, setEntries] = useState<ProcessEntry[]>([]);
const [currentProcess, setCurrentProcess] = useState("");
const [processStatus, setProcessStatus] = useState("unknown");
const [recentLog, setRecentLog] = useState("");
const [recentError, setRecentError] = useState("");
const [stack, setStack] = useState<StackSummary | null>(null);
const [services, setServices] = useState<ServiceItem[]>([]);
const [snapshot, setSnapshot] = useState<SystemSnapshot | null>(null);
const [hardware, setHardware] = useState<HardwareProfile | null>(null);
const [roots, setRoots] = useState<ManagedRoot[]>([]);
const [rootId, setRootId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [filePath, setFilePath] = useState("");
const [fileContent, setFileContent] = useState("");
const [androidAvailable, setAndroidAvailable] = useState(false);
const [androidDevices, setAndroidDevices] = useState<AndroidDevice[]>([]);
const [androidSerial, setAndroidSerial] = useState("");
const [androidOverview, setAndroidOverview] = useState<AndroidOverview | null>(null);
const [androidNodes, setAndroidNodes] = useState<AndroidNode[]>([]);
const [androidPackages, setAndroidPackages] = useState<AndroidPackage[]>([]);
const [androidPackageQuery, setAndroidPackageQuery] = useState("");
const [androidText, setAndroidText] = useState("");
const [androidPackage, setAndroidPackage] = useState("com.zhiliaoapp.musically");
const [androidUrl, setAndroidUrl] = useState("https://www.tiktok.com");
const [androidShell, setAndroidShell] = useState("");
const [androidOutput, setAndroidOutput] = useState("");
const [shotNonce, setShotNonce] = useState(Date.now());
const [previewMode, setPreviewMode] = useState<PreviewMode>("tap");
const [previewPoint, setPreviewPoint] = useState<{ x: number; y: number } | null>(null);
const [fileImportKey, setFileImportKey] = useState(0);
const [configInsightVersion, setConfigInsightVersion] = useState(0);
const [shotObjectUrl, setShotObjectUrl] = useState<string | null>(null);
const [shotFetchErr, setShotFetchErr] = useState<string | null>(null);
const [shotLoading, setShotLoading] = useState(false);
const currentRoot = roots.find((item) => item.root_id === rootId) ?? null;
const shellRoots = roots.filter((item) => SHELLCRASH_ROOT_IDS.has(item.root_id));
const otherRoots = roots.filter((item) => !SHELLCRASH_ROOT_IDS.has(item.root_id));
const shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null;
const androidService = services.find((item) => item.service_id === "android_panel") ?? null;
const qlNorm = useMemo(() => {
const raw = stack?.quick_links ?? {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw)) {
const s = String(v);
out[k] = /^https?:\/\//i.test(s) ? normalizeBrowserReachableHttpUrl(s) : s;
}
return out;
}, [stack]);
const rawAndroidPanelUrl = useMemo(() => {
const baseUrl =
qlNorm.ws_scrcpy ||
(androidService?.host && androidService.port
? `http://${androidService.host}:${androidService.port}`
: stack?.mdns_host
? `http://${stack.mdns_host}:5000`
: "");
return baseUrl ? normalizeBrowserReachableHttpUrl(baseUrl) : "";
}, [androidService?.host, androidService?.port, qlNorm.ws_scrcpy, stack?.mdns_host]);
const androidResolution = parseResolution(androidOverview?.resolution);
const notify = useCallback((message: string) => {
setToast(message);
window.setTimeout(() => setToast(null), 2500);
}, []);
const fetchJson = useCallback(
async <T,>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`${base}${path}`, init);
const data = (await response.json()) as T & { error?: string };
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
},
[base],
);
const syncUrl = useCallback((nextTab: TabKey, nextLang: Lang) => {
const url = new URL(window.location.href);
url.pathname = tabPath(nextTab);
if (url.pathname === "/console") {
url.searchParams.set("tab", nextTab);
} else {
url.searchParams.delete("tab");
}
url.searchParams.set("lang", nextLang);
window.history.replaceState(null, "", url.toString());
window.localStorage.setItem("live-console-lang", nextLang);
}, []);
const refreshProcess = useCallback(async () => {
if (!currentProcess) return;
const data = await fetchJson<ProcessStatusResponse>(
`/status?process=${encodeURIComponent(currentProcess)}`,
);
setProcessStatus(data.process_status || "unknown");
setRecentLog(data.recent_log || "");
setRecentError(data.recent_error || "");
}, [currentProcess, fetchJson]);
const refreshServices = useCallback(async () => {
const [serviceData, snapshotData, hardwareData] = await Promise.all([
fetchJson<{ services?: ServiceItem[] }>("/stack/services"),
fetchJson<SystemSnapshot>("/system_snapshot"),
fetchJson<HardwareProfile>("/hardware_profile"),
]);
setServices(serviceData.services || []);
setSnapshot(snapshotData);
setHardware(hardwareData);
}, [fetchJson]);
const refreshFiles = useCallback(
async (selectedRoot: string, preferredPath?: string) => {
if (!selectedRoot) return;
const data = await fetchJson<{ files?: ManagedFile[] }>(
`/managed_files?root=${encodeURIComponent(selectedRoot)}`,
);
const nextFiles = data.files || [];
setFiles(nextFiles);
const expected = preferredPath || filePath;
if (expected && nextFiles.some((item) => item.path === expected)) {
setFilePath(expected);
} else {
setFilePath(nextFiles[0]?.path || "");
}
},
[fetchJson, filePath],
);
const refreshAndroidDevices = useCallback(async () => {
const data = await fetchJson<{ available?: boolean; devices?: AndroidDevice[] }>("/android/devices");
const nextDevices = data.devices || [];
const nextSerial =
androidSerial && nextDevices.some((item) => item.serial === androidSerial)
? androidSerial
: nextDevices[0]?.serial || "";
setAndroidAvailable(Boolean(data.available));
setAndroidDevices(nextDevices);
setAndroidSerial(nextSerial);
return nextSerial;
}, [androidSerial, fetchJson]);
const refreshAndroidOverview = useCallback(
async (serial: string) => {
if (!serial) {
setAndroidOverview(null);
return;
}
const data = await fetchJson<AndroidOverview>(
`/android/overview?serial=${encodeURIComponent(serial)}`,
);
setAndroidOverview(data);
},
[fetchJson],
);
const refreshAndroidPackages = useCallback(
async (serial: string, query = androidPackageQuery) => {
if (!serial) {
setAndroidPackages([]);
return;
}
const data = await fetchJson<{ packages?: AndroidPackage[] }>(
`/android/packages?serial=${encodeURIComponent(serial)}&query=${encodeURIComponent(query)}&limit=60`,
);
setAndroidPackages(data.packages || []);
},
[androidPackageQuery, fetchJson],
);
const refreshAndroidUi = useCallback(
async (serial: string) => {
if (!serial) {
setAndroidNodes([]);
return;
}
const data = await fetchJson<{ nodes?: AndroidNode[] }>(
`/android/ui?serial=${encodeURIComponent(serial)}&limit=80`,
);
setAndroidNodes(data.nodes || []);
},
[fetchJson],
);
const openManagedFile = useCallback(
async (nextRoot: string, nextPath: string) => {
setRootId(nextRoot);
setFilePath(nextPath);
await refreshFiles(nextRoot, nextPath);
},
[refreshFiles],
);
const jumpToConfig = useCallback(
(root: string, path: string) => {
setTab("config");
window.setTimeout(() => {
void openManagedFile(root, path);
}, 0);
},
[openManagedFile],
);
const probeHardware = useCallback(async () => {
setBusy("hardware:probe");
try {
const data = await fetchJson<HardwareProfile>("/hardware_profile?refresh=true");
setHardware(data);
notify(tr("硬件探测已更新", "Hardware profile refreshed"));
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
}, [fetchJson, notify, tr]);
const boot = useCallback(async () => {
setBooting(true);
setError(null);
try {
const [monitor, info, managed] = await Promise.all([
fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor"),
fetchJson<{ stack?: StackSummary }>("/server_info"),
fetchJson<{ roots?: ManagedRoot[] }>("/managed_roots"),
]);
setEntries(monitor.entries || []);
setCurrentProcess(monitor.entries?.[0]?.pm2 || "");
setStack(info.stack || null);
setRoots(managed.roots || []);
setRootId(managed.roots?.[0]?.root_id || "");
await Promise.all([refreshServices(), refreshAndroidDevices()]);
} catch (reason) {
setError((reason as Error).message);
} finally {
setBooting(false);
}
}, [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");
const nextLang = params.get("lang");
const storedLang = window.localStorage.getItem("live-console-lang");
if (pathTab) setTab(pathTab);
else if (isTabKey(nextTab)) setTab(nextTab);
if (nextLang === "zh" || nextLang === "en") setLang(nextLang);
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, embeddedShellCrash]);
useEffect(() => {
if (!embeddedShellCrash) return;
if (portalLang === "zh" || portalLang === "en") setLang(portalLang);
}, [embeddedShellCrash, portalLang]);
useEffect(() => {
if (embeddedShellCrash) return;
syncUrl(tab, lang);
}, [embeddedShellCrash, lang, syncUrl, tab]);
useEffect(() => {
void boot();
}, [boot]);
useEffect(() => {
if (!currentProcess) return;
void refreshProcess();
const timer = window.setInterval(() => void refreshProcess(), 1500);
return () => window.clearInterval(timer);
}, [currentProcess, refreshProcess]);
useEffect(() => {
if (booting || error) return;
if (tab !== "overview" && tab !== "modules" && tab !== "shellcrash") return;
void refreshServices();
const timer = window.setInterval(() => void refreshServices(), 10000);
return () => clearInterval(timer);
}, [booting, error, refreshServices, tab]);
useEffect(() => {
const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots;
if (!visibleRoots.length) return;
if (!visibleRoots.some((item) => item.root_id === rootId)) {
setRootId(visibleRoots[0].root_id);
setFilePath("");
}
}, [otherRoots, rootId, roots, shellRoots, tab]);
useEffect(() => {
if (!rootId) return;
void refreshFiles(rootId);
}, [refreshFiles, rootId]);
useEffect(() => {
if (!rootId || !filePath) {
setFileContent("");
return;
}
(async () => {
try {
const data = await fetchJson<{ content?: string }>(
`/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`,
);
setFileContent(data.content || "");
} catch (reason) {
notify((reason as Error).message);
}
})();
}, [fetchJson, filePath, notify, rootId]);
useEffect(() => {
if (tab !== "android") return;
void (async () => {
const serial = await refreshAndroidDevices();
if (serial) {
await Promise.all([
refreshAndroidOverview(serial),
refreshAndroidUi(serial),
refreshAndroidPackages(serial),
]);
setShotNonce(Date.now());
}
})();
}, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]);
useEffect(() => {
if (tab !== "android" || !androidSerial) return;
const timer = window.setInterval(() => {
void refreshAndroidOverview(androidSerial);
setShotNonce(Date.now());
}, 5000);
return () => window.clearInterval(timer);
}, [androidSerial, refreshAndroidOverview, tab]);
useEffect(() => {
if (!androidSerial) {
setShotObjectUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setShotFetchErr(null);
setShotLoading(false);
return;
}
const ac = new AbortController();
const shotUrl = `${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}&ts=${shotNonce}`;
setShotLoading(true);
setShotFetchErr(null);
fetch(shotUrl, { signal: ac.signal })
.then(async (response) => {
if (!response.ok) {
let msg = response.statusText;
try {
const body = (await response.json()) as { error?: string };
if (body.error) msg = body.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return response.blob();
})
.then((blob) => {
if (!blob.type.startsWith("image/")) {
throw new Error("Not an image");
}
const obj = URL.createObjectURL(blob);
setShotObjectUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return obj;
});
})
.catch((reason) => {
if ((reason as Error).name === "AbortError") return;
setShotFetchErr((reason as Error).message);
setShotObjectUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
})
.finally(() => {
if (!ac.signal.aborted) setShotLoading(false);
});
return () => {
ac.abort();
};
}, [androidSerial, shotNonce, base]);
const processAction = async (action: "start" | "restart" | "stop") => {
if (!currentProcess) return;
setBusy(`process:${action}`);
try {
const data = await fetchJson<ProcessStatusResponse>(`/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ process: currentProcess }),
});
notify(String(data.output || data.pm2_output || data.message || "OK"));
await refreshProcess();
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const serviceAction = async (serviceId: string, action: "start" | "restart" | "stop") => {
setBusy(`${serviceId}:${action}`);
try {
const data = await fetchJson<ServiceItem>("/service_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: serviceId, action }),
});
notify(data.message || "OK");
await refreshServices();
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const saveFile = async () => {
if (!rootId || !filePath) return;
setBusy("file:save");
try {
await fetchJson("/managed_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }),
});
notify(tr("文件已保存", "File saved"));
await refreshFiles(rootId, filePath);
setConfigInsightVersion((value) => value + 1);
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const createFile = async () => {
if (!currentRoot?.allow_create) {
notify(tr("当前目录不允许新建文件", "The current root does not allow new files"));
return;
}
const nextPath = window.prompt(tr("请输入新的相对路径", "Enter a new relative path"), currentRoot.default_file || "");
if (!nextPath) return;
setBusy("file:create");
try {
await fetchJson("/managed_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: nextPath, content: "" }),
});
notify(tr("文件已创建", "File created"));
await refreshFiles(rootId, nextPath);
setConfigInsightVersion((value) => value + 1);
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const deleteFile = async () => {
if (!rootId || !filePath) return;
if (!currentRoot?.allow_delete) {
notify(tr("当前目录不允许删除文件", "The current root does not allow deletion"));
return;
}
if (!window.confirm(tr("确定删除该文件?", "Delete this file?"))) return;
setBusy("file:delete");
try {
await fetchJson(
`/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`,
{ method: "DELETE" },
);
notify(tr("文件已删除", "File deleted"));
await refreshFiles(rootId);
setConfigInsightVersion((value) => value + 1);
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const importManagedFile = async (event: ChangeEvent<HTMLInputElement>) => {
const upload = event.target.files?.[0];
event.target.value = "";
setFileImportKey((value) => value + 1);
if (!upload || !rootId) return;
setBusy("file:import");
try {
const content = await upload.text();
await fetchJson("/managed_file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ root: rootId, path: upload.name, content }),
});
notify(tr("导入完成,已替换同名文件", "Import finished and replaced matching file"));
setFilePath(upload.name);
await refreshFiles(rootId, upload.name);
setConfigInsightVersion((value) => value + 1);
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const sendAndroidAction = async (action: string, payload?: Record<string, unknown>) => {
if (!androidSerial) {
notify(tr("当前没有可用的 ADB 设备", "No active ADB device"));
return;
}
setBusy(`android:${action}`);
try {
const data = await fetchJson<{ message?: string; output?: string }>("/android/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, serial: androidSerial, payload: payload || {} }),
});
notify(data.message || "OK");
if (data.output) setAndroidOutput(data.output);
setShotNonce(Date.now());
await refreshAndroidOverview(androidSerial);
} catch (reason) {
notify((reason as Error).message);
} finally {
setBusy(null);
}
};
const previewActionLabel =
previewMode === "tap"
? tr("点击", "Tap")
: previewMode === "double_tap"
? tr("双击", "Double Tap")
: tr("长按", "Long Press");
const handlePreviewClick = async (event: MouseEvent<HTMLImageElement>) => {
if (!androidSerial) {
notify(tr("当前没有可用的 ADB 设备", "No active ADB device"));
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth || rect.width;
const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight || rect.height;
const x = Math.max(
0,
Math.min(sourceWidth, Math.round((event.clientX - rect.left) * (sourceWidth / rect.width))),
);
const y = Math.max(
0,
Math.min(sourceHeight, Math.round((event.clientY - rect.top) * (sourceHeight / rect.height))),
);
setPreviewPoint({ x, y });
if (previewMode === "long_press") {
await sendAndroidAction("long_press", { x, y, duration: 750 });
return;
}
await sendAndroidAction(previewMode, { x, y });
};
const serviceHref = (item: ServiceItem) => {
if (item.service_id === "shellcrash" && stack?.control_url) {
return `${stack.control_url}/shellcrash?lang=${lang}`;
}
if (item.service_id === "android_panel" && stack?.control_url) {
return `${stack.control_url}/android?lang=${lang}`;
}
return item.url || "";
};
const statusText = (status: string) =>
({
online: tr("在线", "Online"),
stopped: tr("已停止", "Stopped"),
configured: tr("已配置", "Configured"),
unavailable: tr("未安装", "Unavailable"),
}[status] || status);
const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots;
const tabEmoji: Record<TabKey, string> = {
overview: "📊",
business: "🎬",
modules: "🧩",
shellcrash: "🛡️",
android: "🤖",
config: "⚙️",
};
return (
<>
{!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>
) : 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}
{!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 处理推流或 HDMINekoDocker Chromium或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Neko (Docker Chromium) or host Chromium for browser; USB ADB to AOSP. 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>
</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>
) : (
<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">
{tr("正在连接控制台...", "Connecting to the control plane...")}
</div>
) : null}
{!booting && error ? (
<div className="glass rounded-3xl border border-rose-500/25 bg-rose-950/20 p-8 text-center text-rose-200">
{error}
</div>
) : null}
{!booting && !error ? (
<div className="grid gap-6">
{tab === "overview" ? (
<>
<section className="glass rounded-3xl border border-amber-500/20 bg-gradient-to-br from-amber-500/[0.07] to-transparent p-6">
<h2 className="section-title">{tr("第一次用?按这条线理解整套板子", "New here? Follow this mental model")}</h2>
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm leading-relaxed text-slate-300">
<li>
{tr(
"插电、接网线或连好 WiFi 后,用浏览器打开本页(验收域名一般为 http://live.local:8001/ ,门户在根路径 /,控制台在 /console。",
"After power and Ethernet/WiFi, open this stack in a browser (often http://live.local:8001/ — portal at /, full console at /console).",
)}
</li>
<li>
{tr(
"「直播业务」里启停 PM2 脚本抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy、Neko 等是否在跑。",
"Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, Neko, etc.",
)}
</li>
<li>
{tr(
"「安卓设备」需要 USB 调试授权;截图预览 + ws-scrcpy 低延迟画面二选一或同用。TikTok 等场景常配合板载 AOSP。",
"Android tab needs USB debugging; use screenshot taps and/or ws-scrcpy. TikTok workflows often use on-board AOSP.",
)}
</li>
<li>
{tr(
"深度指标点 Netdata命令行点 WebTTY代理规则与 YAML 在 ShellCrash 页。",
"Netdata for metrics; WebTTY for shell; ShellCrash tab for proxy rules and YAML.",
)}
</li>
</ol>
</section>
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2>
<p className="section-lead">
{tr(
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebugNekoDocker 里跑 Chromium 桌面(非 Firefox 版)。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: Chromium desktop in Docker (not the Firefox image).",
)}
</p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll">
{stack?.dashboard_url ? (
<a className="link-tile" href={stack.dashboard_url} target="_blank" rel="noreferrer">
🏠 Homepage
</a>
) : null}
{stack?.control_url ? (
<>
<a className="link-tile" href={`${stack.control_url}/console?tab=shellcrash&lang=${lang}`} target="_blank" rel="noreferrer">
🛡 ShellCrash
</a>
<a className="link-tile" href={`${stack.control_url}/console?tab=android&lang=${lang}`} target="_blank" rel="noreferrer">
🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")}
</a>
<a className="link-tile" href={`${stack.control_url}/console?tab=config&lang=${lang}`} target="_blank" rel="noreferrer">
{tr("配置中心", "Config")}
</a>
</>
) : null}
{qlNorm.ws_scrcpy ? (
<a className="link-tile link-tile-accent" href={qlNorm.ws_scrcpy} target="_blank" rel="noreferrer">
📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")})
</a>
) : null}
{qlNorm.chromium_remote_debug ? (
<a className="link-tile" href={qlNorm.chromium_remote_debug} target="_blank" rel="noreferrer">
🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a>
) : null}
{qlNorm.neko_browser ? (
<a className="link-tile link-tile-warn" href={qlNorm.neko_browser} target="_blank" rel="noreferrer">
🐱 Neko / {tr("Docker Chromium 桌面", "Docker Chromium desktop")}
</a>
) : null}
{qlNorm.webtty ? (
<a className="link-tile" href={qlNorm.webtty} target="_blank" rel="noreferrer">
WebTTY
</a>
) : null}
{qlNorm.filebrowser ? (
<a className="link-tile" href={qlNorm.filebrowser} target="_blank" rel="noreferrer">
📂 File Browser
</a>
) : null}
{qlNorm.netdata ? (
<a className="link-tile" href={qlNorm.netdata} target="_blank" rel="noreferrer">
📈 Netdata
</a>
) : null}
{qlNorm.srs_http ? (
<a className="link-tile" href={qlNorm.srs_http} target="_blank" rel="noreferrer">
🎞 SRS :8080
</a>
) : null}
{qlNorm.srs_api ? (
<a className="link-tile" href={qlNorm.srs_api} target="_blank" rel="noreferrer">
🎞 SRS API :1985
</a>
) : null}
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="section-title">{tr("节点状态", "Node Status")}</h2>
<div className="panel-inset mt-4">
<p>{stack?.mdns_host || "live.local"}</p>
<p className="mt-2">IP: {(stack?.ips || []).join(" / ") || "-"}</p>
<p className="mt-2">{stack?.machine || "-"}</p>
<p className="mt-2">{stack?.kernel || "-"}</p>
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="section-title">{tr("系统快照", "System Snapshot")}</h2>
<p className="section-lead">{tr("总览页每 10 秒自动刷新服务与监控数据。", "Overview auto-refreshes every 10s.")}</p>
<div className="panel-inset mt-4">
<p>
CPU:{" "}
{[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]]
.map((item) => (typeof item === "number" ? item.toFixed(2) : "0.00"))
.join(" / ")}
</p>
<p className="mt-2">RAM: {formatBytes(snapshot?.memory?.available)} / {formatBytes(snapshot?.memory?.total)}</p>
<p className="mt-2">Disk: {formatBytes(snapshot?.disk?.free)} / {formatBytes(snapshot?.disk?.total)}</p>
<p className="mt-2">Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}</p>
</div>
<OverviewCharts snapshot={snapshot} theme={theme} tr={tr} />
</article>
</section>
<section className="glass rounded-3xl p-6">
<h2 className="section-title">{tr("模块概况", "Module Summary")}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{services.map((item) => (
<div key={item.service_id} className="service-card">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-white">{item.label}</h3>
<p className="mt-1 text-xs text-slate-400">{item.description}</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-xs ${statusTone(item.status)}`}>{statusText(item.status)}</span>
</div>
<p className="mt-3 min-h-[3rem] text-xs text-slate-500">{item.detail || "-"}</p>
{serviceHref(item) ? (
<a className="btn-focus mt-3 inline-flex rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-200" href={serviceHref(item)} target="_blank" rel="noreferrer">{tr("打开", "Open")}</a>
) : null}
</div>
))}
</div>
</section>
</>
) : null}
{tab === "business" ? (
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-12">
<h2 className="section-title">{tr("⚡ 快捷配置", "Quick config")}</h2>
<p className="section-lead">
{tr("跳转配置中心编辑推流与录制相关文件。", "Jump to the config editor for stream keys and URLs.")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button className="border border-cyan-500/30 bg-cyan-600/80" onClick={() => jumpToConfig("app-config", "youtube.ini")}>
youtube.ini
</Button>
<Button className="border border-cyan-500/30 bg-cyan-600/80" onClick={() => jumpToConfig("app-config", "URL_config.ini")}>
URL_config.ini
</Button>
<Button className="border border-white/15 bg-black/30" onClick={() => jumpToConfig("app-config", "config.ini")}>
config.ini
</Button>
<Button className="border border-white/15 bg-black/30" onClick={() => jumpToConfig("stack-config", "system-stack.env")}>
system-stack.env
</Button>
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="section-title">{tr("业务运行时", "Business Runtime")}</h2>
<p className="section-lead">{tr("这里只放业务脚本本身Hub 基座可单独运行。", "Only business processes live here.")}</p>
{entries.length > 0 ? (
<>
<select className="field-select mt-4" value={currentProcess} onChange={(event) => setCurrentProcess(event.target.value)}>
{entries.map((item) => (
<option key={item.pm2} value={item.pm2}>{item.label} | {item.script}</option>
))}
</select>
<div className="mt-4 flex items-center justify-between rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<span className={`rounded-full border px-3 py-1 text-xs ${statusTone(processStatus)}`}>{statusText(processStatus)}</span>
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshProcess()}>{tr("轮询", "Poll")}</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button className="bg-emerald-600" disabled={busy === "process:start"} onClick={() => void processAction("start")}>{busy === "process:start" ? tr("启动中...", "Starting...") : tr("启动", "Start")}</Button>
<Button className="bg-amber-600" disabled={busy === "process:restart"} onClick={() => void processAction("restart")}>{busy === "process:restart" ? tr("重启中...", "Restarting...") : tr("重启", "Restart")}</Button>
<Button className="col-span-2 bg-rose-600" disabled={busy === "process:stop"} onClick={() => void processAction("stop")}>{busy === "process:stop" ? tr("停止中...", "Stopping...") : tr("停止", "Stop")}</Button>
</div>
</>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-white/12 bg-black/20 p-4 text-sm text-slate-400">
{tr("未发现业务进程。若只部署了 Hub 基座,这是正常状态。", "No business process discovered.")}
</div>
)}
</article>
<article className="glass rounded-3xl p-6 xl:col-span-8">
<h2 className="section-title">{tr("业务日志", "Process Logs")}</h2>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<pre className="log-scroll min-h-[22rem] rounded-2xl border border-emerald-500/15 bg-black/55 p-4 font-mono text-[11px] text-emerald-100/95">{recentLog || tr("暂无输出", "No stdout yet.")}</pre>
<pre className="log-scroll min-h-[22rem] rounded-2xl border border-rose-500/15 bg-black/55 p-4 font-mono text-[11px] text-rose-100/95">{recentError || tr("暂无报错", "No stderr yet.")}</pre>
</div>
</article>
</section>
) : null}
{tab === "modules" ? (
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="section-title">{tr("系统模块", "System Modules")}</h2>
<p className="section-lead">{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshServices()}>
{tr("刷新服务", "Refresh Services")}
</Button>
<Button className="border border-violet-500/35 bg-violet-600/70" disabled={busy === "hardware:probe"} onClick={() => void probeHardware()}>
{busy === "hardware:probe" ? tr("探测中…", "Probing…") : tr("重探测硬件", "Re-probe HW")}
</Button>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{services.map((item) => (
<div key={item.service_id} className="service-card">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-white">{item.label}</h3>
<p className="mt-1 text-xs text-slate-400">{item.description}</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-xs ${statusTone(item.status)}`}>{statusText(item.status)}</span>
</div>
<p className="mt-3 min-h-[3rem] text-xs text-slate-500">{item.detail || "-"}</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button className="bg-emerald-600" disabled={!item.available || !!busy} onClick={() => void serviceAction(item.service_id, "start")}>{tr("启动", "Start")}</Button>
<Button className="bg-amber-600" disabled={!item.available || !!busy} onClick={() => void serviceAction(item.service_id, "restart")}>{tr("重启", "Restart")}</Button>
<Button className="bg-rose-600" disabled={!item.available || !!busy} onClick={() => void serviceAction(item.service_id, "stop")}>{tr("停止", "Stop")}</Button>
{serviceHref(item) ? <a className="btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-200" href={serviceHref(item)} target="_blank" rel="noreferrer">{tr("打开", "Open")}</a> : null}
</div>
</div>
))}
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("硬件与系统", "Hardware and System")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-xs text-slate-400">
<p>Arch: {hardware?.arch || "-"}</p>
<p className="mt-2">FFmpeg: {(hardware?.ffmpeg_hwaccels || []).join(", ") || "-"}</p>
<p className="mt-2">GPU: {hardware?.gpu?.present ? hardware.gpu.driver || "present" : "none"}</p>
<p className="mt-2">NPU: {hardware?.npu?.present ? (hardware.npu.devices || []).join(", ") : "none"}</p>
<p className="mt-2">Decoder: {hardware?.recommendation?.video_decoder || "cpu"}</p>
<p className="mt-2">HDMI: {hardware?.recommendation?.hdmi_player || "cpu"}</p>
<p className="mt-2">Mode: {hardware?.recommendation?.stability_mode || "safe"}</p>
</div>
</article>
</section>
) : null}
{tab === "shellcrash" ? (
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">ShellCrash</h2>
<p className="mt-2 text-xs text-slate-400">{tr("这里不再依赖命令行菜单,直接做 YAML / JSON / CFG 编辑与服务控制。", "Manage ShellCrash from the web instead of the shell menu.")}</p>
{shellcrashService ? (
<div className="mt-4 rounded-3xl border border-white/8 bg-black/20 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-white">{shellcrashService.label}</h3>
<p className="mt-1 text-xs text-slate-400">{shellcrashService.description}</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-xs ${statusTone(shellcrashService.status)}`}>{statusText(shellcrashService.status)}</span>
</div>
<p className="mt-3 text-xs text-slate-500">{shellcrashService.detail || "-"}</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button className="bg-emerald-600" disabled={!shellcrashService.available || !!busy} onClick={() => void serviceAction("shellcrash", "start")}>{tr("启动", "Start")}</Button>
<Button className="bg-amber-600" disabled={!shellcrashService.available || !!busy} onClick={() => void serviceAction("shellcrash", "restart")}>{tr("重启", "Restart")}</Button>
<Button className="bg-rose-600" disabled={!shellcrashService.available || !!busy} onClick={() => void serviceAction("shellcrash", "stop")}>{tr("停止", "Stop")}</Button>
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-xs text-slate-400">
<p className="text-white">{tr("快捷文件", "Quick Files")}</p>
<div className="mt-3 grid gap-2">
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-left text-slate-200" onClick={() => void openManagedFile("shellcrash-yamls", "config.yaml")}>config.yaml</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-left text-slate-200" onClick={() => void openManagedFile("shellcrash-configs", "ShellCrash.cfg")}>ShellCrash.cfg</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-left text-slate-200" onClick={() => void openManagedFile("shellcrash-configs", "servers.list")}>servers.list</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-left text-slate-200" onClick={() => void openManagedFile("shellcrash-configs", "command.env")}>command.env</button>
</div>
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-8">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("ShellCrash 文件编辑器", "ShellCrash Editor")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("支持查看、修改、新建、删除,路径被限制在 ShellCrash 托管目录内。", "View, edit, create and delete files inside managed ShellCrash roots.")}</p>
</div>
<div className="flex flex-wrap gap-2">
<label className={`btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-100 ${busy ? "cursor-not-allowed opacity-40" : ""}`}>
{busy === "file:import" ? tr("导入中...", "Importing...") : tr("导入/替换", "Import/Replace")}
<input key={fileImportKey} className="hidden" disabled={!!busy} type="file" accept=".yaml,.yml,.json,.cfg,.env,.list,.txt" onChange={(event) => void importManagedFile(event)} />
</label>
<Button className="bg-cyan-600" disabled={!filePath || !!busy} onClick={() => void saveFile()}>{busy === "file:save" ? tr("保存中...", "Saving...") : tr("保存", "Save")}</Button>
</div>
</div>
<div className="mt-4 grid gap-6 xl:grid-cols-[18rem,1fr]">
<div>
<select className="w-full rounded-2xl border border-white/12 bg-slate-950/60 px-4 py-3 text-sm text-white" value={rootId} onChange={(event) => { setRootId(event.target.value); setFilePath(""); }}>
{shellRoots.map((item) => (
<option key={item.root_id} value={item.root_id}>{item.label} | {item.file_count}</option>
))}
</select>
<div className="mt-4 flex flex-wrap gap-2">
<Button className="bg-cyan-600" disabled={!!busy} onClick={() => void createFile()}>{tr("新建", "New")}</Button>
<Button className="bg-rose-600" disabled={!filePath || !!busy} onClick={() => void deleteFile()}>{tr("删除", "Delete")}</Button>
</div>
<div className="mt-4 space-y-2">
{files.map((item) => (
<button key={item.path} className={`btn-focus w-full rounded-2xl border px-4 py-3 text-left ${filePath === item.path ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/8 bg-black/20"}`} onClick={() => setFilePath(item.path)}>
<div className="truncate text-sm text-white">{item.path}</div>
<div className="mt-1 text-[11px] text-slate-500">{formatBytes(item.size)}</div>
</button>
))}
</div>
</div>
<div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 font-mono text-[11px] text-slate-500">{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}</div>
<textarea className="log-scroll mt-4 min-h-[32rem] w-full resize-y rounded-2xl border border-white/10 bg-black/45 p-4 font-mono text-xs leading-relaxed text-slate-200" spellCheck={false} value={fileContent} onChange={(event) => setFileContent(event.target.value)} />
</div>
</div>
</article>
</section>
) : null}
{tab === "android" ? (
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("安卓设备中心", "Android Device Center")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("把设备发现、截图预览、ADB 动作和原始 web-scrcpy 放到一个模块里。", "Combine discovery, preview, ADB actions and raw web-scrcpy in one module.")}</p>
</div>
<Button className="border border-white/12 bg-black/20" onClick={() => void (async () => { const serial = await refreshAndroidDevices(); if (serial) await Promise.all([refreshAndroidOverview(serial), refreshAndroidUi(serial), refreshAndroidPackages(serial)]); setShotNonce(Date.now()); })()}>{tr("刷新设备", "Refresh")}</Button>
</div>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4">
<p className="text-sm font-semibold text-white">{tr("已连接设备", "Devices")}</p>
{!androidAvailable || androidDevices.length === 0 ? (
<p className="mt-3 text-sm text-slate-400">{tr("当前没有可用的 ADB 设备。", "No active ADB device.")}</p>
) : (
<div className="mt-3 space-y-2">
{androidDevices.map((item) => (
<button key={item.serial} className={`btn-focus w-full rounded-2xl border px-4 py-3 text-left ${androidSerial === item.serial ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/8 bg-black/20"}`} onClick={() => { setAndroidSerial(item.serial); void Promise.all([refreshAndroidOverview(item.serial), refreshAndroidUi(item.serial), refreshAndroidPackages(item.serial)]); setShotNonce(Date.now()); }}>
<div className="text-sm text-white">{item.model || item.device || item.serial}</div>
<div className="mt-1 text-[11px] text-slate-500">{item.serial} | {item.state}</div>
</button>
))}
</div>
)}
</div>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-xs text-slate-400">
<p className="text-white">{tr("设备状态", "Device Status")}</p>
<p className="mt-2">{androidOverview?.brand || "-"} {androidOverview?.model || "-"}</p>
<p className="mt-2">Android {androidOverview?.android_version || "-"} / SDK {androidOverview?.sdk || "-"}</p>
<p className="mt-2 whitespace-pre-wrap">{androidOverview?.battery || "-"}</p>
<p className="mt-2 whitespace-pre-wrap">{androidOverview?.resolution || "-"}</p>
<p className="mt-2 whitespace-pre-wrap">{androidOverview?.focus || "-"}</p>
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-8">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("屏幕预览与动作", "Screen Preview and Actions")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("预览图支持直接点按/双击/长按,配合 UI 节点和包管理形成更完整的安卓控制台。", "The preview supports tap, double tap and long press, backed by UI nodes and package tools.")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button className={`border border-white/12 ${previewMode === "tap" ? "bg-cyan-600" : "bg-black/20"}`} disabled={!androidSerial || !!busy} onClick={() => setPreviewMode("tap")}>{tr("点击", "Tap")}</Button>
<Button className={`border border-white/12 ${previewMode === "double_tap" ? "bg-cyan-600" : "bg-black/20"}`} disabled={!androidSerial || !!busy} onClick={() => setPreviewMode("double_tap")}>{tr("双击", "Double Tap")}</Button>
<Button className={`border border-white/12 ${previewMode === "long_press" ? "bg-cyan-600" : "bg-black/20"}`} disabled={!androidSerial || !!busy} onClick={() => setPreviewMode("long_press")}>{tr("长按", "Long Press")}</Button>
<Button className="border border-white/12 bg-black/20" disabled={!androidSerial} onClick={() => setShotNonce(Date.now())}>{tr("刷新截图", "Refresh Shot")}</Button>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-3xl border border-white/8 bg-black/35 p-4">
{androidSerial ? (
<>
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>{tr("当前动作", "Current action")}: {previewActionLabel}</span>
<span>{tr("分辨率", "Resolution")}: {androidOverview?.resolution || "-"}</span>
<span>{tr("最近坐标", "Last point")}: {previewPoint ? `${previewPoint.x}, ${previewPoint.y}` : "-"}</span>
{shotLoading ? <span className="text-cyan-400/90">{tr("截图刷新中…", "Refreshing…")}</span> : null}
</div>
{shotFetchErr ? (
<div className="mb-3 rounded-xl border border-rose-500/30 bg-rose-950/25 px-4 py-3 text-sm text-rose-100">
{tr("截图不可用:", "Screenshot unavailable: ")}
{shotFetchErr}
</div>
) : null}
{shotObjectUrl ? (
<img
alt="Android preview"
className="max-h-[min(70vh,48rem)] min-h-[14rem] w-full cursor-crosshair rounded-2xl object-contain bg-black"
onClick={(event) => void handlePreviewClick(event)}
src={shotObjectUrl}
/>
) : !shotFetchErr ? (
<div className="flex min-h-[18rem] items-center justify-center rounded-2xl border border-dashed border-white/10 bg-black/30 text-sm text-slate-500">
{tr("正在拉取截图…", "Fetching screenshot…")}
</div>
) : null}
</>
) : <div className="flex min-h-[18rem] items-center justify-center rounded-2xl border border-dashed border-white/10 bg-black/20 text-sm text-slate-500">{tr("当前没有可用的 ADB 设备。", "No active ADB device.")}</div>}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-3">
<div className="rounded-3xl border border-white/8 bg-black/20 p-5">
<h3 className="text-base font-semibold text-white">{tr("快捷控制", "Quick Controls")}</h3>
<div className="mt-4 grid grid-cols-3 gap-2">
{[
["HOME", "KEYCODE_HOME"], ["BACK", "KEYCODE_BACK"], ["RECENTS", "KEYCODE_APP_SWITCH"],
["POWER", "KEYCODE_POWER"], ["NOTIFY", "KEYCODE_NOTIFICATION"], ["VOL+", "KEYCODE_VOLUME_UP"],
["VOL-", "KEYCODE_VOLUME_DOWN"],
].map(([label, keycode]) => (
<button key={label} className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-3 text-xs text-slate-100" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("key", { keycode })}>{label}</button>
))}
<button className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-3 text-xs text-slate-100" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("wake")}>WAKE</button>
<button className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-3 text-xs text-slate-100" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("rotate", { rotation: "0" })}>Portrait</button>
<button className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-3 text-xs text-slate-100" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("rotate", { rotation: "1" })}>Landscape</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{["up", "down", "left", "right"].map((direction) => (
<button key={direction} className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-3 text-xs uppercase text-slate-100" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("swipe", { direction })}>Swipe {direction}</button>
))}
</div>
</div>
<div className="rounded-3xl border border-white/8 bg-black/20 p-5">
<h3 className="text-base font-semibold text-white">{tr("输入与跳转", "Input and Launch")}</h3>
<textarea className="mt-4 min-h-[6rem] w-full rounded-2xl border border-white/10 bg-black/45 p-4 text-sm text-slate-200" placeholder={tr("输入要发送到安卓输入框的文字", "Text to send to Android")} value={androidText} onChange={(event) => setAndroidText(event.target.value)} />
<Button className="mt-3 bg-cyan-600" disabled={!androidText.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("text", { text: androidText })}>{tr("发送文本", "Send Text")}</Button>
<input className="mt-4 w-full rounded-2xl border border-white/10 bg-black/45 px-4 py-3 text-sm text-slate-200" placeholder="com.zhiliaoapp.musically" value={androidPackage} onChange={(event) => setAndroidPackage(event.target.value)} />
<div className="mt-3 grid grid-cols-3 gap-2">
<Button className="bg-emerald-600" disabled={!androidPackage.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("launch_package", { package: androidPackage })}>{tr("启动", "Launch")}</Button>
<Button className="bg-amber-600" disabled={!androidPackage.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("force_stop", { package: androidPackage })}>{tr("停应用", "Stop App")}</Button>
<Button className="bg-rose-600" disabled={!androidPackage.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("clear_app", { package: androidPackage })}>{tr("清数据", "Clear Data")}</Button>
</div>
<input className="mt-4 w-full rounded-2xl border border-white/10 bg-black/45 px-4 py-3 text-sm text-slate-200" placeholder="https://www.tiktok.com" value={androidUrl} onChange={(event) => setAndroidUrl(event.target.value)} />
<Button className="mt-3 bg-violet-600" disabled={!androidUrl.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("open_url", { url: androidUrl })}>{tr("打开链接", "Open URL")}</Button>
</div>
<div className="rounded-3xl border border-white/8 bg-black/20 p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">{tr("应用工作台", "App Workspace")}</h3>
<Button className="border border-white/12 bg-black/20" disabled={!androidSerial || !!busy} onClick={() => void refreshAndroidUi(androidSerial)}>{tr("刷新UI", "Refresh UI")}</Button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{[
["TikTok", "com.zhiliaoapp.musically"],
["Chrome", "com.android.chrome"],
["Settings", "com.android.settings"],
["YouTube", "com.google.android.youtube"],
["Play", "com.android.vending"],
].map(([label, pkg]) => (
<button key={pkg} className="btn-focus rounded-xl border border-white/10 bg-slate-950/50 px-3 py-2 text-xs text-slate-100" disabled={!androidSerial || !!busy} onClick={() => { setAndroidPackage(pkg); void sendAndroidAction("launch_package", { package: pkg }); }}>
{label}
</button>
))}
</div>
<div className="mt-4 flex gap-2">
<input className="w-full rounded-2xl border border-white/10 bg-black/45 px-4 py-3 text-sm text-slate-200" placeholder={tr("搜索包名,例如 tiktok", "Search package, e.g. tiktok")} value={androidPackageQuery} onChange={(event) => setAndroidPackageQuery(event.target.value)} />
<Button className="bg-cyan-600" disabled={!androidSerial || !!busy} onClick={() => void refreshAndroidPackages(androidSerial)}>{tr("搜索", "Search")}</Button>
</div>
<div className="mt-4 space-y-2">
{androidPackages.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-slate-500">{tr("还没有查询结果。", "No package results yet.")}</div>
) : (
androidPackages.slice(0, 8).map((item) => (
<div key={item.package} className="rounded-2xl border border-white/8 bg-slate-950/30 p-3">
<div className="text-sm text-white">{item.label || item.package.split(".").pop()}</div>
<div className="mt-1 truncate font-mono text-[11px] text-slate-500">{item.package}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-xs text-slate-200" disabled={!androidSerial || !!busy} onClick={() => { setAndroidPackage(item.package); void sendAndroidAction("launch_package", { package: item.package }); }}>{tr("启动", "Launch")}</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-xs text-slate-200" disabled={!androidSerial || !!busy} onClick={() => { setAndroidPackage(item.package); void sendAndroidAction("force_stop", { package: item.package }); }}>{tr("停应用", "Stop")}</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-xs text-slate-200" disabled={!androidSerial || !!busy} onClick={() => { setAndroidPackage(item.package); void sendAndroidAction("clear_app", { package: item.package }); }}>{tr("清数据", "Clear")}</button>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr,1.05fr]">
<div className="rounded-3xl border border-white/8 bg-black/20 p-5">
<h3 className="text-base font-semibold text-white">ADB Shell</h3>
<div className="mt-4 flex flex-col gap-3 lg:flex-row">
<input className="w-full rounded-2xl border border-white/10 bg-black/45 px-4 py-3 text-sm text-slate-200" placeholder="input keyevent KEYCODE_HOME" value={androidShell} onChange={(event) => setAndroidShell(event.target.value)} />
<Button className="bg-amber-600" disabled={!androidShell.trim() || !androidSerial || !!busy} onClick={() => void sendAndroidAction("shell", { command: androidShell })}>{tr("执行命令", "Run Command")}</Button>
</div>
<pre className="log-scroll mt-4 min-h-[14rem] rounded-2xl border border-white/10 bg-black/45 p-4 font-mono text-[11px] text-slate-200">{androidOutput || tr("动作结果会显示在这里。", "Action output will appear here.")}</pre>
</div>
<div className="rounded-3xl border border-white/8 bg-black/20 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-white">{tr("UI 节点", "UI Nodes")}</h3>
<p className="mt-2 text-xs text-slate-400">{tr("用 uiautomator 抓当前页面结构,快速定位可点击元素。", "Inspect the current UI hierarchy and act on clickable nodes.")}</p>
</div>
<Button className="border border-white/12 bg-black/20" disabled={!androidSerial || !!busy} onClick={() => void refreshAndroidUi(androidSerial)}>{tr("刷新节点", "Refresh Nodes")}</Button>
</div>
<div className="mt-4 space-y-3">
{androidNodes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-slate-500">{tr("还没有抓到 UI 节点。", "No UI nodes yet.")}</div>
) : (
androidNodes.slice(0, 12).map((node, index) => (
<div key={`${node.resource_id || node.label}-${index}`} className="rounded-2xl border border-white/8 bg-slate-950/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm text-white">{node.label || node.resource_id || node.class_name || "node"}</div>
<div className="mt-1 text-[11px] text-slate-500">{node.resource_id || node.class_name || "-"}</div>
<div className="mt-1 text-[11px] text-slate-500">{node.bounds} | {node.center_x},{node.center_y}</div>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-xs text-slate-200" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("tap", { x: node.center_x, y: node.center_y })}>{tr("点击", "Tap")}</button>
<button className="btn-focus rounded-xl border border-white/10 px-3 py-2 text-xs text-slate-200" disabled={!androidSerial || !!busy} onClick={() => void sendAndroidAction("long_press", { x: node.center_x, y: node.center_y, duration: 750 })}>{tr("长按", "Long Press")}</button>
</div>
</div>
{node.text || node.content_desc ? (
<div className="mt-2 whitespace-pre-wrap text-xs text-slate-400">{node.text || node.content_desc}</div>
) : null}
</div>
))
)}
</div>
</div>
</div>
{rawAndroidPanelUrl ? (
<div className="mt-6 rounded-3xl border border-white/8 bg-black/20 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">ws-scrcpy</h3>
<a className="btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-200" href={rawAndroidPanelUrl} target="_blank" rel="noreferrer">{tr("新标签全屏打开", "Open full tab")}</a>
</div>
<p className="mt-2 text-xs leading-relaxed text-slate-500">
{tr(
"内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线并尝试新标签打开NekoChromium在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。",
"Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium is often :9200 when ENABLE_NEKO=1 and the container is up.",
)}
</p>
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="ws-scrcpy" />
</div>
) : null}
</article>
</section>
) : null}
{tab === "config" ? (
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("配置中心", "Config Center")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("系统级与业务级配置文件分目录托管,避免大杂烩。", "System and business configs are isolated by managed roots.")}</p>
</div>
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshFiles(rootId)}>{tr("刷新", "Refresh")}</Button>
</div>
<select className="mt-4 w-full rounded-2xl border border-white/12 bg-slate-950/60 px-4 py-3 text-sm text-white" value={rootId} onChange={(event) => { setRootId(event.target.value); setFilePath(""); }}>
{activeRoots.map((item) => (
<option key={item.root_id} value={item.root_id}>{item.label} | {item.file_count}</option>
))}
</select>
{currentRoot ? (
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-xs text-slate-400">
<p className="text-white">{currentRoot.label}</p>
<p className="mt-2">{currentRoot.description}</p>
<p className="mt-2 font-mono text-[11px] text-slate-500">{currentRoot.path}</p>
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<Button className="bg-cyan-600" disabled={!!busy} onClick={() => void createFile()}>{tr("新建", "New")}</Button>
<Button className="bg-rose-600" disabled={!filePath || !!busy} onClick={() => void deleteFile()}>{tr("删除", "Delete")}</Button>
</div>
<div className="mt-4 space-y-2">
{files.map((item) => (
<button key={item.path} className={`btn-focus w-full rounded-2xl border px-4 py-3 text-left ${filePath === item.path ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/8 bg-black/20"}`} onClick={() => setFilePath(item.path)}>
<div className="truncate text-sm text-white">{item.path}</div>
<div className="mt-1 text-[11px] text-slate-500">{formatBytes(item.size)} | {new Date(item.mtime * 1000).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { hour12: false })}</div>
</button>
))}
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-8">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("文件编辑器", "Config Editor")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("仅允许编辑受管目录中的配置文件。", "Only approved config roots are editable.")}</p>
</div>
<div className="flex flex-wrap gap-2">
<label className={`btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-100 ${busy ? "cursor-not-allowed opacity-40" : ""}`}>
{busy === "file:import" ? tr("导入中...", "Importing...") : tr("导入/替换", "Import/Replace")}
<input key={`config-${fileImportKey}`} className="hidden" disabled={!!busy} type="file" accept=".yaml,.yml,.json,.cfg,.env,.list,.txt,.ini" onChange={(event) => void importManagedFile(event)} />
</label>
<Button className="bg-cyan-600" disabled={!filePath || !!busy} onClick={() => void saveFile()}>{busy === "file:save" ? tr("保存中...", "Saving...") : tr("保存", "Save")}</Button>
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 font-mono text-[11px] text-slate-500">{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}</div>
<div className="mt-4">
<ConfigAdvisorPanel
lang={lang}
rootId={rootId}
filePath={filePath}
fileContent={fileContent}
refreshKey={configInsightVersion}
busy={busy}
fetchJson={fetchJson}
notify={notify}
onChangeContent={setFileContent}
onRefreshListing={() => refreshFiles(rootId, filePath)}
/>
</div>
<textarea className="log-scroll mt-4 min-h-[32rem] w-full resize-y rounded-2xl border border-white/10 bg-black/45 p-4 font-mono text-xs leading-relaxed text-slate-200" spellCheck={false} value={fileContent} onChange={(event) => setFileContent(event.target.value)} />
</article>
</section>
) : null}
</div>
) : null}
</main>
{!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}
</>
);
}