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("/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("/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("/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("/browser/sessions/{id}/control/acquire", { method: "POST", pathParams: { id }, body: { reason: "原站视图接管" }, }), onSuccess: (r) => refresh(r.session), }); const release = useMutation({ mutationFn: (id: string) => apiData("/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 = (path: string, body: unknown) => apiData(`/browser/sessions/{id}/${path}`, { method: "POST", pathParams: { id }, body }); switch (input.kind) { case "click": return (await post("click", { target: input.target })).session; case "text": return (await post("type", { text: input.text, submit: input.submit })).session; case "credential": return (await post("type", { credential: input.credential })).session; case "key": return (await post("type", { key: input.key })).session; case "scroll": return (await post("scroll", { direction: input.direction, amount: 600 })).session; case "navigate": return (await post("navigate", { url: input.url })).session; case "history": return (await post("navigate", { action: input.action })).session; } }, onSuccess: (s) => refresh(s), }); }