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:
1 parent
adffe36846
commit
8574d34144
5 files changed
+555
-3
No files matched your search
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user