This commit is contained in:
eric
2026-03-28 15:08:59 -05:00
parent 92ecc14e43
commit d74f3046a3
14 changed files with 1889 additions and 245 deletions

View File

@@ -0,0 +1,597 @@
"use client";
import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type DeviceRow = { serial: string; model?: string; state: string };
type TFn = (zh: string, en: string) => 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 PkgRow = { package: string; label?: string };
type UiNode = {
label: string;
text?: string;
content_desc?: string;
resource_id?: string;
center_x: number;
center_y: number;
clickable?: boolean;
enabled?: boolean;
};
type Props = {
t: TFn;
notify: (msg: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
busy: string | null;
androidSerial: string;
setAndroidSerial: (s: string) => void;
devices: DeviceRow[];
adbAvailable: boolean;
scrcpyUrl: string;
androidShotUrl: string | null;
androidShotErr: string | null;
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
};
export function LiveAndroidWorkstation({
t,
notify,
fetchJson,
busy,
androidSerial,
setAndroidSerial,
devices,
adbAvailable,
scrcpyUrl,
androidShotUrl,
androidShotErr,
apkPath,
setApkPath,
onAndroidAction,
}: Props) {
const [overview, setOverview] = useState<AndroidOverview | null>(null);
const [overviewErr, setOverviewErr] = useState<string | null>(null);
const [pkgQuery, setPkgQuery] = useState("");
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
const [shellOut, setShellOut] = useState("");
const [logcatLines, setLogcatLines] = useState("300");
const [logcatText, setLogcatText] = useState("");
const [logcatErr, setLogcatErr] = useState<string | null>(null);
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
const [uiErr, setUiErr] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState("https://");
const [pending, setPending] = useState<string | null>(null);
const lock = !!(busy || pending);
const loadOverview = useCallback(async () => {
if (!androidSerial || !adbAvailable) {
setOverview(null);
setOverviewErr(null);
return;
}
setPending("overview");
setOverviewErr(null);
try {
const o = await fetchJson<AndroidOverview>(
`/android/overview?serial=${encodeURIComponent(androidSerial)}`,
);
setOverview(o);
} catch (e) {
setOverview(null);
setOverviewErr((e as Error).message);
} finally {
setPending(null);
}
}, [adbAvailable, androidSerial, fetchJson]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const searchPackages = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("packages");
try {
const q = encodeURIComponent(pkgQuery.trim());
const data = await fetchJson<{ packages: PkgRow[] }>(
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
);
setPkgList(data.packages || []);
} catch (e) {
notify((e as Error).message);
setPkgList([]);
} finally {
setPending(null);
}
};
const runShell = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const cmd = shellCmd.trim();
if (!cmd) return;
setPending("shell");
try {
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
});
setShellOut(data.output ?? data.message ?? "");
} catch (e) {
setShellOut((e as Error).message);
} finally {
setPending(null);
}
};
const refreshLogcat = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
setPending(null);
}
};
const loadUiNodes = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("ui");
setUiErr(null);
try {
const data = await fetchJson<{ nodes: UiNode[] }>(
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
);
setUiNodes(data.nodes || []);
} catch (e) {
setUiNodes([]);
setUiErr((e as Error).message);
} finally {
setPending(null);
}
};
const tapNode = async (x: number, y: number) => {
await onAndroidAction("tap", { x, y });
};
const sendKey = async (keycode: string) => {
await onAndroidAction("key", { keycode });
};
const openUrlGo = async () => {
const u = openUrl.trim();
if (!u || u === "https://") {
notify(t("填写 URL", "Enter URL"));
return;
}
await onAndroidAction("open_url", { url: u });
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white">{t("设备与工作台", "Device workstation")}</h3>
{!adbAvailable ? (
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
) : (
<button
type="button"
disabled={lock || !androidSerial}
onClick={() => void loadOverview()}
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
>
{t("刷新设备信息", "Refresh device info")}
</button>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{devices.map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
</button>
))}
</div>
</div>
{adbAvailable && androidSerial ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
{t("设备信息", "Device info")}
</h4>
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
{overview ? (
<dl className="mt-3 space-y-1.5 font-mono text-[11px] text-zinc-400">
<div>
<dt className="inline text-zinc-600">model</dt>{" "}
<dd className="inline text-zinc-300">
{overview.brand} {overview.model}
</dd>
</div>
<div>
<dt className="inline text-zinc-600">Android</dt>{" "}
<dd className="inline text-zinc-300">
{overview.android_version} (sdk {overview.sdk})
</dd>
</div>
<div>
<dt className="inline text-zinc-600">display</dt>{" "}
<dd className="inline text-zinc-300">{overview.resolution}</dd>
</div>
<div>
<dt className="inline text-zinc-600">rotation</dt>{" "}
<dd className="inline text-zinc-300">{overview.rotation}</dd>
</div>
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">
{overview.battery}
</div>
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
</dl>
) : !overviewErr ? (
<p className="mt-2 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
) : null}
</div>
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
{t("快捷控制", "Quick controls")}
</h4>
<p className="mt-2 text-[11px] text-zinc-500">
{t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void sendKey("3")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Home
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("4")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Back
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("187")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Recents
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("wake")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Wake
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })}
className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10"
>
TikTok
</button>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={openUrl}
onChange={(e) => setOpenUrl(e.target.value)}
placeholder="https://"
/>
<button
type="button"
disabled={lock}
onClick={() => void openUrlGo()}
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
>
{t("打开链接", "Open URL")}
</button>
</div>
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Package className="h-4 w-4 text-emerald-400" />
{t("包名检索与启动", "Packages")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("过滤 pm list packages;行内可启动或强停。", "Filter pm list packages; launch or force-stop.")}
</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
value={pkgQuery}
onChange={(e) => setPkgQuery(e.target.value)}
placeholder="tiktok / musically / …"
/>
<button
type="button"
disabled={lock}
onClick={() => void searchPackages()}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("搜索", "Search")}
</button>
</div>
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
{pkgList.map((p) => (
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: p.package })}
className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200"
>
{t("启动", "Launch")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("force_stop", { package: p.package })}
className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200"
>
{t("强停", "Stop")}
</button>
</span>
</li>
))}
{!pkgList.length ? (
<li className="py-2 text-center text-zinc-600">{t("暂无结果,先搜索", "No results")}</li>
) : null}
</ul>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Terminal className="h-4 w-4 text-amber-400" />
{t("交互式 Shell(只读安全子集由你输入决定)", "ADB shell")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("等同 adb shell;勿执行不可逆命令。", "Same as adb shell; avoid destructive commands.")}
</p>
<textarea
className="mt-3 min-h-[72px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
value={shellCmd}
onChange={(e) => setShellCmd(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
onClick={() => void runShell()}
className="mt-2 rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
>
{pending === "shell" ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Run
</span>
) : (
"Run"
)}
</button>
{shellOut ? (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400">
{shellOut}
</pre>
) : null}
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("Logcat(最近行)", "Logcat (recent)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("执行 logcat -d -t N,适合快速排障。", "Runs logcat -d -t N for quick triage.")}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={logcatLines}
onChange={(e) => setLogcatLines(e.target.value)}
/>
<button
type="button"
disabled={lock}
onClick={() => void refreshLogcat()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("拉取日志", "Pull logs")}
</button>
</div>
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
{logcatText ? (
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500">
{logcatText}
</pre>
) : null}
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Layers className="h-4 w-4 text-fuchsia-400" />
{t("界面节点(uiautomator dump)", "UI nodes")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("列出可交互节点,点击坐标模拟触控。", "Clickable nodes from UI dump; tap by coordinates.")}
</p>
<button
type="button"
disabled={lock}
onClick={() => void loadUiNodes()}
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
>
{t("刷新节点树", "Refresh nodes")}
</button>
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
{uiNodes.map((n, i) => (
<li
key={`${n.center_x}-${n.center_y}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
>
<div className="min-w-0 flex-1">
<span className="text-zinc-200">{n.label}</span>
{n.resource_id ? (
<span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span>
) : null}
<div className="font-mono text-[10px] text-zinc-600">
({n.center_x}, {n.center_y}){n.clickable ? " · click" : ""}
</div>
</div>
<button
type="button"
disabled={lock}
onClick={() => void tapNode(n.center_x, n.center_y)}
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
>
Tap
</button>
</li>
))}
{!uiNodes.length && !uiErr ? (
<li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li>
) : null}
</ul>
</div>
) : null}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK(设备上的路径)", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("实时画面(ADB 截图)", "Live screen (ADB)")}</h3>
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
>
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
)}
</p>
{!adbAvailable ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && adbAvailable ? <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}
{adbAvailable && !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">
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
</div>
) : null}
</div>
);
}