This commit is contained in:
root
2026-05-17 13:21:44 +00:00
parent ffdc887b8a
commit a3e8a4a96c
27 changed files with 671 additions and 140 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel";
@@ -167,6 +167,41 @@ function parseResolution(resolution?: string) {
return { width: Number(match[1]), height: Number(match[2]) };
}
function clientPointToImagePixel(
img: HTMLImageElement,
clientX: number,
clientY: number,
): { x: number; y: number } | null {
const rect = img.getBoundingClientRect();
const nw = img.naturalWidth;
const nh = img.naturalHeight;
if (!nw || !nh || !rect.width || !rect.height) return null;
const elAR = rect.width / rect.height;
const imAR = nw / nh;
let drawW: number;
let drawH: number;
let offX: number;
let offY: number;
if (imAR > elAR) {
drawW = rect.width;
drawH = rect.width / imAR;
offX = 0;
offY = (rect.height - drawH) / 2;
} else {
drawH = rect.height;
drawW = rect.height * imAR;
offX = (rect.width - drawW) / 2;
offY = 0;
}
const lx = clientX - rect.left - offX;
const ly = clientY - rect.top - offY;
if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null;
return {
x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))),
y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))),
};
}
function Button({
children,
className,
@@ -212,6 +247,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [pageVisible, setPageVisible] = useState(true);
const toastTimerRef = useRef<number | null>(null);
const [entries, setEntries] = useState<ProcessEntry[]>([]);
const [currentProcess, setCurrentProcess] = useState("");
@@ -280,7 +317,13 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
const notify = useCallback((message: string) => {
setToast(message);
window.setTimeout(() => setToast(null), 2500);
if (toastTimerRef.current != null) {
window.clearTimeout(toastTimerRef.current);
}
toastTimerRef.current = window.setTimeout(() => {
setToast(null);
toastTimerRef.current = null;
}, 2500);
}, []);
const fetchJson = useCallback(
@@ -481,6 +524,22 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
window.localStorage.setItem("live-console-theme", theme);
}, [theme, embeddedShellCrash]);
useEffect(() => {
const onVis = () => setPageVisible(document.visibilityState === "visible");
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(
() => () => {
if (toastTimerRef.current != null) {
window.clearTimeout(toastTimerRef.current);
}
},
[],
);
useEffect(() => {
if (!embeddedShellCrash) return;
if (portalLang === "zh" || portalLang === "en") setLang(portalLang);
@@ -496,19 +555,19 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
}, [boot]);
useEffect(() => {
if (!currentProcess) return;
if (!currentProcess || !pageVisible) return;
void refreshProcess();
const timer = window.setInterval(() => void refreshProcess(), 1500);
return () => window.clearInterval(timer);
}, [currentProcess, refreshProcess]);
}, [currentProcess, pageVisible, refreshProcess]);
useEffect(() => {
if (booting || error) return;
if (booting || error || !pageVisible) 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]);
}, [booting, error, pageVisible, refreshServices, tab]);
useEffect(() => {
const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots;
@@ -557,13 +616,15 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
}, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]);
useEffect(() => {
if (tab !== "android" || !androidSerial) return;
if (tab !== "android" || !androidSerial || !pageVisible) return;
void refreshAndroidOverview(androidSerial);
setShotNonce(Date.now());
const timer = window.setInterval(() => {
void refreshAndroidOverview(androidSerial);
setShotNonce(Date.now());
}, 5000);
return () => window.clearInterval(timer);
}, [androidSerial, refreshAndroidOverview, tab]);
}, [androidSerial, pageVisible, refreshAndroidOverview, tab]);
useEffect(() => {
if (!androidSerial) {
@@ -773,17 +834,17 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
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))),
);
const point = clientPointToImagePixel(event.currentTarget, event.clientX, event.clientY);
if (!point) {
notify(tr("请点击画面有效区域", "Tap inside the visible frame"));
return;
}
const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth;
const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight;
const scaleX = sourceWidth > 0 ? sourceWidth / Math.max(1, event.currentTarget.naturalWidth) : 1;
const scaleY = sourceHeight > 0 ? sourceHeight / Math.max(1, event.currentTarget.naturalHeight) : 1;
const x = Math.max(0, Math.min(sourceWidth || point.x, Math.round(point.x * scaleX)));
const y = Math.max(0, Math.min(sourceHeight || point.y, Math.round(point.y * scaleY)));
setPreviewPoint({ x, y });
if (previewMode === "long_press") {
await sendAndroidAction("long_press", { x, y, duration: 750 });
@@ -843,7 +904,12 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
}
>
{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">
<div
role="status"
aria-live="polite"
aria-atomic="true"
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}

View File

