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),
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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} />}
|
||||
|
||||
Reference in new issue
Block a user