1615 lines
84 KiB
TypeScript
1615 lines
84 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import OverviewCharts from "@/components/OverviewCharts";
|
||
import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel";
|
||
import { fetchJsonResponse, readErrorMessage } from "@/lib/fetchJson";
|
||
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> => {
|
||
return fetchJsonResponse<T>(`${base}${path}`, init);
|
||
},
|
||
[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) {
|
||
throw new Error(await readErrorMessage(response));
|
||
}
|
||
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 处理推流或 HDMI;Neko(Docker 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(
|
||
"插电、接网线或连好 Wi‑Fi 后,用浏览器打开本页(验收域名一般为 http://live.local:8001/ ,门户在根路径 /,控制台在 /console)。",
|
||
"After power and Ethernet/Wi‑Fi, 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:本机+油猴+RemoteDebug;Neko:Docker 里跑 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 ? (
|
||
<Image
|
||
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}
|
||
unoptimized
|
||
width={androidResolution?.width || 1080}
|
||
height={androidResolution?.height || 1920}
|
||
/>
|
||
) : !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 在线,并尝试新标签打开;Neko(Chromium)在 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}
|
||
</>
|
||
);
|
||
}
|