Files
creator-hub/web/src/BrowsersPage.jsx
T
rogee 7ecad93821 refactor(web): migrate frontend to React 19 + Refine v5 + shadcn/ui stack
技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。

- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
  BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
  DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
  /browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
  等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
  AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
  hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
  ({resource, pagination, filters, meta, variables}),create/update/
  delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互

验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
2026-09-02 13:40:34 +08:00

385 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { useDataProvider, useList, useOne } from '@refinedev/core'
import {
Alert, Button, Card, CardContent, Copyable, DetailList, Field, Input, Modal, PageHeader,
PageState, Select, StatusPill, Table,
} from './lib/ui.jsx'
import { useTitle } from './lib/hooks.js'
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/
const platformOptions = ['windows', 'linux', 'macos'].map(value => ({ value, label: value }))
const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'].map(value => ({ value, label: value }))
const spoofingFields = ['font', 'audio', 'canvas', 'clientrects', 'gpu']
const stateText = { created: '已创建', running: '运行中', exited: '已停止', restarting: '重启中', paused: '已暂停', missing: '未部署' }
const blockText = {
account_revoked: '授权已撤销', account_paused: '账号已暂停', binding_missing: '绑定缺失',
network_exit_missing: '固定出口缺失', network_exit_unhealthy: '固定出口不健康',
runtime_stop_pending: '停止结果待确认', runtime_missing: '运行实例缺失',
}
const scheduleBlock = runtime => runtime.schedule_status === 'ready'
? ''
: blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度'
const createInitial = {
name: '', alias: '', gateway: '', image_version: '', account_id: '', network_exit_id: '', seed: '1000',
platform: '', platform_version: '', brand: '', brand_version: '', hardware_concurrency: '', lang: '', accept_lang: '', timezone: '', disable_spoofing: [],
}
// 高级字段空值不提交,收敛到 seed 驱动。
function fingerprintPayload(form) {
const payload = { seed: Number(form.seed) }
const advanced = { platform: form.platform, platform_version: form.platform_version, brand: form.brand, brand_version: form.brand_version, lang: form.lang, accept_lang: form.accept_lang, timezone: form.timezone }
for (const [key, value] of Object.entries(advanced)) if (value !== '') payload[key] = value
if (form.hardware_concurrency !== '') payload.hardware_concurrency = Number(form.hardware_concurrency)
if (form.disable_spoofing.length > 0) payload.disable_spoofing = form.disable_spoofing.join(',')
return payload
}
function StatePill({ state }) {
const known = Object.hasOwn(stateText, state)
return <StatusPill tone={state === 'running' ? 'success' : state === 'exited' ? 'warning' : 'neutral'} label={known ? stateText[state] : '未知状态'} sub={known ? undefined : state} />
}
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime)
const startLabel = `启动 ${runtime.name}${block ? `(${block})` : ''}`
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" icon="ri-play-line" aria-label={startLabel} disabled={busy || running || block !== ''} onClick={() => onAction(runtime, 'start')}>启动</Button>
<Button size="sm" icon="ri-stop-line" aria-label={`停止 ${runtime.name}`} disabled={busy || !running} onClick={() => onAction(runtime, 'stop')}>停止</Button>
<Button size="sm" icon="ri-arrow-up-circle-line" aria-label={`升级 ${runtime.name}`} disabled={busy} onClick={() => onAction(runtime, 'upgrade')}>升级</Button>
<Button size="sm" variant="danger" icon="ri-delete-bin-line" aria-label={`回收 ${runtime.name}`} disabled={busy} onClick={() => onAction(runtime, 'recycle')}>回收</Button>
</div>
)
}
function SchedulePill({ runtime }) {
const block = scheduleBlock(runtime)
return <StatusPill tone={block ? 'warning' : 'success'} label={block || '可调度'} />
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
const [version, setVersion] = useState('')
const enabled = images.filter(image => image.enabled)
useEffect(() => { setVersion('') }, [target])
return (
<Modal open={target !== null} onClose={busy ? undefined : onClose} title={`升级 ${target ? `${target.name}(${target.alias})` : ''}`} size="sm" labelledBy="upgrade-title"
footer={<>
<Button onClick={onClose} disabled={busy}>取消</Button>
<Button variant="primary" busy={busy} busyText="升级中…" disabled={version === '' || version === target?.image_version} onClick={() => onConfirm(version)}>确认升级</Button>
</>}>
<div className="space-y-4">
<p className="text-sm text-muted">容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。</p>
<Field id="upgrade-version" label="目标镜像版本" required>
<Select id="upgrade-version" value={version} onChange={event => setVersion(event.target.value)}
options={enabled.map(image => ({ value: image.version, label: `${image.version}${image.version === target?.image_version ? '(当前)' : ''}` }))}
placeholder="选择版本" />
</Field>
</div>
</Modal>
)
}
function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
return (
<div className="hidden overflow-x-auto rounded-lg border border-hairline bg-white md:block">
<table className="w-full min-w-[860px] table-fixed text-sm">
<thead>
<tr className="border-b border-hairline text-left text-xs text-muted">
<th className="w-[19%] px-4 py-2.5 font-medium">环境</th>
<th className="w-[12%] px-4 py-2.5 font-medium">状态</th>
<th className="w-[23%] px-4 py-2.5 font-medium">固定绑定</th>
<th className="w-[16%] px-4 py-2.5 font-medium">CDP</th>
<th className="w-[30%] px-4 py-2.5 font-medium">操作</th>
</tr>
</thead>
<tbody>
{runtimes.map(runtime => (
<tr key={runtime.alias} className="border-b border-hairline last:border-0">
<td className="cursor-pointer px-4 py-3" onClick={() => onRowClick(runtime)}>
<p className="font-semibold">{runtime.name}</p>
<p className="anywhere text-xs text-muted">{runtime.alias} · seed {runtime.fingerprint?.seed}</p>
</td>
<td className="px-4 py-3"><StatePill state={runtime.state} /></td>
<td className="px-4 py-3">
<p className="text-sm">{runtime.account_id || '未绑定账号'}</p>
<p className="anywhere text-xs text-muted">{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}</p>
<div className="mt-1"><SchedulePill runtime={runtime} /></div>
</td>
<td className="px-4 py-3">
{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <span className="text-sm text-muted">—</span>}
</td>
<td className="px-4 py-3"><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function RuntimeCards({ runtimes, busy, onAction }) {
return (
<div className="space-y-4 md:hidden">
{runtimes.map(runtime => (
<Card key={runtime.alias}>
<CardContent className="space-y-3">
<div>
<p className="font-semibold">{runtime.name}</p>
<p className="anywhere text-xs text-muted">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</p>
</div>
<StatePill state={runtime.state} />
<p className="text-sm">账号:{runtime.account_id || '未绑定'} · 网络出口:{runtime.network_exit_id || '当前机器直连'}</p>
<SchedulePill runtime={runtime} />
{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}
<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} />
</CardContent>
</Card>
))}
</div>
)
}
export function BrowserList() {
const navigate = useNavigate()
const dataProvider = useDataProvider()('default')
const [busy, setBusy] = useState('')
const [actionError, setActionError] = useState('')
const [upgradeTarget, setUpgradeTarget] = useState(null)
const { result, query } = useList({ resource: 'browsers', queryOptions: { refetchInterval: 3000 } })
const error = query.error
const isPending = query.isPending
const runtimes = result.data ?? []
const { result: imageResult, query: imagesQuery } = useList({ resource: 'browser-images' })
const images = imageResult.data ?? []
useTitle('CreatorHub · 运行环境')
async function handleAction(runtime, action) {
if (action === 'recycle' && !window.confirm(`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`)) return
if (action === 'upgrade') { setUpgradeTarget(runtime); return }
await runAction(runtime, action)
}
async function runAction(runtime, action, data) {
setBusy(runtime.alias)
setActionError('')
try {
await dataProvider.browserAction(runtime.alias, action, data)
await query.refetch()
} catch (reason) {
setActionError(reason.message)
} finally {
setBusy('')
}
}
async function upgrade(version) {
await runAction(upgradeTarget, 'upgrade', { version })
setUpgradeTarget(null)
}
return (
<div>
<PageHeader icon="ri-server-line" title="运行环境" description="启动、停止、升级并回收隔离的指纹浏览器">
<Button variant="primary" icon="ri-add-line" onClick={() => navigate('/browsers/new')}>创建环境</Button>
</PageHeader>
{actionError ? (
<Alert variant="destructive" className="mb-4">
{actionError}
<Button size="sm" onClick={() => query.refetch()}>重试</Button>
</Alert>
) : null}
<PageState pending={isPending} error={error} empty={runtimes.length === 0} emptyText="暂无运行环境,创建第一个隔离 Profile。">
<RuntimeTable runtimes={runtimes} busy={busy} onAction={handleAction} onRowClick={runtime => navigate(`/browsers/${encodeURIComponent(runtime.alias)}`)} />
<RuntimeCards runtimes={runtimes} busy={busy} onAction={handleAction} />
</PageState>
<UpgradeDialog target={upgradeTarget} images={images} busy={busy === upgradeTarget?.alias} onClose={() => setUpgradeTarget(null)} onConfirm={upgrade} />
<p className="mt-6 flex items-center gap-2 text-sm text-muted"><i className="ri-shield-check-line text-primary" aria-hidden="true" />Docker socket 仅由受限网关访问,镜像与参数由平台下发</p>
</div>
)
}
export function BrowserCreatePage() {
const navigate = useNavigate()
const dataProvider = useDataProvider()('default')
const [form, setForm] = useState(createInitial)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [submitBusy, setSubmitBusy] = useState(false)
const [submitError, setSubmitError] = useState('')
const { query: runtimesQuery } = useList({ resource: 'browsers' })
const { result: gatewayResult } = useList({ resource: 'gateways' })
const gateways = gatewayResult.data ?? []
const { result: imageResult } = useList({ resource: 'browser-images' })
const images = imageResult.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 enabledImages = images.filter(image => image.enabled)
const pausedAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused')
const healthyExits = networkExits.filter(item => item.health_status === 'healthy')
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
const toggleSpoofing = field => setForm(current => ({ ...current, disable_spoofing: current.disable_spoofing.includes(field) ? current.disable_spoofing.filter(item => item !== field) : [...current.disable_spoofing, field] }))
// 选中项为空且列表就绪时,默认选中第一个可选项,减少必填噪音。
useEffect(() => { if (form.gateway === '' && gateways.length > 0) update('gateway', gateways[0].name) }, [gateways, form.gateway])
useEffect(() => { if (form.image_version === '' && enabledImages.length > 0) update('image_version', enabledImages[0].version) }, [enabledImages, form.image_version])
useEffect(() => { if (form.account_id === '' && pausedAccounts[0]?.id) update('account_id', pausedAccounts[0].id) }, [pausedAccounts, form.account_id])
const seed = Number(form.seed)
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' && form.image_version !== '' && form.account_id !== '' && Number.isInteger(seed) && seed >= 1 && seed <= 2147483647
async function submit(event) {
event.preventDefault()
if (!valid) return
setSubmitBusy(true)
setSubmitError('')
try {
await dataProvider.create({
resource: 'browsers',
variables: {
alias: form.alias, name: form.name.trim(), gateway: form.gateway, image_version: form.image_version,
account_id: form.account_id, network_exit_id: form.network_exit_id, fingerprint: fingerprintPayload(form),
},
})
await runtimesQuery.refetch()
navigate('/browsers')
} catch (reason) {
setSubmitError(reason.status === 409 ? '冲突(409):账号或运行环境已有活动绑定;表单内容已保留。' : reason.status === 503 ? `环境不可用(503):${reason.message}` : reason.message)
} finally {
setSubmitBusy(false)
}
}
return (
<div className="max-w-[960px]">
<PageHeader title="创建运行环境" description="为已授权且暂停的账号绑定网关、镜像、指纹与网络出口">
<Button icon="ri-arrow-left-line" onClick={() => navigate('/browsers')}>返回运行环境</Button>
</PageHeader>
{submitError ? <Alert variant="destructive" className="mb-4">{submitError}</Alert> : null}
<Card>
<CardContent>
<form onSubmit={submit} noValidate className="space-y-6">
<div className="grid gap-5 sm:grid-cols-2">
<Field id="env-name" label="环境名称" required helper="展示名称,支持中文,如:店铺一号">
<Input id="env-name" required maxLength={64} value={form.name} onChange={event => update('name', event.target.value)} />
</Field>
<Field id="env-alias" label="别名(容器名)" required error={form.alias !== '' && !aliasPattern.test(form.alias) ? '小写字母、数字与中划线,最长 32 字符' : undefined} helper="用于容器名、数据卷与 API 路径">
<Input id="env-alias" required pattern="[a-z0-9][a-z0-9-]{0,31}" value={form.alias} onChange={event => update('alias', event.target.value)} placeholder="如:shop-01" />
</Field>
<Field id="env-gateway" label="网关" required error={gateways.length === 0 ? '请先在「网关管理」注册网关' : undefined} helper="容器调度到该网关">
<Select id="env-gateway" value={form.gateway} onChange={event => update('gateway', event.target.value)} options={gateways.map(item => ({ value: item.name, label: item.name }))} placeholder="选择网关" disabled={gateways.length === 0} />
</Field>
<Field id="env-version" label="镜像版本" required error={enabledImages.length === 0 ? '请先在「镜像版本」添加并启用' : undefined} helper="环境使用的浏览器镜像">
<Select id="env-version" value={form.image_version} onChange={event => update('image_version', event.target.value)} options={enabledImages.map(item => ({ value: item.version, label: item.version }))} placeholder="选择版本" disabled={enabledImages.length === 0} />
</Field>
<Field id="env-seed" label="Fingerprint Seed" required helper="1..2147483647,派生指纹参数">
<Input id="env-seed" type="number" min={1} max={2147483647} required value={form.seed} onChange={event => update('seed', event.target.value)} />
</Field>
<Field id="env-account" label="社媒账号" required error={pausedAccounts.length === 0 ? '请先准备已授权且暂停的账号' : undefined} helper="绑定后该账号固定使用此环境">
<Select id="env-account" value={form.account_id} onChange={event => update('account_id', event.target.value)} options={pausedAccounts.map(item => ({ value: item.id, label: `${item.id} · ${item.platform}` }))} placeholder="选择账号" disabled={pausedAccounts.length === 0} />
</Field>
<Field id="env-exit" label="网络出口" helper="留空时使用当前机器网络出口直连">
<Select id="env-exit" value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)}
options={healthyExits.map(item => ({ value: item.id, label: `${item.id} · ${item.protocol}://${item.host}:${item.port}` }))}
placeholder="当前机器直连" emptyOption={{ value: '', label: '当前机器直连' }} />
</Field>
</div>
<div>
<button type="button" onClick={() => setAdvancedOpen(current => !current)} className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline">
高级指纹参数{advancedOpen ? '(收起)' : '(展开)'}
<i className={advancedOpen ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'} aria-hidden="true" />
</button>
{advancedOpen ? (
<div className="mt-4 grid gap-5 border-t border-hairline pt-4 sm:grid-cols-3">
<Field id="env-platform" label="操作系统 platform">
<Select id="env-platform" value={form.platform} onChange={event => update('platform', event.target.value)} options={platformOptions} emptyOption={{ value: '', label: '默认' }} placeholder="默认" />
</Field>
<Field id="env-platform-version" label="系统版本 platform-version" helper="如 11.0.0">
<Input id="env-platform-version" value={form.platform_version} onChange={event => update('platform_version', event.target.value)} />
</Field>
<Field id="env-brand" label="浏览器品牌 brand">
<Select id="env-brand" value={form.brand} onChange={event => update('brand', event.target.value)} options={brandOptions} emptyOption={{ value: '', label: '默认 Chromium' }} placeholder="默认 Chromium" />
</Field>
<Field id="env-brand-version" label="品牌版本 brand-version" helper="如 132.0.6834.159">
<Input id="env-brand-version" value={form.brand_version} onChange={event => update('brand_version', event.target.value)} />
</Field>
<Field id="env-concurrency" label="CPU 核心数 hardware-concurrency" helper="留空由种子生成">
<Input id="env-concurrency" type="number" min={1} max={128} value={form.hardware_concurrency} onChange={event => update('hardware_concurrency', event.target.value)} />
</Field>
<Field id="env-lang" label="语言 lang" helper="如 zh-CN">
<Input id="env-lang" value={form.lang} onChange={event => update('lang', event.target.value)} />
</Field>
<Field id="env-accept-lang" label="接受语言 accept-lang" helper="如 zh-CN,en-US">
<Input id="env-accept-lang" value={form.accept_lang} onChange={event => update('accept_lang', event.target.value)} />
</Field>
<Field id="env-timezone" label="时区 timezone" helper="如 Asia/Shanghai">
<Input id="env-timezone" value={form.timezone} onChange={event => update('timezone', event.target.value)} />
</Field>
<div className="space-y-1.5">
<p className="text-sm font-medium">禁用指纹伪装 disable-spoofing</p>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{spoofingFields.map(field => (
<label key={field} className="flex cursor-pointer items-center gap-1.5 text-sm">
<input type="checkbox" checked={form.disable_spoofing.includes(field)} onChange={() => toggleSpoofing(field)} aria-label={`禁用伪装 ${field}`} className="size-4 accent-primary" />
{field}
</label>
))}
</div>
</div>
</div>
) : null}
</div>
<div className="flex gap-3">
<Button variant="primary" type="submit" busy={submitBusy} busyText="创建中…" disabled={!valid}>创建环境</Button>
<Button type="button" onClick={() => navigate('/browsers')}>取消</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
export function BrowserDetail() {
const { id } = useParams()
const { result, error, isPending } = useOne({ resource: 'browsers', id, queryOptions: { retry: false } })
const runtime = result
useTitle('CreatorHub · 环境详情')
if (isPending) return <PageState pending />
if (error || !runtime) return <Alert variant="destructive">{error?.message || '运行环境不存在'}</Alert>
return (
<div>
<PageHeader title="运行环境详情" description={`${runtime.name}(${runtime.alias})`}>
<Button icon="ri-arrow-left-line" as={Link} to="/browsers">返回运行环境</Button>
</PageHeader>
<Card>
<CardContent className="space-y-4">
<StatePill state={runtime.state} />
<DetailList rows={[
['账号与镜像', <span className="anywhere">{runtime.account_id} · {runtime.image_version}</span>],
['固定出口', <span className="anywhere">{runtime.network_exit?.id || '未绑定'} · 健康:{runtime.network_exit?.health_status || '未知'}</span>],
['绑定版本 / 运行实例', <span className="anywhere">{runtime.binding_version} · {runtime.runtime_instance_id || '无'}</span>],
]} />
{runtime.endpoint ? (
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<dt className="text-muted">CDP</dt>
<dd><Copyable value={runtime.endpoint} /></dd>
</div>
) : null}
<div className="flex flex-wrap gap-3">
<Button as={Link} to={`/accounts/${encodeURIComponent(runtime.account_id)}`}>查看账号</Button>
{runtime.network_exit?.id ? <Button as={Link} to={`/network-exits/${encodeURIComponent(runtime.network_exit.id)}`}>查看出口</Button> : null}
</div>
</CardContent>
</Card>
</div>
)
}