@@ -187,6 +187,7 @@ export function LiveAndroidStreamConsole({
server_version?: string;
hint_zh?: string;
} | null>(null);
const [pageVisible, setPageVisible] = useState(true);
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -206,6 +207,13 @@ export function LiveAndroidStreamConsole({
setFrameTs((n) => n + 1);
}, []);
useEffect(() => {
const onVis = () => setPageVisible(document.visibilityState === "visible");
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
try {
const raw = window.localStorage.getItem(BOOKMARKS_KEY);
@@ -256,10 +264,15 @@ export function LiveAndroidStreamConsole({
}, [apiBase, adbAvailable, serial]);
useEffect(() => {
if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return;
if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream || !pageVisible) return;
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
return () => clearInterval(id);
}, [adbAvailable, liveMs, serial, scrcpyStream]);
}, [adbAvailable, liveMs, pageVisible, serial, scrcpyStream]);
useEffect(() => {
if (!pageVisible || !adbAvailable || !serial || scrcpyStream) return;
bumpFrame();
}, [adbAvailable, bumpFrame, pageVisible, serial, scrcpyStream]);
useEffect(() => {
if (!adbAvailable) {

View File

@@ -167,7 +167,8 @@ function formatUptime(sec?: number) {
}
function formatBytes(value?: number) {
if (!value) return "—";
if (value == null || !Number.isFinite(value)) return "—";
if (value === 0) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
let n = value;
let i = 0;
@@ -212,6 +213,8 @@ export default function LiveControlApp() {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const [pageVisible, setPageVisible] = useState(true);
const [entries, setEntries] = useState<ProcessEntry[]>([]);
const [processRows, setProcessRows] = useState<ProcessMonitorRow[]>([]);
@@ -226,12 +229,19 @@ export default function LiveControlApp() {
const processMonitorInFlightRef = useRef<Promise<void> | null>(null);
const currentProcRef = useRef(currentProc);
const urlConfigRequestSeqRef = useRef(0);
const toastTimerRef = useRef<number | null>(null);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
const notify = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast(null), 2800);
if (toastTimerRef.current != null) {
window.clearTimeout(toastTimerRef.current);
}
toastTimerRef.current = window.setTimeout(() => {
setToast(null);
toastTimerRef.current = null;
}, 2800);
}, []);
const fetchJson = useCallback(
@@ -248,6 +258,7 @@ export default function LiveControlApp() {
const d = await fetchJson<HubDashboard>("/hub/dashboard");
setDash(d);
setDashErr(null);
setLastSyncedAt(Date.now());
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
@@ -279,6 +290,7 @@ export default function LiveControlApp() {
const list = data.entries || [];
setProcessRows(list);
setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label })));
setLastSyncedAt(Date.now());
setCurrentProc((prev) => {
if (!list.length) return prev;
if (prev && list.some((row) => row.pm2 === prev)) return prev;
@@ -314,6 +326,36 @@ export default function LiveControlApp() {
}
}, [portalTheme]);
useEffect(() => {
const onVis = () => setPageVisible(document.visibilityState === "visible");
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
if (!mobileNav) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMobileNav(false);
};
window.addEventListener("keydown", onKeyDown);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
};
}, [mobileNav]);
useEffect(
() => () => {
if (toastTimerRef.current != null) {
window.clearTimeout(toastTimerRef.current);
}
},
[],
);
useEffect(() => {
try {
if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false);
@@ -332,20 +374,20 @@ export default function LiveControlApp() {
}, [refreshProcessMonitor]);
useEffect(() => {
if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return;
if (!autoRefreshEnabled || !pageVisible || view === "live_youtube" || view === "live_tiktok") return;
const id = window.setInterval(() => {
void refreshDashboard();
}, 10000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshDashboard, view]);
}, [autoRefreshEnabled, pageVisible, refreshDashboard, view]);
useEffect(() => {
if (!autoRefreshEnabled) return;
if (!autoRefreshEnabled || !pageVisible) return;
const id = window.setInterval(() => {
void refreshProcessMonitor();
}, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshProcessMonitor, view]);
}, [autoRefreshEnabled, pageVisible, refreshProcessMonitor, view]);
useEffect(() => {
currentProcRef.current = currentProc;
@@ -497,7 +539,22 @@ export default function LiveControlApp() {
];
const copyText = (text: string) => {
void navigator.clipboard.writeText(text).then(
const write = async () => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const ta = document.createElement("textarea");
ta.value = text;
ta.readOnly = true;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
};
void write().then(
() => notify(t("已复制", "Copied")),
() => notify(t("复制失败", "Copy failed")),
);
@@ -506,6 +563,10 @@ export default function LiveControlApp() {
const onlineServices =
dash?.services?.filter((s) => s.running && s.available).length ?? 0;
const totalServices = dash?.services?.length ?? 0;
const lastSyncLabel = lastSyncedAt
? new Date(lastSyncedAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour12: false })
: t("未同步", "Not synced");
const refreshStateLabel = pageVisible ? t("前台自动刷新", "Auto refresh") : t("后台已暂停刷新", "Refresh paused in background");
return (
<div
@@ -536,6 +597,9 @@ export default function LiveControlApp() {
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
role="status"
aria-live="polite"
aria-atomic="true"
className="fixed bottom-6 left-1/2 z-[100] max-w-md -translate-x-1/2 rounded-2xl border border-white/10 bg-zinc-950/95 px-5 py-3 text-center text-sm text-zinc-100 shadow-2xl shadow-black/50 backdrop-blur-xl"
>
{toast}
@@ -571,6 +635,7 @@ export default function LiveControlApp() {
key={item.id}
type="button"
onClick={() => setView(item.id)}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition ${
portalTheme === "light"
? active
@@ -633,7 +698,8 @@ export default function LiveControlApp() {
</h1>
<p className={`truncate text-xs ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
{dash?.stack?.mdns_host || "live.local"} ·{" "}
{portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {t("自动刷新", "Auto refresh")}
{portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {refreshStateLabel} ·{" "}
{t("最后同步", "Last sync")} {lastSyncLabel}
</p>
</div>
</div>
@@ -1532,6 +1598,7 @@ export default function LiveControlApp() {
<nav className="mt-4 flex flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = view === item.id;
return (
<button
key={item.id}
@@ -1540,9 +1607,12 @@ export default function LiveControlApp() {
setView(item.id);
setMobileNav(false);
}}
className="flex items-center gap-3 rounded-xl px-3 py-3 text-left text-sm text-zinc-300"
aria-current={active ? "page" : undefined}
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-left text-sm transition ${
active ? "bg-white/[0.08] text-white shadow-inner shadow-white/5" : "text-zinc-300 hover:bg-white/[0.04]"
}`}
>
<Icon className="h-4 w-4 text-violet-400" />
<Icon className={`h-4 w-4 ${active ? "text-violet-300" : "text-violet-400"}`} />
{t(item.labelZh, item.labelEn)}
</button>
);

View File

@@ -88,6 +88,7 @@ export function LiveProcessLiveLogCard({
const [paused, setPaused] = useState(false);
const [liveSince, setLiveSince] = useState<number | null>(null);
const [nowTick, setNowTick] = useState(0);
const [pageVisible, setPageVisible] = useState(true);
const outRef = useRef<HTMLPreElement>(null);
const errRef = useRef<HTMLPreElement>(null);
const prevOnlineRef = useRef(false);
@@ -177,16 +178,23 @@ export function LiveProcessLiveLogCard({
void pull();
}, [currentProc, pull]);
useEffect(() => {
const onVis = () => setPageVisible(document.visibilityState === "visible");
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
if (!currentProc) return;
void pull();
}, [currentProc, pull, refreshToken]);
useEffect(() => {
if (paused || !currentProc) return;
if (paused || !pageVisible || !currentProc) return;
const id = window.setInterval(() => void pull(), pollMs);
return () => clearInterval(id);
}, [paused, currentProc, pull, pollMs]);
}, [paused, pageVisible, currentProc, pull, pollMs]);
useEffect(() => {
const onVis = () => {
@@ -203,21 +211,31 @@ export function LiveProcessLiveLogCard({
}, [liveSince]);
useEffect(() => {
if (paused) return;
if (paused || !pageVisible) return;
const el = outRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentLog, paused]);
}, [recentLog, paused, pageVisible]);
useEffect(() => {
if (paused) return;
if (paused || !pageVisible) return;
const el = errRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [recentError, paused]);
}, [recentError, paused, pageVisible]);
const durationLabel =
liveSince == null
? t("—(未检测到在线)", "— (not online)")
: fmtDuration(Math.max(0, Math.floor((Date.now() - liveSince) / 1000)));
const refreshBadgeLabel = paused
? t("已暂停刷新", "Paused")
: pageVisible
? t("每秒刷新", "~1s refresh")
: t("后台暂停", "Hidden");
const refreshBadgeClass = paused
? "bg-amber-500/20 text-amber-100 ring-amber-500/35"
: pageVisible
? "bg-emerald-500/15 text-emerald-200/90 ring-emerald-500/25"
: "bg-sky-500/15 text-sky-100 ring-sky-500/25";
return (
<section className="rounded-2xl border border-emerald-500/20 bg-gradient-to-b from-emerald-500/[0.07] to-zinc-950/80 p-6 ring-1 ring-white/[0.04]">
@@ -230,8 +248,8 @@ export function LiveProcessLiveLogCard({
<span className="rounded-full bg-white/10 px-2 py-0.5 font-mono text-[10px] text-zinc-300 ring-1 ring-white/10">
{currentProc || "—"}
</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ${refreshBadgeClass}`}>
{refreshBadgeLabel}
</span>
</div>
<div className="flex flex-wrap gap-2">