From 8fb77f50da1eda82eefdd0ff4856d8ada1602ba9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 22 Sep 2026 12:55:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20task-5=20browsers=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=BF=81=E7=A7=BB=EF=BC=88=E8=BF=90=E8=A1=8C=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=88=97=E8=A1=A8/=E5=88=9B=E5=BB=BA/=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E3=80=81=E7=BD=91=E7=BB=9C=E5=87=BA=E5=8F=A3=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E8=AF=A6=E6=83=85=E3=80=81=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E6=98=BE=E5=BC=8F=E5=8A=A8=E8=AF=8D?= =?UTF-8?q?=20API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/browsers/$id/index.tsx | 111 ++++++- web/src/pages/browsers/index.tsx | 184 +++++++++++- web/src/pages/browsers/new.tsx | 206 ++++++++++++- web/src/pages/gateways/index.tsx | 255 +++++++++++++++- web/src/pages/network-exits/$id/index.tsx | 76 ++++- web/src/pages/network-exits/index.tsx | 337 +++++++++++++++++++++- 6 files changed, 1157 insertions(+), 12 deletions(-) diff --git a/web/src/pages/browsers/$id/index.tsx b/web/src/pages/browsers/$id/index.tsx index e5c496b..12f6328 100644 --- a/web/src/pages/browsers/$id/index.tsx +++ b/web/src/pages/browsers/$id/index.tsx @@ -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 = { + 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(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(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 ; + if (error || !runtime) return ; + + return ( +
+
+ + {`${runtime.name}(${runtime.alias})`} + + +
+ + + + {stateText[runtime.state] ?? `未知状态:${runtime.state}`} + + + + {runtime.account_id} · seed {runtime.fingerprint?.seed} + + + {`${runtime.network_exit_id || '未绑定'} · 健康:${runtime.network_exit_health || '未知'}`} + + + {`${runtime.binding_version} · ${runtime.runtime_instance_id || '无'}`} + + {runtime.gateway_reachable ? '可达' : '不可达'} + {runtime.runtime_node_id ? {runtime.runtime_node_id} : null} + {runtime.runtime_cleanup_state === 'pending' ? ( + + {runtime.runtime_cleanup_error || '原因未返回'} + + ) : null} + {runtime.endpoint ? ( + + + + {runtime.endpoint} + + + {runtime.network_exit_id ? ( + + ) : null} + + + +
+ ); } diff --git a/web/src/pages/browsers/index.tsx b/web/src/pages/browsers/index.tsx index 96c74e3..e79bf28 100644 --- a/web/src/pages/browsers/index.tsx +++ b/web/src/pages/browsers/index.tsx @@ -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 = { + created: '已创建', + running: '运行中', + stopped: '已停止', + degraded: '异常', + failed: '失败待清理', + cleanup_pending: '待清理', + gateway_unreachable: '网关不可达', + runtime_missing: '运行实例缺失', + recorded: '已记录', + missing: '未部署', +}; +const blockText: Record = { + 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([]); + const [pending, setPending] = useState(true); + const [error, setError] = useState(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 = [ + { + title: '环境', + dataIndex: 'name', + render: (_, runtime) => ( +
history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}> + {runtime.name} + + {runtime.alias} · seed {runtime.fingerprint?.seed} + +
+ ), + }, + { + title: '状态', + dataIndex: 'state', + render: (state: string) => { + const known = Object.hasOwn(stateText, state); + return ( + {known ? stateText[state] : `未知状态:${state}`} + ); + }, + }, + { + title: '固定绑定', + dataIndex: 'account_id', + render: (_, runtime) => { + const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime); + return ( +
+ {runtime.account_id || '未绑定账号'} + + {runtime.network_exit_id || '当前机器直连'} + + {block || '可调度'} +
+ ); + }, + }, + { + title: 'CDP', + dataIndex: 'endpoint', + render: (endpoint?: string) => + endpoint ? ( + + + {endpoint} + + + + handleRecycle(runtime)}> + + + + ); + }, + }, + ]; + + return ( +
+ {contextHolder} +
+ 启动、停止并回收隔离的指纹浏览器运行环境 + +
+ {error ? ( + 重试} /> + ) : null} + + + ); } diff --git a/web/src/pages/browsers/new.tsx b/web/src/pages/browsers/new.tsx index da76967..6f4cf08 100644 --- a/web/src/pages/browsers/new.tsx +++ b/web/src/pages/browsers/new.tsx @@ -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 = { 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([]); + const [gateways, setGateways] = useState([]); + const [accounts, setAccounts] = useState([]); + const [networkExits, setNetworkExits] = useState([]); + 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 ( +
+ {contextHolder} +
+ 为已授权且暂停的账号绑定网关、指纹与网络出口 + +
+ +
+ + + + + + + + + ({ value: item.id, label: `${item.id} · ${item.platform}` }))} /> + + + + + + + + + + + + + + + + + + + + + + + + ({ value: field, label: field }))} /> + + + ), + }, + ]} + /> + + + + + +
+
+ ); } diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 726db08..b3e76ad 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -1,4 +1,255 @@ -// TODO: task-5 迁移网关管理页(web.archived/src/features/environments/GatewaysPage.jsx) +// 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。 +import { useCallback, useEffect, useState } from 'react'; +import { Alert, Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, message } from 'antd'; +import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { create, getList, remove, update } from '@/services/api'; +import { conflictMessage } from '@/utils/helpers'; + +const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/; + +function GatewayFormModal({ + open, + initial, + busy, + error, + onCancel, + onSubmit, +}: { + open: boolean; + initial: any | null; + busy: boolean; + error: any; + onCancel: () => void; + onSubmit: (values: { name: string; endpoint: string; token: string }) => Promise; +}) { + const editing = initial !== null; + const [form] = Form.useForm<{ name: string; endpoint: string; token?: string }>(); + useEffect(() => { + if (open) form.setFieldsValue(editing ? { name: initial.name, endpoint: initial.endpoint, token: '' } : { name: '', endpoint: '', token: '' }); + }, [open, initial, editing, form]); + + return ( + form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '注册中…') : editing ? '保存' : '注册网关'}> + {error ? : null} +
+ + + + + + + + + + +
+ ); +} + export default function Page() { - return null; + const [gateways, setGateways] = useState([]); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(''); + const [created, setCreated] = useState(null); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(async () => { + setPending(true); + setError(null); + try { + const result = await getList({ resource: 'gateways' }); + setGateways(result.data ?? []); + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function createGateway(values: { name: string; endpoint: string; token: string }): Promise { + setBusy('create'); + try { + const record = await create('gateways', values); + setCreated(record?.data ?? record); + await load(); + setCreateOpen(false); + return true; + } catch (reason) { + messageApi.error(conflictMessage(reason, '网关注册失败;表单内容已保留。')); + return false; + } finally { + setBusy(''); + } + } + + async function updateGateway(values: { name: string; endpoint: string; token: string }): Promise { + if (!editing) return false; + setBusy('edit'); + try { + await update('gateways', editing.name, values); + await load(); + setEditing(null); + return true; + } catch (reason) { + messageApi.error(conflictMessage(reason, '网关更新失败;表单内容已保留。')); + return false; + } finally { + setBusy(''); + } + } + + async function removeGateway(gateway: any) { + setBusy(gateway.name); + try { + await remove('gateways', gateway.name); + await load(); + } catch (reason) { + messageApi.error(conflictMessage(reason, '网关删除失败')); + } finally { + setBusy(''); + } + } + + const columns: ColumnsType = [ + { title: '名称', dataIndex: 'name', render: (value: string) => {value} }, + { title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => {value} }, + { + title: '令牌', + dataIndex: 'token', + render: (token: string, gateway) => , + }, + { + title: '操作', + key: 'actions', + align: 'right', + render: (_, gateway) => ( + + + removeGateway(gateway)}> + + + + ), + }, + ]; + + return ( +
+ {contextHolder} +
+ 注册宿主机 native browser gateway;令牌须与 gateway 进程的 GATEWAY_TOKEN 一致 + +
+ {created ? ( + + 网关 {created.name} 已注册。令牌 + {created.token} + + + } + /> + ) : null} + {error ? ( + 重试} /> + ) : null} +
+ { + setCreateOpen(false); + }} + onSubmit={createGateway} + /> + setEditing(null)} + onSubmit={updateGateway} + /> + + ); +} + +function TokenCell({ token, name }: { token: string; name: string }) { + const [visible, setVisible] = useState(false); + return ( + + + {visible ? token : '••••••••••••'} + + + + + + + {known ? healthText[exit.health_status] : `未知状态:${exit.health_status}`} + + + + {exit.id} + + + {`${healthText[exit.health_status] ?? exit.health_status} · ${exit.version}`} + + {exit.username || '无'} + {exit.password || '无'} + {exit.observed_public_ip || '尚无观测'} + {exit.last_checked_at ? dateTime(exit.last_checked_at) : '未检测'} + + + + + ); } diff --git a/web/src/pages/network-exits/index.tsx b/web/src/pages/network-exits/index.tsx index 15bceb6..abde3f1 100644 --- a/web/src/pages/network-exits/index.tsx +++ b/web/src/pages/network-exits/index.tsx @@ -1,4 +1,337 @@ -// TODO: 从 web.archived 迁移 network-exits/index +// 网络出口:语义对齐 web.archived NetworkExitsPage.jsx(列表 + 创建/编辑 Modal + 检测/启停/删除动作 + 出口详情页)。 +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { history } from '@umijs/max'; +import { Alert, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { create, getOne, getList, networkExitAction, remove, update } from '@/services/api'; +import { conflictMessage, dateTime } from '@/utils/helpers'; + +const healthText: Record = { + unchecked: '未检测', + healthy: '健康', + unhealthy: '不健康', + disabled: '已停用', +}; +const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map((value) => ({ value, label: value })); + +function healthTag(status?: string) { + const known = Object.hasOwn(healthText, status ?? ''); + const color = status === 'healthy' ? 'success' : status === 'disabled' ? 'default' : status === 'unchecked' ? 'warning' : 'error'; + return {known ? healthText[status ?? ''] : `未知状态:${status}`}; +} + +interface ExitFormValues { + protocol: string; + host: string; + port: number; + username?: string; + password?: string; + expected_public_ip?: string; + expected_region?: string; +} + +function ExitFormModal({ + open, + initial, + title, + submitLabel, + busy, + error, + onCancel, + onSubmit, +}: { + open: boolean; + initial: any | null; + title: string; + submitLabel: string; + busy: boolean; + error: any; + onCancel: () => void; + onSubmit: (values: ExitFormValues) => Promise; +}) { + const [form] = Form.useForm(); + useEffect(() => { + if (open) { + form.setFieldsValue( + initial + ? { ...initial, port: initial.port ? Number(initial.port) : undefined } + : { protocol: 'socks5', host: '', port: undefined, username: '', password: '', expected_public_ip: '', expected_region: '' }, + ); + } + }, [open, initial, form]); + + return ( + form.submit()} confirmLoading={busy} okText={busy ? '保存中…' : submitLabel} width={640}> + {error ? : null} +
+ + + + + + + + + + + ({ + validator: (_rule, value) => + !value || getFieldValue('username') + ? Promise.resolve() + : Promise.reject(new Error('填写密码时必须同时填写用户名')), + }), + ]} + > + + + + + + + + + + +
+ ); +} + export default function Page() { - return null; + const [exits, setExits] = useState([]); + const [browsers, setBrowsers] = useState([]); + const [bindingsError, setBindingsError] = useState(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(''); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(async () => { + setPending(true); + setError(null); + try { + const result = await getList({ resource: 'network-exits' }); + setExits(result.data ?? []); + } catch (loadError) { + setError(loadError); + } finally { + setPending(false); + } + }, []); + + const loadBindings = useCallback(async () => { + try { + const result = await getList({ resource: 'browsers' }); + setBrowsers(result.data ?? []); + setBindingsError(null); + } catch (loadError) { + setBindingsError(loadError); + } + }, []); + + useEffect(() => { + load(); + loadBindings(); + }, [load, loadBindings]); + + const accountsByExit = useMemo(() => { + const map = new Map(); + for (const runtime of browsers) { + if (!runtime.network_exit_id || !runtime.account_id) continue; + map.set(runtime.network_exit_id, [...(map.get(runtime.network_exit_id) || []), runtime.account_id]); + } + return map; + }, [browsers]); + + async function createExit(values: ExitFormValues): Promise { + setBusy('create'); + try { + await create('network-exits', values); + await load(); + setCreateOpen(false); + messageApi.success('网络出口已创建;检测通过后才可用于账号绑定。'); + return true; + } catch (reason) { + messageApi.error(conflictMessage(reason, '出口地址或认证信息与现有资源冲突;表单内容已保留。')); + return false; + } finally { + setBusy(''); + } + } + + async function updateExit(values: ExitFormValues): Promise { + if (!editing) return false; + setBusy(`edit:${editing.id}`); + try { + await update('network-exits', editing.id, values); + await load(); + setEditing(null); + messageApi.success('网络出口已更新,需重新检测后才能使用。'); + return true; + } catch (reason) { + messageApi.error(conflictMessage(reason, '出口当前不能修改。')); + return false; + } finally { + setBusy(''); + } + } + + async function deleteExit(exit: any) { + setBusy(`delete:${exit.id}`); + try { + await remove('network-exits', exit.id); + await load(); + messageApi.success('网络出口已删除。'); + } catch (reason) { + messageApi.error(conflictMessage(reason, '出口仍有绑定或运行实例,不能删除。')); + } finally { + setBusy(''); + } + } + + async function runAction(exit: any, action: 'check' | 'disable' | 'enable') { + setBusy(exit.id); + try { + await networkExitAction(exit.id, action); + await Promise.all([load(), loadBindings()]); + messageApi.success( + action === 'check' ? '出口检测完成。' : action === 'enable' ? '出口已启用;重新检测通过后才能绑定。' : '出口已停用;绑定账号不会自动恢复。', + ); + } catch (reason) { + messageApi.error(conflictMessage(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。')); + } finally { + setBusy(''); + } + } + + const columns: ColumnsType = [ + { + title: '出口', + dataIndex: 'id', + render: (_, exit) => ( +
+ history.push(`/network-exits/${encodeURIComponent(exit.id)}`)}>{exit.id} + + {`${exit.protocol}://${exit.host}:${exit.port}`} + +
+ ), + }, + { title: '状态', dataIndex: 'health_status', render: (value?: string) => healthTag(value) }, + { title: '用户名', dataIndex: 'username', render: (value?: string) => value || '无' }, + { title: '密码', dataIndex: 'password', render: (value?: string) => value || '无' }, + { title: '出口IP', dataIndex: 'observed_public_ip', render: (value?: string) => value || '尚无观测' }, + { title: '最近检测', dataIndex: 'last_checked_at', render: (value?: string) => (value ? dateTime(value) : '未检测') }, + { + title: '绑定账号', + key: 'accounts', + render: (_, exit) => + bindingsError ? ( + 状态未知 + ) : ( + + {accountsByExit.get(exit.id)?.join('、') || '未绑定'} + + ), + }, + { + title: '操作', + key: 'actions', + align: 'right', + render: (_, exit) => ( + + + {exit.health_status === 'disabled' ? ( + + ) : ( + runAction(exit, 'disable')}> + + + )} + + deleteExit(exit)}> + + + + ), + }, + ]; + + return ( +
+ {contextHolder} +
+ 维护固定出口、健康观测与账号绑定 + +
+ {error ? ( + 重试} /> + ) : null} + {bindingsError ? ( + + 重试环境状态 + + } + /> + ) : null} +
+ { + setCreateOpen(false); + }} + onSubmit={createExit} + /> + setEditing(null)} + onSubmit={updateExit} + /> + + ); }