's'
This commit is contained in:
597
web-console/components/live-product/LiveAndroidWorkstation.tsx
Normal file
597
web-console/components/live-product/LiveAndroidWorkstation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user