feat(web): task-5 browsers 模块迁移(运行环境列表/创建/详情、网络出口列表/详情、网关管理,显式动词 API)

This commit is contained in:
2026-09-22 12:55:23 +08:00
parent 3e67556428
commit 8fb77f50da
6 changed files with 1157 additions and 12 deletions
+109 -2
View File
@@ -1,4 +1,111 @@
// TODO: 从 web.archived 迁移 browsers/$id
// 运行环境详情:语义对齐 web.archived BrowsersPage.jsx 的 BrowserDetail。
import { useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import { Alert, Button, Card, Descriptions, Space, Tag, Typography, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { getOne } from '@/services/api';
const stateText: Record<string, string> = {
created: '已创建',
running: '运行中',
stopped: '已停止',
degraded: '异常',
failed: '失败待清理',
cleanup_pending: '待清理',
gateway_unreachable: '网关不可达',
runtime_missing: '运行实例缺失',
recorded: '已记录',
missing: '未部署',
};
export default function Page() {
return null;
const { id = '' } = useParams<{ id: string }>();
const [runtime, setRuntime] = useState<any>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
useEffect(() => {
let cancelled = false;
setPending(true);
setError(null);
getOne('browsers', id)
.then((result) => {
if (!cancelled) setRuntime(result);
})
.catch((loadError) => {
if (!cancelled) setError(loadError);
})
.finally(() => {
if (!cancelled) setPending(false);
});
return () => {
cancelled = true;
};
}, [id]);
if (pending) return <Card loading />;
if (error || !runtime) return <Alert type="error" showIcon message={error?.message || '运行环境不存在'} />;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Title level={4} style={{ marginBottom: 0 }}>
{`${runtime.name}(${runtime.alias})`}
</Typography.Title>
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
</div>
<Card>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Tag color={runtime.state === 'running' ? 'success' : runtime.state === 'stopped' ? 'warning' : 'default'}>
{stateText[runtime.state] ?? `未知状态:${runtime.state}`}
</Tag>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="账号与指纹">
{runtime.account_id} · seed {runtime.fingerprint?.seed}
</Descriptions.Item>
<Descriptions.Item label="固定出口">
{`${runtime.network_exit_id || '未绑定'} · 健康:${runtime.network_exit_health || '未知'}`}
</Descriptions.Item>
<Descriptions.Item label="绑定版本 / 运行实例">
{`${runtime.binding_version} · ${runtime.runtime_instance_id || '无'}`}
</Descriptions.Item>
<Descriptions.Item label="网关">{runtime.gateway_reachable ? '可达' : '不可达'}</Descriptions.Item>
{runtime.runtime_node_id ? <Descriptions.Item label="节点">{runtime.runtime_node_id}</Descriptions.Item> : null}
{runtime.runtime_cleanup_state === 'pending' ? (
<Descriptions.Item label="资源待清理">
<Typography.Text type="danger">{runtime.runtime_cleanup_error || '原因未返回'}</Typography.Text>
</Descriptions.Item>
) : null}
{runtime.endpoint ? (
<Descriptions.Item label="CDP">
<Space size={4}>
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
{runtime.endpoint}
</Typography.Text>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
aria-label="复制 CDP 地址"
onClick={() => {
navigator.clipboard?.writeText(runtime.endpoint);
message.success('已复制 CDP 地址');
}}
/>
</Space>
</Descriptions.Item>
) : null}
</Descriptions>
<Space wrap>
<Button type="primary" onClick={() => history.push(`/accounts/${encodeURIComponent(runtime.account_id)}`)}>
查看账号
</Button>
{runtime.network_exit_id ? (
<Button onClick={() => history.push(`/network-exits/${encodeURIComponent(runtime.network_exit_id)}`)}>查看出口</Button>
) : null}
</Space>
</Space>
</Card>
</div>
);
}
+182 -2
View File
@@ -1,4 +1,184 @@
// TODO: 从 web.archived 迁移 browsers/index
// 运行环境列表:语义对齐 web.archived BrowsersPage.jsx 的 BrowserList。
// 动作保持显式动词(start/stop/recycle),启停禁用条件与归档版一致。
import { useCallback, useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { Alert, Button, Popconfirm, Space, Table, Tag, Typography, message } from 'antd';
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { browserAction, getList } from '@/services/api';
const stateText: Record<string, string> = {
created: '已创建',
running: '运行中',
stopped: '已停止',
degraded: '异常',
failed: '失败待清理',
cleanup_pending: '待清理',
gateway_unreachable: '网关不可达',
runtime_missing: '运行实例缺失',
recorded: '已记录',
missing: '未部署',
};
const blockText: Record<string, string> = {
account_revoked: '授权已撤销',
account_paused: '账号已暂停',
binding_missing: '绑定缺失',
network_exit_missing: '固定出口缺失',
network_exit_unhealthy: '固定出口不健康',
runtime_stop_pending: '停止结果待确认',
runtime_missing: '运行实例缺失',
};
const scheduleBlock = (runtime: any) =>
runtime.schedule_status === 'ready' ? '' : blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度';
export default function Page() {
return null;
const [runtimes, setRuntimes] = useState<any[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const load = useCallback(async () => {
setPending(true);
setError(null);
try {
const result = await getList({ resource: 'browsers' });
setRuntimes(result.data ?? []);
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function runAction(runtime: any, action: 'start' | 'stop' | 'recycle') {
setBusy(runtime.alias);
try {
await browserAction(runtime.alias, action);
await load();
} catch (reason: any) {
messageApi.error(reason.message || '操作失败');
} finally {
setBusy('');
}
}
async function handleRecycle(runtime: any) {
await runAction(runtime, 'recycle');
}
const columns: ColumnsType<any> = [
{
title: '环境',
dataIndex: 'name',
render: (_, runtime) => (
<div style={{ cursor: 'pointer' }} onClick={() => history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}>
<Typography.Text strong>{runtime.name}</Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
{runtime.alias} · seed {runtime.fingerprint?.seed}
</Typography.Paragraph>
</div>
),
},
{
title: '状态',
dataIndex: 'state',
render: (state: string) => {
const known = Object.hasOwn(stateText, state);
return (
<Tag color={state === 'running' ? 'success' : state === 'stopped' ? 'warning' : 'default'}>{known ? stateText[state] : `未知状态:${state}`}</Tag>
);
},
},
{
title: '固定绑定',
dataIndex: 'account_id',
render: (_, runtime) => {
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
return (
<div>
<Typography.Paragraph style={{ marginBottom: 0 }}>{runtime.account_id || '未绑定账号'}</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ marginBottom: 4, fontSize: 12 }} ellipsis>
{runtime.network_exit_id || '当前机器直连'}
</Typography.Paragraph>
<Tag color={block ? 'warning' : 'success'}>{block || '可调度'}</Tag>
</div>
);
},
},
{
title: 'CDP',
dataIndex: 'endpoint',
render: (endpoint?: string) =>
endpoint ? (
<Space size={4}>
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
{endpoint}
</Typography.Text>
<Button type="text" size="small" icon={<CopyOutlined />} aria-label="复制 CDP 地址" onClick={() => navigator.clipboard?.writeText(endpoint)} />
</Space>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
align: 'right',
render: (_, runtime) => {
const running = runtime.state === 'running';
const stopping = ['running', 'degraded', 'starting', 'failed', 'cleanup_pending'].includes(runtime.state);
const gatewayUnavailable = runtime.gateway_reachable === false;
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
return (
<Space wrap style={{ justifyContent: 'flex-end' }}>
<Button
size="small"
title={`启动 ${runtime.name}${block ? `(${block})` : ''}`}
disabled={busy === runtime.alias || running || gatewayUnavailable || block !== ''}
onClick={() => runAction(runtime, 'start')}
>
启动
</Button>
<Button size="small" disabled={busy === runtime.alias || !stopping || gatewayUnavailable} onClick={() => runAction(runtime, 'stop')}>
停止
</Button>
<Popconfirm title={`回收 ${runtime.name}(${runtime.alias})?运行资源将被回收,Profile 目录保留。`} onConfirm={() => handleRecycle(runtime)}>
<Button size="small" danger disabled={busy === runtime.alias || gatewayUnavailable}>
回收
</Button>
</Popconfirm>
</Space>
);
},
},
];
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">启动、停止并回收隔离的指纹浏览器运行环境</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/browsers/new')}>
创建环境
</Button>
</div>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Table
rowKey="alias"
columns={columns}
dataSource={runtimes}
loading={pending}
pagination={false}
locale={{ emptyText: '暂无运行环境,创建第一个隔离 Profile。' }}
/>
</div>
);
}
+204 -2
View File
@@ -1,4 +1,206 @@
// TODO: 从 web.archived 迁移 browsers/new
// 创建运行环境:语义对齐 web.archived BrowsersPage.jsx 的 BrowserCreatePage。
// 高级指纹参数用 antd Collapse 收纳;空值不提交,收敛到 seed 驱动。
import { useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { Alert, Button, Card, Checkbox, Collapse, Form, Input, InputNumber, Select, Space, Typography, message } from 'antd';
import { create, getList } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
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'];
// 高级字段空值不提交,收敛到 seed 驱动(对齐归档版 fingerprintPayload)。
function fingerprintPayload(form: any) {
const payload: Record<string, unknown> = { 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;
}
// 已用 seed 之外的最小可用 seed(对齐归档版 nextFingerprintSeed)。
function nextFingerprintSeed(runtimes: any[]): string {
const used = new Set(runtimes.map((runtime) => Number(runtime.fingerprint?.seed)).filter((seed) => Number.isInteger(seed) && seed > 0));
let seed = 1000;
while (used.has(seed)) seed += 1;
return String(seed);
}
export default function Page() {
return null;
const [form] = Form.useForm();
const [runtimes, setRuntimes] = useState<any[]>([]);
const [gateways, setGateways] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [networkExits, setNetworkExits] = useState<any[]>([]);
const [submitBusy, setSubmitBusy] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
Promise.allSettled([
getList({ resource: 'browsers' }),
getList({ resource: 'gateways' }),
getList({ resource: 'accounts' }),
getList({ resource: 'network-exits' }),
]).then(([browsers, gatewaysResult, accountsResult, exitsResult]) => {
if (browsers.status === 'fulfilled') {
setRuntimes(browsers.value.data ?? []);
form.setFieldsValue({ seed: nextFingerprintSeed(browsers.value.data ?? []) });
}
if (gatewaysResult.status === 'fulfilled') {
setGateways(gatewaysResult.value.data ?? []);
const first = (gatewaysResult.value.data ?? [])[0];
if (first) form.setFieldsValue({ gateway: first.name });
}
if (accountsResult.status === 'fulfilled') {
const paused = (accountsResult.value.data ?? []).filter(
(account: any) => account.authorization_status === 'authorized' && account.runtime_status === 'paused',
);
setAccounts(paused);
if (paused[0]?.id) form.setFieldsValue({ account_id: paused[0].id });
}
if (exitsResult.status === 'fulfilled') {
setNetworkExits((exitsResult.value.data ?? []).filter((item: any) => item.health_status === 'healthy'));
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const pausedAccounts = accounts;
const healthyExits = networkExits;
async function submit(values: any) {
setSubmitBusy(true);
try {
await create('browsers', {
alias: values.alias,
name: values.name.trim(),
gateway: values.gateway,
account_id: values.account_id,
network_exit_id: values.network_exit_id || '',
fingerprint: fingerprintPayload(values),
});
messageApi.success('运行环境已创建。');
history.push('/browsers');
} catch (reason: any) {
messageApi.error(
reason.status === 503 ? `环境不可用(503):${reason.message}` : conflictMessage(reason, '账号或运行环境已有活动绑定;表单内容已保留。'),
);
} finally {
setSubmitBusy(false);
}
}
return (
<div>
{contextHolder}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">为已授权且暂停的账号绑定网关、指纹与网络出口</Typography.Text>
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
</div>
<Card>
<Form form={form} layout="vertical" onFinish={submit}>
<Space size={16} wrap style={{ display: 'flex' }}>
<Form.Item name="name" label="环境名称" required rules={[{ required: true, message: '请输入环境名称' }]} extra="展示名称,支持中文,如:店铺一号">
<Input maxLength={64} />
</Form.Item>
<Form.Item
name="alias"
label="别名"
required
rules={[
{ required: true, message: '请输入别名' },
{ pattern: aliasPattern, message: '小写字母、数字与中划线,最长 32 字符' },
]}
extra="用于 Profile 标识与 API 路径"
>
<Input placeholder="如:shop-01" />
</Form.Item>
<Form.Item name="gateway" label="网关" required rules={[{ required: true, message: '请选择网关' }]} extra={gateways.length === 0 ? '请先在「网关管理」注册网关' : '环境由该 native gateway 管理'}>
<Select style={{ width: 200 }} placeholder="选择网关" disabled={gateways.length === 0} options={gateways.map((item) => ({ value: item.name, label: item.name }))} />
</Form.Item>
<Form.Item
name="seed"
label="Fingerprint Seed"
required
rules={[
{ required: true, message: '请输入 seed' },
{ type: 'integer', min: 1, max: 2147483647, message: '1..2147483647' },
]}
extra="1..2147483647,派生指纹参数"
>
<InputNumber min={1} max={2147483647} style={{ width: 180 }} />
</Form.Item>
<Form.Item name="account_id" label="社媒账号" required rules={[{ required: true, message: '请选择账号' }]} extra={pausedAccounts.length === 0 ? '请先准备已授权且暂停的账号' : '绑定后该账号固定使用此环境'}>
<Select style={{ width: 240 }} placeholder="选择账号" disabled={pausedAccounts.length === 0} options={pausedAccounts.map((item) => ({ value: item.id, label: `${item.id} · ${item.platform}` }))} />
</Form.Item>
<Form.Item name="network_exit_id" label="网络出口" extra="留空时使用当前机器网络出口直连">
<Select
style={{ width: 240 }}
allowClear
placeholder="当前机器直连"
options={healthyExits.map((item) => ({ value: item.id, label: `${item.id} · ${item.protocol}://${item.host}:${item.port}` }))}
/>
</Form.Item>
</Space>
<Collapse
ghost
items={[
{
key: 'advanced',
label: '高级指纹参数',
children: (
<Space size={16} wrap style={{ display: 'flex' }}>
<Form.Item name="platform" label="操作系统 platform">
<Select style={{ width: 140 }} allowClear placeholder="默认" options={platformOptions} />
</Form.Item>
<Form.Item name="platform_version" label="系统版本 platform-version" extra="如 11.0.0">
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="brand" label="浏览器品牌 brand">
<Select style={{ width: 140 }} allowClear placeholder="默认 Chromium" options={brandOptions} />
</Form.Item>
<Form.Item name="brand_version" label="品牌版本 brand-version" extra="如 132.0.6834.159">
<Input style={{ width: 160 }} />
</Form.Item>
<Form.Item name="hardware_concurrency" label="CPU 核心数 hardware-concurrency" extra="留空由种子生成">
<InputNumber min={1} max={128} style={{ width: 100 }} />
</Form.Item>
<Form.Item name="lang" label="语言 lang" extra="如 zh-CN">
<Input style={{ width: 120 }} />
</Form.Item>
<Form.Item name="accept_lang" label="接受语言 accept-lang" extra="如 zh-CN,en-US">
<Input style={{ width: 160 }} />
</Form.Item>
<Form.Item name="timezone" label="时区 timezone" extra="如 Asia/Shanghai">
<Input style={{ width: 160 }} />
</Form.Item>
<Form.Item name="disable_spoofing" label="禁用指纹伪装 disable-spoofing">
<Checkbox.Group options={spoofingFields.map((field) => ({ value: field, label: field }))} />
</Form.Item>
</Space>
),
},
]}
/>
<Space>
<Button type="primary" htmlType="submit" loading={submitBusy}>
{submitBusy ? '创建中…' : '创建环境'}
</Button>
<Button onClick={() => history.push('/browsers')}>取消</Button>
</Space>
</Form>
</Card>
</div>
);
}