Files
gitlab-instance-0a899031_do…/web-console/app/page.tsx
2026-03-28 08:39:12 -05:00

1238 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, useState } from "react";
type Lang = "zh" | "en";
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;
};
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 "/";
}
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,
}: {
children: React.ReactNode;
className?: string;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<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}
>
{children}
</button>
);
}
export default function Home() {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
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 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 rawAndroidPanelUrl =
androidService?.host && androidService.port
? `http://${androidService.host}:${androidService.port}`
: stack?.mdns_host
? `http://${stack.mdns_host}:5000`
: "";
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 === "/") {
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 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(() => {
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);
}, []);
useEffect(() => {
syncUrl(tab, lang);
}, [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(() => {
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]);
const processAction = async (action: "start" | "restart" | "stop") => {
if (!currentProcess) return;
setBusy(`process:${action}`);
try {
const data = await fetchJson<ProcessStatusResponse>(
`/${action}?process=${encodeURIComponent(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?service=${encodeURIComponent(serviceId)}&action=${encodeURIComponent(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);
} 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);
} 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);
} 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);
} 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;
return (
<main className="mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-10">
{toast ? (
<div className="animate-toast-in fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-sm text-cyan-50">
{toast}
</div>
) : null}
<header className="mb-8 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
{tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")}
</p>
<h1 className="mt-2 text-3xl font-bold text-white sm:text-4xl">
{tr("Linux 超级业务中心", "Linux Super Business Center")}
</h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr(
"系统服务、业务模块、ShellCrash 配置与安卓控制保持解耦,一个模块异常不应拖垮整个平台。",
"Services, business modules, ShellCrash configs and Android control stay decoupled.",
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs text-slate-300">
{(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="border border-white/12 bg-black/20" onClick={() => void boot()}>
{tr("刷新", "Refresh")}
</Button>
</div>
</div>
<nav className="flex flex-wrap gap-2">
{TABS.map((item) => (
<button
key={item}
className={`btn-focus rounded-2xl px-4 py-2 text-sm ${
tab === item
? "border border-cyan-400/50 bg-cyan-500/10 text-cyan-100"
: "border border-white/10 bg-black/20 text-slate-300"
}`}
onClick={() => setTab(item)}
>
{{
overview: tr("总览", "Overview"),
business: tr("业务", "Business"),
modules: tr("系统模块", "Modules"),
shellcrash: "ShellCrash",
android: tr("安卓设备", "Android"),
config: tr("配置中心", "Config"),
}[item]}
</button>
))}
</nav>
</header>
{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="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">{tr("常用入口", "Quick Links")}</h2>
<div className="mt-4 grid gap-3">
{stack?.dashboard_url ? (
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={stack.dashboard_url} target="_blank" rel="noreferrer">Homepage</a>
) : null}
{stack?.control_url ? (
<>
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=shellcrash&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开 ShellCrash 工作区", "Open ShellCrash workspace")}</a>
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=android&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开安卓设备中心", "Open Android device center")}</a>
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=config&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开配置中心", "Open Config center")}</a>
</>
) : null}
{rawAndroidPanelUrl ? (
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={rawAndroidPanelUrl} target="_blank" rel="noreferrer">web-scrcpy</a>
) : null}
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("节点状态", "Node Status")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300">
<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="text-lg font-semibold text-white">{tr("系统快照", "System Snapshot")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300">
<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>
</article>
</section>
<section className="glass rounded-3xl p-6">
<h2 className="text-lg font-semibold text-white">{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="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">{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-4">
<h2 className="text-lg font-semibold text-white">{tr("业务运行时", "Business Runtime")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("这里只放业务脚本本身Hub 基座可单独运行。", "Only business processes live here.")}</p>
{entries.length > 0 ? (
<>
<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={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="text-lg font-semibold text-white">{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 items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{tr("系统模块", "System Modules")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}</p>
</div>
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshServices()}>{tr("刷新服务", "Refresh Services")}</Button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{services.map((item) => (
<div key={item.service_id} className="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">{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>
</div>
<img alt="Android preview" className="max-h-[30rem] w-full cursor-crosshair rounded-2xl object-contain bg-black" onClick={(event) => void handlePreviewClick(event)} src={`${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}&ts=${shotNonce}`} />
</>
) : <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 items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">web-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 raw panel")}</a>
</div>
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="web-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>
<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>
);
}