s'
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user