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