remove browser version management

This commit is contained in:
2026-09-21 08:47:21 +08:00
parent e79bd7425b
commit 7934435475
48 changed files with 713 additions and 3216 deletions
+33 -9
View File
@@ -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>
)
+31
View File
@@ -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();
});
});
+3 -3
View File
@@ -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(
"该版本正被环境引用",
);
});
});
+9 -91
View File
@@ -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",
-4
View File
@@ -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 })
+4 -14
View File
@@ -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"],
-18
View File
@@ -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",
+9 -16
View File
@@ -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>
);
}