This commit is contained in:
eric
2026-03-28 14:16:42 -05:00
parent 8053c4cb6d
commit 92ecc14e43
11 changed files with 325 additions and 45 deletions

View File

@@ -54,6 +54,7 @@ type HubDashboard = {
running: boolean;
status: string;
detail?: string;
url?: string;
}>;
android?: { adb_available: boolean; devices: Array<{ serial: string; model?: string; state: string }> };
live?: {
@@ -108,6 +109,8 @@ export default function LiveControlApp() {
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState("");
const [hubCfgText, setHubCfgText] = useState<string | null>(null);
const [androidShotUrl, setAndroidShotUrl] = useState<string | null>(null);
const [androidShotErr, setAndroidShotErr] = useState<string | null>(null);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
@@ -155,6 +158,59 @@ export default function LiveControlApp() {
return () => clearInterval(id);
}, [refreshDashboard]);
useEffect(() => {
if (view !== "android" || !androidSerial || !dash?.android?.adb_available) {
setAndroidShotErr(null);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
return;
}
let cancelled = false;
const tick = async () => {
try {
const res = await fetch(
`${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}`,
{ cache: "no-store" },
);
const ct = res.headers.get("content-type") || "";
if (!res.ok) {
let msg = `HTTP ${res.status}`;
if (ct.includes("json")) {
try {
const j = (await res.json()) as { error?: string };
if (j.error) msg = j.error;
} catch {
/* ignore */
}
}
if (!cancelled) setAndroidShotErr(msg);
return;
}
const blob = await res.blob();
if (cancelled) return;
setAndroidShotErr(null);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch (e) {
if (!cancelled) setAndroidShotErr((e as Error).message);
}
};
void tick();
const tid = window.setInterval(() => void tick(), 2500);
return () => {
cancelled = true;
window.clearInterval(tid);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
}, [view, androidSerial, base, dash?.android?.adb_available]);
useEffect(() => {
void (async () => {
try {
@@ -224,12 +280,16 @@ export default function LiveControlApp() {
const runService = async (service: string, action: "start" | "stop" | "restart") => {
setBusy(`svc:${service}`);
try {
const data = await fetchJson<{ message?: string }>("/service_action", {
const data = await fetchJson<{ message?: string; status?: string; detail?: string }>("/service_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service, action }),
});
notify(data.message || "OK");
if (data.status === "error") {
notify(data.detail || data.message || t("操作失败", "Action failed"));
} else {
notify(data.message || "OK");
}
await refreshDashboard();
} catch (e) {
notify((e as Error).message);
@@ -570,10 +630,24 @@ export default function LiveControlApp() {
{s.running ? t("运行中", "Running") : s.status}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{s.detail ? (
<p className="mt-2 text-[11px] leading-snug text-amber-200/85 line-clamp-4">{s.detail}</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
{s.url ? (
<a
href={normalizeBrowserReachableHttpUrl(s.url)}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-violet-500/15 px-3 py-1.5 text-xs font-medium text-violet-200 ring-1 ring-violet-500/25"
>
{t("打开面板", "Open panel")}
</a>
) : null}
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "start")}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-200 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
@@ -582,6 +656,7 @@ export default function LiveControlApp() {
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "restart")}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-200 ring-1 ring-amber-500/25 disabled:opacity-30"
>
@@ -590,6 +665,7 @@ export default function LiveControlApp() {
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "stop")}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-200 ring-1 ring-rose-500/25 disabled:opacity-30"
>
@@ -753,6 +829,31 @@ export default function LiveControlApp() {
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。",
"~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.",
)}
</p>
{!dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-rose-300">{androidShotErr}</p>
) : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
@@ -808,21 +909,31 @@ export default function LiveControlApp() {
["SRS", ql.srs_http],
["Neko", ql.neko_browser],
["WebTTY", ql.webtty],
].map(([name, href]) =>
href ? (
<a
key={name}
href={href}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30"
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
) : null,
)}
].map(([name, href]) => (
<a
key={name}
href={href || "#"}
target="_blank"
rel="noreferrer"
className={`flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30 ${href ? "" : "pointer-events-none opacity-40"}`}
onClick={(e) => {
if (!href) {
e.preventDefault();
notify(t("仪表盘未返回该链接", "Hub did not return this link"));
}
}}
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
))}
</div>
<p className="text-center text-xs text-zinc-500">
{t(
"若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko并确认防火墙未拦端口。",
"If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.",
)}
</p>
</div>
) : null}