remove browser version management
This commit is contained in:
+33
-9
@@ -35,7 +35,6 @@ const menu = [
|
||||
items: [
|
||||
{ to: '/browsers', icon: 'ri-hard-drive-3-line', label: '运行环境' },
|
||||
{ to: '/network-exits', icon: 'ri-earth-line', label: '网络出口' },
|
||||
{ to: '/browser-versions', icon: 'ri-window-line', label: '浏览器版本' },
|
||||
{ to: '/gateways', icon: 'ri-router-line', label: '网关管理' },
|
||||
],
|
||||
},
|
||||
@@ -51,7 +50,7 @@ function MenuLink({ item, pathname }) {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActivePath(pathname, item.to)} tooltip={item.label}>
|
||||
<Link to={item.to} aria-label={item.label} onClick={() => { if (isMobile) setOpenMobile(false) }}>
|
||||
<i className={cn(item.icon, 'text-base')} aria-hidden="true" />
|
||||
<i className={cn(item.icon, 'size-4 shrink-0 text-base leading-none')} aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -102,7 +101,7 @@ function AppSidebar({ pathname }) {
|
||||
navigate({ to: '/login', replace: true })
|
||||
}}
|
||||
>
|
||||
<i className="ri-logout-box-r-line" aria-hidden="true" />
|
||||
<i className="ri-logout-box-r-line size-4 shrink-0" aria-hidden="true" />
|
||||
<span>退出登录</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -113,16 +112,41 @@ function AppSidebar({ pathname }) {
|
||||
)
|
||||
}
|
||||
|
||||
function pageTitle(pathname) {
|
||||
const pageMetadataRules = [
|
||||
{ test: (path) => path === '/accounts', title: '账号管理', subtitle: '统一管理自有账号与监测账号,按账号类型筛选。' },
|
||||
{ test: (path) => path === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
|
||||
{ test: (path) => path.endsWith('/edit') && path.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
|
||||
{ test: (path) => path.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
|
||||
{ test: (path) => path === '/creator/competitors', title: '竞品分析', subtitle: '粘贴作品分享内容,自动识别作者并加入监听队列。' },
|
||||
{ test: (path) => path === '/creator/workbench', title: '运营工作台', subtitle: '评论、线索、私信与写操作均保留来源和明确结果。' },
|
||||
{ test: (path) => path === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
|
||||
{ test: (path) => path === '/competitors' || path.startsWith('/competitors/'), title: '竞品分析', subtitle: '粘贴作品分享内容,自动识别作者并加入监听队列。' },
|
||||
{ test: (path) => path.startsWith('/drafts/'), title: '草稿核对', subtitle: '核对版本化草稿,确认后再将操作加入队列。' },
|
||||
{ test: (path) => path.startsWith('/attempts/'), title: 'Attempt 详情', subtitle: '查看执行结果、运行环境与网络出口。' },
|
||||
{ test: (path) => path === '/tasks', title: '任务中心', subtitle: '逐条处理暂停、版本变化与未知执行结果。' },
|
||||
{ test: (path) => path.startsWith('/tasks/'), title: '任务详情', subtitle: '查看任务状态、执行结果与允许的下一步操作。' },
|
||||
{ test: (path) => path === '/browsers', title: '运行环境', subtitle: '启动、停止并回收隔离的指纹浏览器运行环境。' },
|
||||
{ test: (path) => path === '/browsers/new', title: '创建运行环境', subtitle: '为已授权且暂停的账号绑定网关、指纹与网络出口。' },
|
||||
{ test: (path) => path.startsWith('/browsers/'), title: '运行环境详情', subtitle: '查看环境状态、账号绑定和运行实例信息。' },
|
||||
{ test: (path) => path === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
|
||||
{ test: (path) => path.startsWith('/network-exits/'), title: '网络出口详情', subtitle: '查看出口配置、健康状态和绑定关系。' },
|
||||
{ test: (path) => path === '/gateways', title: '网关管理', subtitle: '注册并检查宿主机 native browser gateway。' },
|
||||
{ test: (path) => path.startsWith('/gateways/'), title: '网关详情', subtitle: '查看网关连接信息和运行状态。' },
|
||||
]
|
||||
|
||||
function pageMetadata(pathname) {
|
||||
const page = pageMetadataRules.find((item) => item.test(pathname))
|
||||
if (page) return { title: page.title, subtitle: page.subtitle }
|
||||
for (const group of menu) {
|
||||
const match = group.items.find((item) => isActivePath(pathname, item.to))
|
||||
if (match) return match.label
|
||||
if (match) return { title: match.label, subtitle: 'CreatorHub 运营控制台' }
|
||||
}
|
||||
return 'CreatorHub'
|
||||
return { title: 'CreatorHub', subtitle: '运营控制台' }
|
||||
}
|
||||
|
||||
export function CreatorHubLayout({ children }) {
|
||||
const { pathname } = useLocation()
|
||||
const metadata = pageMetadata(pathname)
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar pathname={pathname} />
|
||||
@@ -134,11 +158,11 @@ export function CreatorHubLayout({ children }) {
|
||||
<SidebarTrigger className="-ml-1" aria-label="打开或收起导航" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{pageTitle(pathname)}</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">CreatorHub 运营控制台</p>
|
||||
<h1 className="truncate text-sm font-semibold text-foreground">{metadata.title}</h1>
|
||||
<p className="hidden truncate text-xs text-muted-foreground sm:block">{metadata.subtitle}</p>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-w-0 flex-1 px-4 py-6 sm:px-6 lg:px-8">{children ?? <Outlet />}</main>
|
||||
<main className="w-full min-w-0 flex-1 px-4 py-6 sm:px-6 lg:px-8">{children ?? <Outlet />}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
||||
@@ -38,5 +38,36 @@ describe("CreatorHubLayout", () => {
|
||||
).toBe("/accounts");
|
||||
expect(screen.queryByRole("link", { name: "竞品账号" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "账号策略" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "浏览器版本" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "账号管理" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("统一管理自有账号与监测账号,按账号类型筛选。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "账号管理" }).querySelector("i"))
|
||||
.not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/drafts/draft-a", "草稿核对", "核对版本化草稿,确认后再将操作加入队列。"],
|
||||
["/attempts/attempt-a", "Attempt 详情", "查看执行结果、运行环境与网络出口。"],
|
||||
["/competitors", "竞品分析", "粘贴作品分享内容,自动识别作者并加入监听队列。"],
|
||||
["/competitors/work-a", "竞品分析", "粘贴作品分享内容,自动识别作者并加入监听队列。"],
|
||||
])("shows route metadata for %s", (pathname, title, subtitle) => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
<Routes>
|
||||
<Route element={<CreatorHubLayout />}>
|
||||
<Route path="*" element={<div>页面内容</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: title })).toBeTruthy();
|
||||
expect(screen.getByText(subtitle)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
'relative grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_minmax(0,1fr)_auto] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
className={cn('col-start-1 min-w-0 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -49,7 +49,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>)
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
'col-start-1 min-w-0 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -232,7 +232,7 @@ export function AccountEditPage() {
|
||||
variant: "destructive",
|
||||
text: conflictMessage(
|
||||
qrError,
|
||||
"登录二维码获取失败;请检查运行环境、网关和浏览器版本配置",
|
||||
"登录二维码获取失败;请检查运行环境和网关状态",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,14 +127,13 @@ describe("AccountList", () => {
|
||||
const dataProvider = provider({
|
||||
create: vi.fn().mockRejectedValue(
|
||||
httpError("conflict", 409, {
|
||||
error: "resource conflicts with existing state",
|
||||
reason_code: "duplicate_platform_account",
|
||||
}),
|
||||
),
|
||||
});
|
||||
renderCreate(dataProvider);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "创建社媒账号" }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: "创建账号" })).toBeTruthy();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "账号名称" }), {
|
||||
target: { value: "新店铺" },
|
||||
});
|
||||
@@ -164,7 +163,7 @@ describe("AccountList", () => {
|
||||
}),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"冲突(409)",
|
||||
"冲突(409):该平台的账号 ID 已存在;表单内容已保留。",
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "账号 ID" }).value).toBe(
|
||||
"shop-new",
|
||||
@@ -175,7 +174,7 @@ describe("AccountList", () => {
|
||||
it("allows creating without cookies and leaves the modal path unused", async () => {
|
||||
const dataProvider = provider();
|
||||
renderCreate(dataProvider);
|
||||
await screen.findByRole("heading", { name: "创建社媒账号" });
|
||||
await screen.findByRole("button", { name: "创建账号" });
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "账号名称" }), {
|
||||
target: { value: "扫码账号" },
|
||||
@@ -200,7 +199,7 @@ describe("AccountList", () => {
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "创建社媒账号" })).toBeNull(),
|
||||
expect(screen.queryByRole("button", { name: "创建账号" })).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("creator pages", () => {
|
||||
it("loads and saves collection settings", async () => {
|
||||
const dataProvider = provider();
|
||||
renderPage(<CreatorSettingsPage />, dataProvider);
|
||||
expect(await screen.findByText("采集设置")).toBeTruthy();
|
||||
await screen.findByLabelText("竞品回溯天数");
|
||||
fireEvent.change(screen.getByLabelText("竞品回溯天数"), {
|
||||
target: { value: "14" },
|
||||
});
|
||||
@@ -273,7 +273,7 @@ describe("creator pages", () => {
|
||||
}),
|
||||
});
|
||||
renderPage(<CreatorSettingsPage />, dataProvider);
|
||||
expect(await screen.findByText("采集设置")).toBeTruthy();
|
||||
await screen.findByRole("button", { name: "保存设置" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
|
||||
expect(await screen.findByText("冲突(409):设置保存失败")).toBeTruthy();
|
||||
});
|
||||
@@ -283,7 +283,7 @@ describe("creator pages", () => {
|
||||
creatorUpdate: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
renderPage(<CreatorSettingsPage />, dataProvider);
|
||||
expect(await screen.findByText("采集设置")).toBeTruthy();
|
||||
await screen.findByRole("button", { name: "保存设置" });
|
||||
fireEvent.click(screen.getByLabelText("已完成 AI 配置审批"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
|
||||
expect(await screen.findByText("采集与 AI 配置已保存。")).toBeTruthy();
|
||||
@@ -387,7 +387,7 @@ describe("creator pages", () => {
|
||||
),
|
||||
});
|
||||
renderPage(<AccountList />, dataProvider);
|
||||
expect(await screen.findByRole("heading", { name: "账号管理" })).toBeTruthy();
|
||||
await screen.findByText("自有账号");
|
||||
expect(await screen.findByText("自有账号")).toBeTruthy();
|
||||
expect(screen.getByText("监测作者")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "账号类型" }));
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useDataProvider, useList } from "../../shared/hooks/dataHooks.js";
|
||||
import { Alert } from "../../components/ui/alert";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
|
||||
import { ConfirmDialog, Field, PageHeader, PageState } from "../../shared/ui/ui.jsx";
|
||||
import { useTitle } from "../../shared/hooks/hooks.js";
|
||||
|
||||
const versionPattern = /^\d[A-Za-z0-9._-]{0,63}$/;
|
||||
const pathPattern = /^\/[A-Za-z0-9._+~/-]{1,4095}$/;
|
||||
|
||||
function BrowserVersionCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
const [form, setForm] = useState({ version: "", browser_path: "", note: "" });
|
||||
const update = (key, value) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
const valid =
|
||||
versionPattern.test(form.version) && pathPattern.test(form.browser_path);
|
||||
const versionInvalid =
|
||||
form.version !== "" && !versionPattern.test(form.version);
|
||||
const pathInvalid =
|
||||
form.browser_path !== "" && !pathPattern.test(form.browser_path);
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!valid) return;
|
||||
const created = await onSubmit({
|
||||
version: form.version,
|
||||
browser_path: form.browser_path,
|
||||
note: form.note,
|
||||
enabled: true,
|
||||
});
|
||||
if (created) {
|
||||
setForm({ version: "", browser_path: "", note: "" });
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>添加浏览器版本</DialogTitle></DialogHeader>
|
||||
<form id="browser-version-create-form" onSubmit={submit} noValidate>
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
{error.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
id="browser-version"
|
||||
label="版本"
|
||||
required
|
||||
error={versionInvalid ? "数字开头,最长 64 字符" : undefined}
|
||||
helper="浏览器版本号,如 148.0.7778.215"
|
||||
>
|
||||
<Input
|
||||
id="browser-version"
|
||||
required
|
||||
maxLength={64}
|
||||
value={form.version}
|
||||
onChange={(event) => update("version", event.target.value)}
|
||||
aria-invalid={versionInvalid || undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="browser-path"
|
||||
label="浏览器路径"
|
||||
required
|
||||
error={pathInvalid ? "格式不正确" : undefined}
|
||||
helper="gateway 节点上的预安装浏览器绝对路径"
|
||||
>
|
||||
<Input
|
||||
id="browser-path"
|
||||
required
|
||||
maxLength={4096}
|
||||
value={form.browser_path}
|
||||
onChange={(event) => update("browser_path", event.target.value)}
|
||||
placeholder="gateway 节点上的预安装浏览器绝对路径"
|
||||
aria-invalid={pathInvalid || undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="browser-version-note"
|
||||
label="备注"
|
||||
helper="可选,最长 200 字符"
|
||||
>
|
||||
<Input
|
||||
id="browser-version-note"
|
||||
maxLength={200}
|
||||
value={form.note}
|
||||
onChange={(event) => update("note", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={busy}>取消</Button>
|
||||
<Button variant="default" type="submit" form="browser-version-create-form" disabled={!valid || busy}>
|
||||
{busy ? "保存中…" : "添加版本"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserVersionEditForm({ editing, onChange, onSave, busy }) {
|
||||
const pathInvalid =
|
||||
editing.browser_path !== "" && !pathPattern.test(editing.browser_path);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
id="edit-browser-path"
|
||||
label="浏览器路径"
|
||||
required
|
||||
error={pathInvalid ? "格式不正确" : undefined}
|
||||
helper="gateway 节点上的预安装浏览器绝对路径"
|
||||
>
|
||||
<Input
|
||||
id="edit-browser-path"
|
||||
value={editing.browser_path}
|
||||
aria-invalid={pathInvalid || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...editing, browser_path: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="edit-browser-version-note"
|
||||
label="备注"
|
||||
helper="可选,最长 200 字符"
|
||||
>
|
||||
<Input
|
||||
id="edit-browser-version-note"
|
||||
maxLength={200}
|
||||
value={editing.note}
|
||||
onChange={(event) =>
|
||||
onChange({ ...editing, note: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">启用(可用于创建与升级)</p>
|
||||
<Switch
|
||||
id="edit-browser-version-enabled"
|
||||
checked={editing.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...editing, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={pathInvalid || busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserVersionList() {
|
||||
const dataProvider = useDataProvider()("default");
|
||||
const { result, query } = useList({ resource: "browser-versions" });
|
||||
const error = query.error;
|
||||
const isPending = query.isPending;
|
||||
const versions = result.data ?? [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [busyAlias, setBusyAlias] = useState("");
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
useTitle("CreatorHub · 浏览器版本");
|
||||
|
||||
async function createBrowserVersion(data) {
|
||||
setBusyAlias("create");
|
||||
setCreateError(null);
|
||||
try {
|
||||
await dataProvider.create({
|
||||
resource: "browser-versions",
|
||||
variables: data,
|
||||
});
|
||||
await query.refetch();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setCreateError(reason);
|
||||
return false;
|
||||
} finally {
|
||||
setBusyAlias("");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBrowserVersion(version) {
|
||||
setBusyAlias(version.version);
|
||||
setNotice(null);
|
||||
try {
|
||||
await dataProvider.update({
|
||||
resource: "browser-versions",
|
||||
id: version.version,
|
||||
variables: {
|
||||
browser_path: version.browser_path,
|
||||
note: version.note,
|
||||
enabled: !version.enabled,
|
||||
},
|
||||
});
|
||||
await query.refetch();
|
||||
} catch (reason) {
|
||||
setNotice({ variant: "destructive", text: reason.message });
|
||||
} finally {
|
||||
setBusyAlias("");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBrowserVersion(version) {
|
||||
setBusyAlias(version.version);
|
||||
setNotice(null);
|
||||
try {
|
||||
await dataProvider.deleteOne({
|
||||
resource: "browser-versions",
|
||||
id: version.version,
|
||||
});
|
||||
await query.refetch();
|
||||
setDeleting(null);
|
||||
} catch (reason) {
|
||||
setNotice({ variant: "destructive", text: reason.message });
|
||||
} finally {
|
||||
setBusyAlias("");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!pathPattern.test(editing.browser_path)) {
|
||||
setEditing({ ...editing, error: "浏览器路径格式不正确" });
|
||||
return;
|
||||
}
|
||||
setBusyAlias(editing.version);
|
||||
setNotice(null);
|
||||
try {
|
||||
await dataProvider.update({
|
||||
resource: "browser-versions",
|
||||
id: editing.version,
|
||||
variables: {
|
||||
browser_path: editing.browser_path,
|
||||
note: editing.note,
|
||||
enabled: editing.enabled,
|
||||
},
|
||||
});
|
||||
setEditing(null);
|
||||
await query.refetch();
|
||||
} catch (reason) {
|
||||
setEditing({ ...editing, error: reason.message });
|
||||
} finally {
|
||||
setBusyAlias("");
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
notice?.text ||
|
||||
(error ? null : notice?.message) ||
|
||||
(notice ? notice.message : null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon="ri-window-line"
|
||||
title="浏览器版本"
|
||||
description="维护可用浏览器版本;创建与升级环境时从中选择"
|
||||
>
|
||||
<Button variant="default" onClick={() => setCreateOpen(true)}>
|
||||
<i className="ri-add-line" aria-hidden="true" />
|
||||
添加版本
|
||||
</Button>
|
||||
</PageHeader>
|
||||
{message ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
{message}
|
||||
</Alert>
|
||||
) : null}
|
||||
<PageState
|
||||
pending={isPending}
|
||||
error={error}
|
||||
empty={versions.length === 0}
|
||||
emptyText="暂无浏览器版本;请先登记 gateway 节点上的预安装浏览器路径。"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>浏览器路径</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versions.map((version) => (
|
||||
<TableRow key={version.version}>
|
||||
<TableCell><span className="font-semibold">{version.version}</span></TableCell>
|
||||
<TableCell><code className="anywhere font-mono text-[13px]">{version.browser_path}</code></TableCell>
|
||||
<TableCell><span className={version.note ? "" : "text-muted-foreground"}>{version.note || "—"}</span></TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
id={`browser-version-enabled-${version.version}`}
|
||||
aria-label={`启用 ${version.version}`}
|
||||
checked={version.enabled}
|
||||
disabled={busyAlias === version.version}
|
||||
onCheckedChange={() => toggleBrowserVersion(version)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${version.version}`} disabled={busyAlias === version.version} onClick={() => setEditing({ ...version, error: "" })}>
|
||||
<i className="ri-edit-line" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="icon-sm" aria-label={`删除 ${version.version}`} disabled={busyAlias === version.version} onClick={() => setDeleting(version)}>
|
||||
<i className="ri-delete-bin-line" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PageState>
|
||||
<BrowserVersionCreateModal
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
onSubmit={createBrowserVersion}
|
||||
busy={busyAlias === "create"}
|
||||
error={createError}
|
||||
/>
|
||||
{editing === null ? null : (
|
||||
<Dialog open onOpenChange={(nextOpen) => { if (!nextOpen) setEditing(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{`编辑 ${editing.version}`}</DialogTitle></DialogHeader>
|
||||
{editing.error ? <Alert variant="destructive" className="mb-4">{editing.error}</Alert> : null}
|
||||
<BrowserVersionEditForm
|
||||
editing={editing}
|
||||
onChange={(next) => setEditing({ ...next, error: editing.error })}
|
||||
onSave={saveEdit}
|
||||
busy={busyAlias === editing.version}
|
||||
/>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setEditing(null)}>关闭</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => removeBrowserVersion(deleting)}
|
||||
title="删除浏览器版本"
|
||||
confirmLabel="确认删除"
|
||||
busy={busyAlias === deleting?.version}
|
||||
body={`删除浏览器版本 ${deleting?.version}?仍被环境引用时删除会被拒绝。`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TestDataProvider } from "../../test/providerCompat.jsx";
|
||||
import { MemoryRouter } from "../../test/routerCompat.jsx";
|
||||
import { BrowserVersionList } from "./BrowserVersionsPage";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const versions = [
|
||||
{
|
||||
id: "148.0.0.1",
|
||||
version: "148.0.0.1",
|
||||
browser_path: "/opt/creatorhub/browsers/148/chrome",
|
||||
note: "",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "144.0.0.1",
|
||||
version: "144.0.0.1",
|
||||
browser_path: "/opt/creatorhub/browsers/144/chrome",
|
||||
note: "旧版",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
getList: vi.fn(() =>
|
||||
Promise.resolve({ data: versions, total: versions.length }),
|
||||
),
|
||||
create: vi.fn().mockResolvedValue({ data: versions[0] }),
|
||||
update: vi.fn().mockResolvedValue({ data: versions[0] }),
|
||||
deleteOne: vi.fn().mockResolvedValue({ data: { id: "144.0.0.1" } }),
|
||||
getOne: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
getManyReference: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
browserAction: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const renderVersions = (dataProvider) =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
}
|
||||
>
|
||||
<TestDataProvider dataProvider={dataProvider}>
|
||||
<MemoryRouter>
|
||||
<BrowserVersionList />
|
||||
</MemoryRouter>
|
||||
</TestDataProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("BrowserVersionList", () => {
|
||||
it("adds a browser version", async () => {
|
||||
const dataProvider = provider();
|
||||
renderVersions(dataProvider);
|
||||
|
||||
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
|
||||
target: { value: "150.0.0.1" },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("textbox", { name: "浏览器路径" }),
|
||||
{ target: { value: "/opt/creatorhub/browsers/150/chrome" } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.create).toHaveBeenCalledWith({
|
||||
resource: "browser-versions",
|
||||
variables: {
|
||||
version: "150.0.0.1",
|
||||
browser_path: "/opt/creatorhub/browsers/150/chrome",
|
||||
note: "",
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps the create modal open after a failure", async () => {
|
||||
const dataProvider = provider({
|
||||
create: vi.fn().mockRejectedValue(new Error("浏览器版本创建失败")),
|
||||
});
|
||||
renderVersions(dataProvider);
|
||||
|
||||
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
|
||||
target: { value: "150.0.0.1" },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("textbox", { name: "浏览器路径" }),
|
||||
{ target: { value: "/opt/creatorhub/browsers/150/chrome" } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
|
||||
|
||||
expect((await within(dialog).findByRole("alert")).textContent).toContain(
|
||||
"浏览器版本创建失败",
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles enabled through update", async () => {
|
||||
const dataProvider = provider();
|
||||
renderVersions(dataProvider);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("switch", { name: "启用 144.0.0.1" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.update).toHaveBeenCalledWith({
|
||||
resource: "browser-versions",
|
||||
id: "144.0.0.1",
|
||||
variables: {
|
||||
browser_path: "/opt/creatorhub/browsers/144/chrome",
|
||||
note: "旧版",
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks invalid input before submit", async () => {
|
||||
const dataProvider = provider();
|
||||
renderVersions(dataProvider);
|
||||
|
||||
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
|
||||
target: { value: "v1!" },
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "添加版本" }).disabled,
|
||||
).toBe(true);
|
||||
expect(dataProvider.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces delete rejection (referenced by env)", async () => {
|
||||
const dataProvider = provider({
|
||||
deleteOne: vi.fn().mockRejectedValue(new Error("该版本正被环境引用")),
|
||||
});
|
||||
renderVersions(dataProvider);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("删除 148.0.0.1"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"该版本正被环境引用",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { useDataProvider, useList, useOne } from "../../shared/hooks/dataHooks.j
|
||||
import { Alert } from "../../components/ui/alert";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
|
||||
@@ -55,7 +55,6 @@ const createInitial = {
|
||||
name: "",
|
||||
alias: "",
|
||||
gateway: "",
|
||||
browser_version: "",
|
||||
account_id: "",
|
||||
network_exit_id: "",
|
||||
seed: "",
|
||||
@@ -144,10 +143,6 @@ function RuntimeActions({ runtime, busy, onAction }) {
|
||||
<i className="ri-stop-line" aria-hidden="true" />
|
||||
停止
|
||||
</Button>
|
||||
<Button size="sm" aria-label={`升级 ${runtime.name}`} disabled={busy || gatewayUnavailable} onClick={() => onAction(runtime, "upgrade")}>
|
||||
<i className="ri-arrow-up-circle-line" aria-hidden="true" />
|
||||
升级
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" aria-label={`回收 ${runtime.name}`} disabled={busy || gatewayUnavailable} onClick={() => onAction(runtime, "recycle")}>
|
||||
<i className="ri-delete-bin-line" aria-hidden="true" />
|
||||
回收
|
||||
@@ -166,36 +161,6 @@ function SchedulePill({ runtime }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UpgradeDialog({ target, versions, busy, onClose, onConfirm }) {
|
||||
const [version, setVersion] = useState("");
|
||||
const enabled = versions.filter((item) => item.enabled);
|
||||
useEffect(() => {
|
||||
setVersion("");
|
||||
}, [target]);
|
||||
return (
|
||||
<Dialog open={target !== null} onOpenChange={(nextOpen) => { if (!nextOpen && !busy) onClose() }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{`升级 ${target ? `${target.name}(${target.alias})` : ""}`}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">环境将按目标浏览器版本重启,Profile 目录与指纹参数保持不变。</p>
|
||||
<Field id="upgrade-version" label="目标浏览器版本" required>
|
||||
<Select value={version} onValueChange={setVersion}>
|
||||
<SelectTrigger id="upgrade-version"><SelectValue placeholder="选择版本" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabled.map((item) => <SelectItem key={item.version} value={item.version}>{`${item.version}${item.version === target?.browser_version ? "(当前)" : ""}`}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>取消</Button>
|
||||
<Button variant="default" disabled={busy || version === "" || version === target?.browser_version} onClick={() => onConfirm(version)}>{busy ? "升级中…" : "确认升级"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
|
||||
return (
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border bg-card md:block">
|
||||
@@ -211,7 +176,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
|
||||
<TableCell><StatePill state={runtime.state} /></TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm">{runtime.account_id || "未绑定账号"}</p>
|
||||
<p className="anywhere text-xs text-muted-foreground">{runtime.network_exit_id || "当前机器直连"} · {runtime.browser_version}</p>
|
||||
<p className="anywhere text-xs text-muted-foreground">{runtime.network_exit_id || "当前机器直连"}</p>
|
||||
<div className="mt-1"><SchedulePill runtime={runtime} /></div>
|
||||
</TableCell>
|
||||
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <span className="text-sm text-muted-foreground">—</span>}</TableCell>
|
||||
@@ -233,8 +198,7 @@ function RuntimeCards({ runtimes, busy, onAction }) {
|
||||
<div>
|
||||
<p className="font-semibold">{runtime.name}</p>
|
||||
<p className="anywhere text-xs text-muted-foreground">
|
||||
{runtime.alias} · {runtime.browser_version} · seed{" "}
|
||||
{runtime.fingerprint?.seed}
|
||||
{runtime.alias} · seed {runtime.fingerprint?.seed}
|
||||
</p>
|
||||
</div>
|
||||
<StatePill state={runtime.state} />
|
||||
@@ -261,15 +225,10 @@ export function BrowserList() {
|
||||
const dataProvider = useDataProvider()("default");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [upgradeTarget, setUpgradeTarget] = useState(null);
|
||||
const { result, query } = useList({ resource: "browsers" });
|
||||
const error = query.error;
|
||||
const isPending = query.isPending;
|
||||
const runtimes = result.data ?? [];
|
||||
const { result: versionsResult } = useList({
|
||||
resource: "browser-versions",
|
||||
});
|
||||
const versions = versionsResult.data ?? [];
|
||||
useTitle("CreatorHub · 运行环境");
|
||||
|
||||
async function handleAction(runtime, action) {
|
||||
@@ -280,10 +239,6 @@ export function BrowserList() {
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (action === "upgrade") {
|
||||
setUpgradeTarget(runtime);
|
||||
return;
|
||||
}
|
||||
await runAction(runtime, action);
|
||||
}
|
||||
|
||||
@@ -300,17 +255,12 @@ export function BrowserList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function upgrade(version) {
|
||||
await runAction(upgradeTarget, "upgrade", { version });
|
||||
setUpgradeTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon="ri-server-line"
|
||||
title="运行环境"
|
||||
description="启动、停止、升级并回收隔离的指纹浏览器运行环境"
|
||||
description="启动、停止并回收隔离的指纹浏览器运行环境"
|
||||
>
|
||||
<Button variant="default" onClick={() => navigate("/browsers/new")}>
|
||||
<i className="ri-add-line" aria-hidden="true" />
|
||||
@@ -341,13 +291,6 @@ export function BrowserList() {
|
||||
/>
|
||||
<RuntimeCards runtimes={runtimes} busy={busy} onAction={handleAction} />
|
||||
</PageState>
|
||||
<UpgradeDialog
|
||||
target={upgradeTarget}
|
||||
versions={versions}
|
||||
busy={busy === upgradeTarget?.alias}
|
||||
onClose={() => setUpgradeTarget(null)}
|
||||
onConfirm={upgrade}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -365,15 +308,12 @@ export function BrowserCreatePage() {
|
||||
const { result: gatewayResult } = useList({ resource: "gateways" });
|
||||
const gateways = gatewayResult.data ?? [];
|
||||
const runtimes = runtimesResult.data ?? [];
|
||||
const { result: versionsResult } = useList({ resource: "browser-versions" });
|
||||
const versions = versionsResult.data ?? [];
|
||||
const { result: accountResult } = useList({ resource: "accounts" });
|
||||
const accounts = accountResult.data ?? [];
|
||||
const { result: exitResult } = useList({ resource: "network-exits" });
|
||||
const networkExits = exitResult.data ?? [];
|
||||
useTitle("CreatorHub · 创建运行环境");
|
||||
|
||||
const enabledVersions = versions.filter((item) => item.enabled);
|
||||
const pausedAccounts = accounts.filter(
|
||||
(account) =>
|
||||
account.authorization_status === "authorized" &&
|
||||
@@ -397,10 +337,6 @@ export function BrowserCreatePage() {
|
||||
if (form.gateway === "" && gateways.length > 0)
|
||||
update("gateway", gateways[0].name);
|
||||
}, [gateways, form.gateway]);
|
||||
useEffect(() => {
|
||||
if (form.browser_version === "" && enabledVersions.length > 0)
|
||||
update("browser_version", enabledVersions[0].version);
|
||||
}, [enabledVersions, form.browser_version]);
|
||||
useEffect(() => {
|
||||
if (form.account_id === "" && pausedAccounts[0]?.id)
|
||||
update("account_id", pausedAccounts[0].id);
|
||||
@@ -418,7 +354,6 @@ export function BrowserCreatePage() {
|
||||
form.name.trim() !== "" &&
|
||||
aliasPattern.test(form.alias) &&
|
||||
form.gateway !== "" &&
|
||||
form.browser_version !== "" &&
|
||||
form.account_id !== "" &&
|
||||
Number.isInteger(seed) &&
|
||||
seed >= 1 &&
|
||||
@@ -436,7 +371,6 @@ export function BrowserCreatePage() {
|
||||
alias: form.alias,
|
||||
name: form.name.trim(),
|
||||
gateway: form.gateway,
|
||||
browser_version: form.browser_version,
|
||||
account_id: form.account_id,
|
||||
network_exit_id: form.network_exit_id,
|
||||
fingerprint: fingerprintPayload(form),
|
||||
@@ -459,10 +393,10 @@ export function BrowserCreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[960px]">
|
||||
<div className="w-full min-w-0">
|
||||
<PageHeader
|
||||
title="创建运行环境"
|
||||
description="为已授权且暂停的账号绑定网关、浏览器版本、指纹与网络出口"
|
||||
description="为已授权且暂停的账号绑定网关、指纹与网络出口"
|
||||
>
|
||||
<Button onClick={() => navigate("/browsers")}>
|
||||
<i className="ri-arrow-left-line" aria-hidden="true" />
|
||||
@@ -528,22 +462,6 @@ export function BrowserCreatePage() {
|
||||
<SelectContent>{gateways.map((item) => <SelectItem key={item.name} value={item.name}>{item.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
id="env-version"
|
||||
label="浏览器版本"
|
||||
required
|
||||
error={
|
||||
enabledVersions.length === 0
|
||||
? "请先在「浏览器版本」添加并启用"
|
||||
: undefined
|
||||
}
|
||||
helper="环境使用的预安装浏览器版本"
|
||||
>
|
||||
<Select value={form.browser_version} onValueChange={(value) => update("browser_version", value)} disabled={enabledVersions.length === 0}>
|
||||
<SelectTrigger id="env-version"><SelectValue placeholder="选择版本" /></SelectTrigger>
|
||||
<SelectContent>{enabledVersions.map((item) => <SelectItem key={item.version} value={item.version}>{item.version}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
id="env-seed"
|
||||
label="Fingerprint Seed"
|
||||
@@ -591,7 +509,7 @@ export function BrowserCreatePage() {
|
||||
</Field>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="button" variant="link" className="h-auto gap-1.5 p-0 text-sm font-medium" onClick={() => setAdvancedOpen((current) => !current)}>
|
||||
<Button type="button" variant="link" className="h-auto gap-1.5 p-0 text-sm font-medium" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((current) => !current)}>
|
||||
高级指纹参数{advancedOpen ? "(收起)" : "(展开)"}
|
||||
<i className={advancedOpen ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"} aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -759,9 +677,9 @@ export function BrowserDetail() {
|
||||
<DetailList
|
||||
rows={[
|
||||
[
|
||||
"账号与浏览器版本",
|
||||
"账号与指纹",
|
||||
<span className="anywhere">
|
||||
{runtime.account_id} · {runtime.browser_version}
|
||||
{runtime.account_id} · seed {runtime.fingerprint?.seed}
|
||||
</span>,
|
||||
],
|
||||
[
|
||||
|
||||
@@ -27,7 +27,6 @@ const runtimes = [
|
||||
name: "店铺一号",
|
||||
state: "running",
|
||||
status: "Up",
|
||||
browser_version: "148.0.0.1",
|
||||
fingerprint: { seed: 1000 },
|
||||
endpoint: "http://account-a:9222",
|
||||
account_id: "social-a",
|
||||
@@ -41,7 +40,6 @@ const runtimes = [
|
||||
name: "店铺二号",
|
||||
state: "exited",
|
||||
status: "Exited",
|
||||
browser_version: "148.0.0.1",
|
||||
fingerprint: { seed: 2000 },
|
||||
endpoint: "http://account-b:9222",
|
||||
},
|
||||
@@ -49,26 +47,6 @@ const runtimes = [
|
||||
const gateways = [
|
||||
{ id: "gw-1", name: "gw-1", endpoint: "http://gw:8081", token: "t" },
|
||||
];
|
||||
const images = [
|
||||
{
|
||||
id: "148.0.0.1",
|
||||
version: "148.0.0.1",
|
||||
browser_path: "reg/img:148",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "144.0.0.1",
|
||||
version: "144.0.0.1",
|
||||
browser_path: "reg/img:144",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "139.0.0.1",
|
||||
version: "139.0.0.1",
|
||||
browser_path: "reg/img:139",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
const accounts = [
|
||||
{
|
||||
id: "social-a",
|
||||
@@ -94,8 +72,6 @@ function provider(overrides = {}) {
|
||||
return Promise.resolve({ data: runtimes, total: runtimes.length });
|
||||
if (resource === "gateways")
|
||||
return Promise.resolve({ data: gateways, total: gateways.length });
|
||||
if (resource === "browser-versions")
|
||||
return Promise.resolve({ data: images, total: images.length });
|
||||
if (resource === "accounts")
|
||||
return Promise.resolve({ data: accounts, total: accounts.length });
|
||||
if (resource === "network-exits")
|
||||
@@ -373,7 +349,6 @@ describe("BrowserList", () => {
|
||||
alias: "shop-03",
|
||||
name: "店铺三号",
|
||||
gateway: "gw-1",
|
||||
browser_version: "148.0.0.1",
|
||||
account_id: "social-a",
|
||||
network_exit_id: "exit-1",
|
||||
fingerprint: { seed: 1000 },
|
||||
@@ -438,28 +413,17 @@ describe("BrowserList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("upgrades an env through the version dialog", async () => {
|
||||
const dataProvider = provider();
|
||||
renderList(dataProvider);
|
||||
await screen.findAllByText("店铺一号");
|
||||
it("opens and closes advanced fingerprint controls without a render error", async () => {
|
||||
renderCreate(provider());
|
||||
await screen.findByRole("button", { name: "创建环境" });
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText("升级 店铺一号")[0]);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("combobox", { name: /目标浏览器版本/ }),
|
||||
);
|
||||
fireEvent.click(
|
||||
await within(document.body).findByRole("option", { name: "144.0.0.1" }),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "确认升级" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "高级指纹参数(展开)" }));
|
||||
expect(screen.getByRole("combobox", { name: "操作系统 platform" })).toBeTruthy();
|
||||
expect(screen.getByRole("textbox", { name: "系统版本 platform-version" })).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "禁用伪装 font" })).toBeTruthy();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.browserAction).toHaveBeenCalledWith(
|
||||
"account-a",
|
||||
"upgrade",
|
||||
{ version: "144.0.0.1" },
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "高级指纹参数(收起)" }));
|
||||
expect(screen.queryByRole("textbox", { name: "系统版本 platform-version" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows action errors and keeps a retry control", async () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ const featureDir = dirname(fileURLToPath(import.meta.url));
|
||||
const tableFiles = [
|
||||
"accounts/AccountsPage.jsx",
|
||||
"creator/competitors/CreatorCompetitorsPage.jsx",
|
||||
"environments/BrowserVersionsPage.jsx",
|
||||
"environments/BrowsersPage.jsx",
|
||||
"environments/GatewaysPage.jsx",
|
||||
"environments/NetworkExitsPage.jsx",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { BrowserVersionList } from '@/features/environments/BrowserVersionsPage'
|
||||
|
||||
export const Route = createFileRoute('/_app/browser-versions')({ component: BrowserVersionList })
|
||||
@@ -49,7 +49,6 @@ const unsupported = (resource, operation) =>
|
||||
|
||||
const resourcePaths = {
|
||||
browsers: "/browsers",
|
||||
"browser-versions": "/browser-versions",
|
||||
gateways: "/gateways",
|
||||
accounts: "/phase-a/accounts",
|
||||
drafts: "/phase-a/drafts",
|
||||
@@ -154,19 +153,17 @@ export const dataProvider = {
|
||||
const path = resourcePaths[resource];
|
||||
if (!path || resource === "browsers")
|
||||
return unsupported(resource, "update");
|
||||
// browser-versions 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
|
||||
const { version: _ignored, ...rest } = variables ?? {};
|
||||
await request(
|
||||
`${path}/${encodeURIComponent(id)}`,
|
||||
jsonOptions("PUT", rest),
|
||||
jsonOptions("PUT", variables),
|
||||
);
|
||||
return { data: { ...rest, id } };
|
||||
return { data: { ...variables, id } };
|
||||
},
|
||||
async deleteOne({ resource, id }) {
|
||||
const path = resourcePaths[resource];
|
||||
if (
|
||||
!path ||
|
||||
!["browser-versions", "accounts", "creator-competitors"].includes(resource)
|
||||
!["accounts", "creator-competitors"].includes(resource)
|
||||
)
|
||||
return unsupported(resource, "deleteOne");
|
||||
await request(`${path}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
@@ -177,14 +174,7 @@ export const dataProvider = {
|
||||
updateMany: (resource) => unsupported(resource, "updateMany"),
|
||||
deleteMany: (resource) => unsupported(resource, "deleteMany"),
|
||||
// 环境的领域动作保持显式动词,不伪装成 CRUD update。
|
||||
async browserAction(alias, action, data) {
|
||||
if (action === "upgrade") {
|
||||
await request(
|
||||
`/browsers/${encodeURIComponent(alias)}/upgrade`,
|
||||
jsonOptions("POST", data),
|
||||
);
|
||||
return;
|
||||
}
|
||||
async browserAction(alias, action) {
|
||||
const paths = {
|
||||
start: [`/browsers/${encodeURIComponent(alias)}/start`, "POST"],
|
||||
stop: [`/browsers/${encodeURIComponent(alias)}/stop`, "POST"],
|
||||
|
||||
@@ -174,24 +174,6 @@ describe("dataProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends upgrade with the version payload", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await dataProvider.browserAction("account-a", "upgrade", {
|
||||
version: "144.0.0.1",
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/browsers/account-a/upgrade",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: '{"version":"144.0.0.1"}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves API error messages and status codes", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -22,8 +22,11 @@ export function apiMessage(error) {
|
||||
}
|
||||
|
||||
export function conflictMessage(error, fallback) {
|
||||
if (error?.status === 409)
|
||||
return `冲突(409):${error?.body?.error || fallback}`;
|
||||
if (error?.status === 409) {
|
||||
const detail = error?.body?.error;
|
||||
const isGenericConflict = detail === "resource conflicts with existing state";
|
||||
return `冲突(409):${isGenericConflict ? fallback : detail || fallback}`;
|
||||
}
|
||||
if (error?.status === 503)
|
||||
return `环境不可用(503):${error?.body?.reason || error.message}`;
|
||||
return apiMessage(error);
|
||||
@@ -78,21 +81,11 @@ export function EmptyState({ text, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({ icon, title, description, children }) {
|
||||
export function PageHeader({ children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<header className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="flex items-center gap-2.5 text-2xl font-bold tracking-tight">
|
||||
{icon ? <i className={icon} aria-hidden="true" /> : null}
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="flex flex-wrap items-center gap-3">{children}</div>
|
||||
) : null}
|
||||
<header className="mb-6 flex min-h-10 justify-end">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">{children}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user