feat(web): site origin view tab with polling screenshot, coordinate input and takeover

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:17:46 +08:00
1 parent adffe36846
commit 8574d34144
5 files changed
+555 -3

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
BrowserActionResult,
BrowserControlResult,
BrowserNavigation,
BrowserProviderView,
BrowserScreenshotView,
BrowserSessionView,
BrowserTarget,
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
// C1-E 原站视图:浏览器服务、站点会话、定时截图与人工输入。输入类动作要先接管(服务端校验租约)。
const AREA = "browser";
export function useBrowserProviders() {
return useQuery({
queryKey: qk.list(AREA, { kind: "providers" }),
queryFn: ({ signal }) => apiData<{ items: BrowserProviderView[] }>("/browser/providers", { signal }),
staleTime: 60_000,
});
}
/** 站点的未关闭会话(最新的一个) */
export function useSiteBrowserSession(siteId: string, enabled = true) {
return useQuery({
queryKey: qk.sub(AREA, "site", siteId),
queryFn: async ({ signal }) => {
const r = await apiData<{ items: BrowserSessionView[] }>("/browser/sessions", { params: { siteId }, signal });
return r.items[0] ?? null;
},
enabled: !!siteId && enabled,
refetchInterval: 5_000,
});
}
/**
* 定时截图(1.5 秒,可暂停)。上一张保留到新图到达,避免闪烁;失败时保留旧图并在界面标"连接中断"。
*/
export function useBrowserScreenshot(sessionId: string | null, opts: { paused: boolean; intervalMs?: number }) {
return useQuery({
queryKey: qk.sub(AREA, "screenshot", sessionId ?? ""),
queryFn: ({ signal }) =>
apiData<BrowserScreenshotView>("/browser/sessions/{id}/screenshot", {
pathParams: { id: sessionId ?? "" },
signal,
}),
enabled: !!sessionId,
refetchInterval: opts.paused ? false : (opts.intervalMs ?? 1_500),
refetchIntervalInBackground: false,
placeholderData: keepPreviousData,
retry: false,
gcTime: 0,
});
}
function useRefresh(siteId: string) {
const qc = useQueryClient();
return (session?: BrowserSessionView | null) => {
if (session !== undefined) qc.setQueryData(qk.sub(AREA, "site", siteId), session);
void qc.invalidateQueries({ queryKey: qk.sub(AREA, "site", siteId) });
void qc.invalidateQueries({ queryKey: qk.sub(AREA, "screenshot") });
};
}
export function useOpenBrowserSession(siteId: string) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: () => apiData<BrowserSessionView>("/browser/sessions", { method: "POST", body: { siteId } }),
onSuccess: (s) => refresh(s),
});
}
export function useCloseBrowserSession(siteId: string) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: (id: string) =>
apiData<BrowserSessionView>("/browser/sessions/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: () => refresh(null),
});
}
export function useBrowserControl(siteId: string) {
const refresh = useRefresh(siteId);
const acquire = useMutation({
mutationFn: (id: string) =>
apiData<BrowserControlResult>("/browser/sessions/{id}/control/acquire", {
method: "POST",
pathParams: { id },
body: { reason: "原站视图接管" },
}),
onSuccess: (r) => refresh(r.session),
});
const release = useMutation({
mutationFn: (id: string) =>
apiData<BrowserControlResult>("/browser/sessions/{id}/control/release", {
method: "POST",
pathParams: { id },
body: { reason: "原站视图交还" },
}),
onSuccess: (r) => refresh(r.session),
});
return { acquire, release };
}
export type BrowserInput =
| { kind: "click"; target: BrowserTarget }
| { kind: "text"; text: string; submit?: boolean }
| { kind: "credential"; credential: "username" | "password" | "totp" }
| { kind: "key"; key: string }
| { kind: "scroll"; direction: "up" | "down" }
| { kind: "navigate"; url: string }
| { kind: "history"; action: "back" | "forward" | "reload" };
/** 人工输入:点击 / 文本 / 凭证 / 按键 / 滚动 / 导航;成功后立即刷新截图 */
export function useBrowserInput(siteId: string, sessionId: string | null) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: async (input: BrowserInput) => {
const id = sessionId ?? "";
const post = <T>(path: string, body: unknown) =>
apiData<T>(`/browser/sessions/{id}/${path}`, { method: "POST", pathParams: { id }, body });
switch (input.kind) {
case "click":
return (await post<BrowserActionResult>("click", { target: input.target })).session;
case "text":
return (await post<BrowserActionResult>("type", { text: input.text, submit: input.submit })).session;
case "credential":
return (await post<BrowserActionResult>("type", { credential: input.credential })).session;
case "key":
return (await post<BrowserActionResult>("type", { key: input.key })).session;
case "scroll":
return (await post<BrowserActionResult>("scroll", { direction: input.direction, amount: 600 })).session;
case "navigate":
return (await post<BrowserNavigation>("navigate", { url: input.url })).session;
case "history":
return (await post<BrowserNavigation>("navigate", { action: input.action })).session;
}
},
onSuccess: (s) => refresh(s),
});
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { holderName, toViewport, waitingText } from "./coords.ts";
describe("原站视图坐标换算", () => {
const natural = { width: 1280, height: 800 };
it("缩放显示时按比例换算到视口像素", () => {
// 手机上 320px 宽显示 1280 宽的截图:放大 4 倍
expect(toViewport({ x: 110, y: 70 }, { left: 10, top: 20, width: 320, height: 200 }, natural)).toEqual({
x: 400,
y: 200,
});
// 原尺寸显示
expect(toViewport({ x: 640, y: 400 }, { left: 0, top: 0, width: 1280, height: 800 }, natural)).toEqual({
x: 640,
y: 400,
});
});
it("画面外或尺寸未知返回 null;右下边缘夹在画面内", () => {
expect(toViewport({ x: 5, y: 5 }, { left: 10, top: 10, width: 100, height: 100 }, natural)).toBeNull();
expect(toViewport({ x: 5, y: 5 }, { left: 0, top: 0, width: 0, height: 0 }, natural)).toBeNull();
expect(toViewport({ x: 100, y: 100 }, { left: 0, top: 0, width: 100, height: 100 }, natural)).toEqual({
x: 1279,
y: 799,
});
});
it("等待原因与持有者显示", () => {
expect(waitingText("captcha")).toBe("登录页需要验证码");
expect(waitingText(null)).toBeNull();
expect(holderName("admin_session:alice")).toBe("alice");
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { BrowserController } from "@umt/contracts";
/** 截图上的点击位置 → 浏览器视口像素坐标(按图片实际像素与显示尺寸的比例换算,四舍五入并夹在画面内) */
export function toViewport(
client: { x: number; y: number },
rect: { left: number; top: number; width: number; height: number },
natural: { width: number; height: number },
): { x: number; y: number } | null {
if (rect.width <= 0 || rect.height <= 0 || natural.width <= 0 || natural.height <= 0) return null;
const rx = (client.x - rect.left) / rect.width;
const ry = (client.y - rect.top) / rect.height;
if (rx < 0 || ry < 0 || rx > 1 || ry > 1) return null;
return {
x: Math.min(natural.width - 1, Math.round(rx * natural.width)),
y: Math.min(natural.height - 1, Math.round(ry * natural.height)),
};
}
export const CONTROLLER_LABEL: Record<BrowserController, string> = {
automation: "Agent",
human: "人工接管",
waiting: "等待处理",
closed: "已关闭",
};
const WAITING_TEXT: Record<string, string> = {
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
login_required: "登录已失效",
lease_expired: "接管超时未交还",
agent_request: "Agent 请求人工处理",
human_released_waiting: "交还时选择继续等待",
};
export function waitingText(reason: string | null): string | null {
if (!reason) return null;
return WAITING_TEXT[reason] ?? reason;
}
/** 租约持有者 "admin_session:alice" → "alice" */
export function holderName(holder: string): string {
const i = holder.indexOf(":");
return i >= 0 ? holder.slice(i + 1) : holder;
}
@@ -0,0 +1,329 @@
import type { SiteListItem } from "@umt/contracts";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CornerDownLeft,
Globe,
Hand,
KeyRound,
LoaderCircle,
Pause,
Play,
Power,
RotateCw,
Send,
Undo2,
UserRound,
} from "lucide-react";
import { type FormEvent, type MouseEvent, useEffect, useState } from "react";
import { toast } from "sonner";
import {
type BrowserInput,
useBrowserControl,
useBrowserInput,
useBrowserProviders,
useBrowserScreenshot,
useCloseBrowserSession,
useOpenBrowserSession,
useSiteBrowserSession,
} from "~/api/browser.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { CONTROLLER_LABEL, holderName, toViewport, waitingText } from "./coords.ts";
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
const CONTROLLER_TONE: Record<string, StatusTone> = {
automation: "neutral",
human: "success",
waiting: "warning",
closed: "neutral",
};
export function SiteOriginView({ site }: { site: SiteListItem }) {
const providers = useBrowserProviders();
const session = useSiteBrowserSession(site.id);
const s = session.data ?? null;
const [paused, setPaused] = useState(false);
const shot = useBrowserScreenshot(s && s.controller !== "closed" ? s.id : null, { paused });
const open = useOpenBrowserSession(site.id);
const close = useCloseBrowserSession(site.id);
const control = useBrowserControl(site.id);
const input = useBrowserInput(site.id, s?.id ?? null);
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [editingUrl, setEditingUrl] = useState(false);
const currentUrl = s?.currentUrl ?? "";
useEffect(() => {
if (!editingUrl) setUrl(currentUrl);
}, [currentUrl, editingUrl]);
const provider = providers.data?.items.find((p) => p.enabled && p.kind !== "none");
const human = s?.controller === "human";
const busy = input.isPending || control.acquire.isPending || control.release.isPending;
const send = (i: BrowserInput) =>
input.mutate(i, { onError: (e) => toast.error(errorMessage(e, "操作没有送达浏览器")) });
if (providers.isPending || session.isPending) return <LoadingState rows={4} />;
if (providers.error) return <ErrorState error={providers.error} onRetry={() => void providers.refetch()} />;
if (!provider)
return (
<EmptyState
icon={Globe}
title="没有启用的浏览器服务"
description="登记 camofox 地址与访问凭证后,这里显示站点的真实页面。"
/>
);
if (!site.enabled) return <EmptyState icon={Globe} title="站点已停用" description="启用站点后才能打开原站页面。" />;
if (!s)
return (
<EmptyState
icon={Globe}
title="还没有打开这个站点的浏览器会话"
description="打开后显示站点首页;验证码等需本人操作在此完成。"
action={
<Button
size="sm"
disabled={open.isPending}
onClick={() => open.mutate(undefined, { onError: (e) => toast.error(errorMessage(e, "打开失败")) })}
>
{open.isPending ? <LoaderCircle className="animate-spin" /> : <Globe />}
打开会话
</Button>
}
/>
);
const caps = provider.capabilities;
const unverified = !caps?.takeover.verified || !caps?.screenshot.verified;
const conn: { tone: StatusTone; text: string } = paused
? { tone: "neutral", text: "已暂停刷新" }
: shot.isError
? { tone: "destructive", text: "连接中断" }
: shot.data
? { tone: "success", text: "已连接" }
: { tone: "neutral", text: "连接中" };
const waiting = waitingText(s.waitingReason);
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
const onScreenClick = (e: MouseEvent<HTMLImageElement>) => {
if (!human || !shot.data || busy) return;
const img = e.currentTarget;
const pt = toViewport({ x: e.clientX, y: e.clientY }, img.getBoundingClientRect(), {
width: shot.data.width ?? img.naturalWidth,
height: shot.data.height ?? img.naturalHeight,
});
if (pt) send({ kind: "click", target: pt });
};
const onUrlSubmit = (e: FormEvent) => {
e.preventDefault();
setEditingUrl(false);
if (url.trim() && url !== currentUrl) send({ kind: "navigate", url: url.trim() });
};
const onTextSubmit = (e: FormEvent) => {
e.preventDefault();
if (!text) return;
send({ kind: "text", text });
setText("");
};
return (
<div className="space-y-3">
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
当前控制:{CONTROLLER_LABEL[s.controller]}
{human && leaseHolder ? `(${leaseHolder})` : ""}
</StatusBadge>
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
<div className="ml-auto flex flex-wrap items-center gap-2">
{human ? (
<Button
size="sm"
disabled={busy}
onClick={() =>
control.release.mutate(s.id, {
onSuccess: () => toast.success("已交还,Agent 会重新读取页面"),
onError: (e) => toast.error(errorMessage(e)),
})
}
>
<Undo2 />
交还
</Button>
) : (
<Button
size="sm"
disabled={busy || s.controller === "closed"}
onClick={() => control.acquire.mutate(s.id, { onError: (e) => toast.error(errorMessage(e)) })}
>
<Hand />
接管
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setPaused((p) => !p)}>
{paused ? <Play /> : <Pause />}
{paused ? "继续刷新" : "暂停刷新"}
</Button>
<Button
variant="ghost"
size="sm"
disabled={close.isPending}
onClick={() => close.mutate(s.id, { onError: (e) => toast.error(errorMessage(e)) })}
>
<Power />
<span className="sr-only sm:not-sr-only">关闭会话</span>
</Button>
</div>
</div>
<form onSubmit={onUrlSubmit} className="flex min-w-0 items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="后退"
disabled={!human || busy}
onClick={() => send({ kind: "history", action: "back" })}
>
<ArrowLeft />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="前进"
disabled={!human || busy}
onClick={() => send({ kind: "history", action: "forward" })}
>
<ArrowRight />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="刷新"
disabled={!human || busy}
onClick={() => send({ kind: "history", action: "reload" })}
>
<RotateCw />
</Button>
<Input
aria-label="地址"
value={url}
readOnly={!human}
onFocus={() => setEditingUrl(true)}
onBlur={() => setEditingUrl(false)}
onChange={(e) => setUrl(e.target.value)}
className="h-8 min-w-0 flex-1 font-mono text-meta"
/>
<Button type="submit" variant="outline" size="sm" disabled={!human || busy}>
前往
</Button>
</form>
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
{shot.data ? (
// biome-ignore lint/a11y/useKeyWithClickEvents: 画面点击映射到浏览器;键盘输入走下方输入栏
<img
src={`data:${shot.data.mimeType};base64,${shot.data.dataBase64}`}
alt="站点页面画面"
draggable={false}
onClick={onScreenClick}
className={human ? "block h-auto w-full cursor-crosshair select-none" : "block h-auto w-full select-none"}
/>
) : shot.isError ? (
<ErrorState plain title="读取画面失败" error={shot.error} onRetry={() => void shot.refetch()} />
) : (
<LoadingState rows={6} className="p-4" />
)}
{shot.data && !human ? (
<p className="absolute right-2 bottom-2 rounded-md bg-background/85 px-2 py-1 text-meta text-muted-foreground">
接管后可点击和输入
</p>
) : null}
</div>
{human ? (
<div className="space-y-2">
<form onSubmit={onTextSubmit} className="flex min-w-0 items-center gap-1.5">
<Input
aria-label="输入到页面"
placeholder="先点页面上的输入框,再在这里输入"
value={text}
onChange={(e) => setText(e.target.value)}
className="h-8 min-w-0 flex-1"
autoComplete="off"
/>
<Button type="submit" size="sm" disabled={!text || busy}>
<Send />
发送
</Button>
</form>
<div className="flex flex-wrap items-center gap-1.5">
<Button variant="outline" size="xs" disabled={busy} onClick={() => send({ kind: "key", key: "Enter" })}>
<CornerDownLeft />
回车
</Button>
<Button variant="outline" size="xs" disabled={busy} onClick={() => send({ kind: "key", key: "Tab" })}>
Tab
</Button>
<Button variant="outline" size="xs" disabled={busy} onClick={() => send({ kind: "key", key: "Backspace" })}>
退格
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => send({ kind: "scroll", direction: "up" })}
>
<ArrowUp />
上滚
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => send({ kind: "scroll", direction: "down" })}
>
<ArrowDown />
下滚
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
title="用站点账号的用户名填当前输入框"
onClick={() => send({ kind: "credential", credential: "username" })}
>
<UserRound />
填用户名
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
title="用站点账号的密码填当前输入框;密码不经过这个页面"
onClick={() => send({ kind: "credential", credential: "password" })}
>
<KeyRound />
填密码
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -19,6 +19,7 @@ import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginView } from "~/components/browser/site-origin-view.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
@@ -97,9 +98,9 @@ function useDebounced<T>(value: T, ms = 400) {
return v;
}
const TABS = ["overview", "resources", "snapshots", "accounts"] as const;
/** 旧页签(整理视图 / 原站视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources", origin: "resources" };
const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const;
/** 旧页签(整理视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources" };
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
@@ -274,6 +275,7 @@ export default function SiteWorkbenchPage() {
<TabsList aria-label="站点分节">
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
@@ -419,6 +421,8 @@ export default function SiteWorkbenchPage() {
</div>
</TabsContent>
<TabsContent value="origin">{s ? <SiteOriginView site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}