feat(web): gateway management for outbound access keys

- create/edit by name only; platform-issued key echoed once with
  gateway config guide (CREATOR_PLATFORM_URL + CREATOR_GATEWAY_KEY)
- online/offline/never-connected tag from channel session state,
  version column, pending task count linking to per-gateway task
  history drawer (paginated, duration, status tag, error)
- reset-key action with confirm; api layer adds gatewayAction and
  gatewayTasks; delete whitelist includes gateways
- tests updated for the outbound model; events ellipsis checklist
  entry removed (field no longer truncated); table header test picks
  the sticky table
This commit is contained in:
2026-10-09 08:36:34 +08:00
parent d9e57c4900
commit a34ba07d5b
6 changed files with 311 additions and 146 deletions
+209 -118
View File
@@ -1,53 +1,101 @@
// 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, App, Button, Card, Flex, Form, Grid, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons';
// 网关管理:平台为每个网关签发 access key,网关以出站 WebSocket 连回 /v1/agent;
// 页面维护名称与 key 的生命周期(创建回显、重置回显),在线状态由通道会话维护。
import { useCallback, useEffect, useState } from 'react';
import { Alert, App, Button, Card, Drawer, Flex, Form, Grid, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { create, getList, remove, update } from '@/services/api';
import { create, getList, gatewayAction, gatewayTasks, remove, update } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } from '@/utils/table';
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 HealthCell({ status, reason, checkedAt }: { status?: string; reason?: string; checkedAt?: string }) {
const label = status === 'healthy' ? '在线' : status === 'unhealthy' ? '离线' : '未检查';
const color = status === 'healthy' ? 'success' : status === 'unhealthy' ? 'error' : 'default';
const details = [
checkedAt ? `最近检查:${dateTime(checkedAt)}` : null,
reason || null,
].filter(Boolean).join(';');
// 在线状态:会话维护 online/last_seen_at;从未连接不冒充离线。
function OnlineCell({ online, lastSeenAt }: { online: boolean; lastSeenAt?: string | null }) {
const [label, color] = online
? ['在线', 'success']
: lastSeenAt
? ['离线', 'error']
: ['从未连接', 'default'];
return (
<Tooltip title={details || '尚未执行健康检查'}>
<Tooltip title={online ? undefined : lastSeenAt ? `最近在线:${dateTime(lastSeenAt)}` : '网关尚未建立出站连接'}>
<Tag color={color}>{label}</Tag>
</Tooltip>
);
}
function AccessKeyCell({ accessKey, name }: { accessKey: string; name: string }) {
const [visible, setVisible] = useState(false);
return (
<Space size={0}>
<Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>
{visible ? accessKey : '••••••••••••'}
</Typography.Text>
<Button
type="text"
size="small"
aria-label={visible ? `隐藏 ${name} 的 access key` : `显示 ${name} 的 access key`}
icon={visible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => setVisible((current) => !current)}
/>
{visible ? (
<Button
type="text"
size="small"
aria-label={`复制 ${name} 的 access key`}
icon={<CopyOutlined />}
onClick={() => navigator.clipboard?.writeText(accessKey)}
/>
) : null}
</Space>
);
}
function KeyGuideAlert({ name, accessKey, onClose }: { name: string; accessKey: string; onClose: () => void }) {
const guide = [
`CREATOR_PLATFORM_URL=<平台地址>`,
`CREATOR_GATEWAY_KEY=${accessKey}`,
].join('\n');
return (
<Alert
type="success"
showIcon
closable={{ onClose }}
style={{ marginBottom: 16 }}
title={`网关 ${name} 的 access key 已生成,只在本次显示`}
description={
<Flex vertical gap={4}>
<Typography.Text>在网关主机的环境变量中配置后启动网关,网关会主动连入平台:</Typography.Text>
<Typography.Paragraph code copyable={{ text: guide, tooltips: false }} style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
{guide}
</Typography.Paragraph>
<Typography.Text type="secondary">旧 key 立即失效;网关重启后以新 key 重新连入。</Typography.Text>
</Flex>
}
/>
);
}
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<boolean>;
onSubmit: (values: { name: string }) => Promise<boolean>;
}) {
const editing = initial !== null;
const [form] = Form.useForm<{ name: string; endpoint: string; token?: string }>();
const [form] = Form.useForm<{ name: string }>();
useEffect(() => {
if (open) form.setFieldsValue(editing ? { name: initial.name, endpoint: initial.endpoint, token: '' } : { name: '', endpoint: '', token: '' });
if (open) form.setFieldsValue(editing ? { name: initial.name } : { name: '' });
}, [open, initial, editing, form]);
return (
<Modal open={open} title={editing ? '编辑网关' : '注册网关'} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '注册中…') : editing ? '保存' : '注册网关'}>
{error ? <Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} /> : null}
<Modal open={open} title={editing ? '编辑网关' : '创建网关'} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '创建中…') : editing ? '保存' : '创建网关'}>
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form.Item
name="name"
@@ -61,31 +109,91 @@ function GatewayFormModal({
>
<Input maxLength={64} placeholder="如 gw-main" />
</Form.Item>
<Form.Item
name="endpoint"
label="Endpoint"
required
rules={[
{ required: true, message: '请输入 Endpoint' },
{ pattern: /^https?:\/\/\S+$/, message: '须为 http(s) URL' },
]}
extra="网关进程的可访问地址"
>
<Input placeholder="http://127.0.0.1:8081" />
</Form.Item>
<Form.Item
name="token"
label={editing ? '令牌(留空保持不变)' : '令牌(可选)'}
rules={[{ pattern: tokenPattern, message: '至少 16 个字符,字母、数字与 . _ -' }]}
extra={editing ? '填写时须与网关 GATEWAY_TOKEN 一致' : '填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成'}
>
<Input maxLength={128} placeholder={editing ? '留空保持现有令牌' : '留空由平台生成'} />
</Form.Item>
</Form>
</Modal>
);
}
const taskStatusMeta: Record<string, { label: string; color: string }> = {
pending: { label: '待下发', color: 'default' },
dispatched: { label: '已下发', color: 'processing' },
succeeded: { label: '成功', color: 'success' },
failed: { label: '失败', color: 'error' },
};
function TaskHistoryDrawer({ gateway, onClose }: { gateway: any | null; onClose: () => void }) {
const [tasks, setTasks] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
const [pending, setPending] = useState(false);
const [error, setError] = useState<any>(null);
const load = useCallback(async (targetPage: number) => {
if (!gateway) return;
setPending(true);
setError(null);
try {
const result = await gatewayTasks(gateway.name, targetPage, pageSize);
setTasks(result.data ?? []);
setTotal(result.total ?? 0);
setPage(targetPage);
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, [gateway]);
useEffect(() => {
if (gateway) load(1);
}, [gateway, load]);
const columns: ColumnsType<any> = [
{ title: '时间', dataIndex: 'created_at', width: 160, render: (value: string) => dateTime(value) },
{ title: '方法', dataIndex: 'method', width: 80, render: (value: string) => <Typography.Text code>{value}</Typography.Text> },
{ title: '路径', dataIndex: 'path', ellipsis: true, render: (value: string) => <Typography.Text code style={{ fontSize: 12 }}>{value}</Typography.Text> },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: string, task) => {
const meta = taskStatusMeta[value] ?? { label: value, color: 'default' };
return <Tooltip title={task.error || undefined}><Tag color={meta.color}>{meta.label}</Tag></Tooltip>;
},
},
{ title: '响应码', dataIndex: 'response_status', width: 80, render: (value: number) => (value ? value : '—') },
{
title: '耗时',
key: 'duration',
width: 100,
render: (_, task) => {
if (!task.dispatched_at || !task.finished_at) return '—';
const ms = new Date(task.finished_at).getTime() - new Date(task.dispatched_at).getTime();
return Number.isFinite(ms) && ms >= 0 ? `${ms} ms` : '—';
},
},
{ title: '错误', dataIndex: 'error', ellipsis: true, render: (value: string) => value || '—' },
];
return (
<Drawer open={gateway !== null} size="large" onClose={onClose} title={`网关 ${gateway?.name ?? ''} 任务记录`}>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={() => load(page)}>重试</Button>} />
) : null}
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={tasks}
loading={pending}
pagination={{ current: page, pageSize, total, onChange: (next) => load(next), showSizeChanger: false }}
locale={{ emptyText: '暂无任务记录' }}
/>
</Drawer>
);
}
export default function Page() {
const [gateways, setGateways] = useState<any[]>([]);
const [pending, setPending] = useState(true);
@@ -94,11 +202,12 @@ export default function Page() {
const [editing, setEditing] = useState<any | null>(null);
const [busy, setBusy] = useState('');
const [created, setCreated] = useState<any>(null);
const [resetKey, setResetKey] = useState<any>(null);
const [tasksGateway, setTasksGateway] = useState<any | null>(null);
const { message: messageApi } = App.useApp();
const horizontal = !Grid.useBreakpoint().xl;
const sticky = useTableSticky();
const pagination = useTablePagination();
const healthReloadTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const load = useCallback(async () => {
setPending(true);
@@ -113,41 +222,32 @@ export default function Page() {
}
}, []);
// 创建/编辑后的立即探活在后台异步进行:延迟刷新一次让健康状态列尽快显示最新结果。
const scheduleHealthReload = useCallback(() => {
clearTimeout(healthReloadTimer.current);
healthReloadTimer.current = setTimeout(load, 2500);
}, [load]);
useEffect(() => () => clearTimeout(healthReloadTimer.current), []);
useEffect(() => {
load();
}, [load]);
async function createGateway(values: { name: string; endpoint: string; token?: string }): Promise<boolean> {
async function createGateway(values: { name: string }): Promise<boolean> {
setBusy('create');
try {
const record = await create('gateways', values);
setCreated(record?.data ?? record);
await load();
scheduleHealthReload();
setCreateOpen(false);
return true;
} catch (reason) {
messageApi.error(conflictMessage(reason, '网关注册失败;表单内容已保留。'));
messageApi.error(conflictMessage(reason, '网关创建失败;表单内容已保留。'));
return false;
} finally {
setBusy('');
}
}
async function updateGateway(values: { name: string; endpoint: string; token?: string }): Promise<boolean> {
async function updateGateway(values: { name: string }): Promise<boolean> {
if (!editing) return false;
setBusy('edit');
try {
await update('gateways', editing.name, values);
await load();
scheduleHealthReload();
setEditing(null);
return true;
} catch (reason) {
@@ -158,6 +258,19 @@ export default function Page() {
}
}
async function resetGatewayKey(gateway: any) {
setBusy(gateway.name);
try {
const record = await gatewayAction(gateway.name, 'reset-key');
setResetKey(record?.data ?? record);
await load();
} catch (reason) {
messageApi.error(conflictMessage(reason, '网关 key 重置失败'));
} finally {
setBusy('');
}
}
async function removeGateway(gateway: any) {
setBusy(gateway.name);
try {
@@ -172,17 +285,31 @@ export default function Page() {
const columns: ColumnsType<any> = [
{ title: '名称', dataIndex: 'name', ...fixedLeft<any>({}), render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
{ title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => <Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>{value}</Typography.Text> },
{
title: '健康状态',
dataIndex: 'health_status',
title: '在线状态',
key: 'online',
width: 110,
render: (_, gateway) => <HealthCell status={gateway.health_status} reason={gateway.last_check_reason} checkedAt={gateway.last_checked_at} />,
render: (_, gateway) => <OnlineCell online={Boolean(gateway.online)} lastSeenAt={gateway.last_seen_at} />,
},
{ title: '版本', dataIndex: 'version', width: 140, render: (value: string) => (value ? <Typography.Text code style={{ fontSize: 12 }}>{value}</Typography.Text> : '—') },
{
title: '任务队列',
dataIndex: 'pending_tasks',
width: 90,
align: 'right',
render: (value: number, gateway) => (
<Tooltip title="等待下发给网关的异步任务数">
<Button type="link" size="small" style={{ paddingInline: 0 }} disabled={busy === gateway.name} onClick={() => setTasksGateway(gateway)}>
{value}
</Button>
</Tooltip>
),
},
{
title: '令牌',
dataIndex: 'token',
render: (token: string, gateway) => <TokenCell token={token} name={gateway.name} /> },
title: 'Access Key',
dataIndex: 'access_key',
render: (accessKey: string, gateway) => <AccessKeyCell accessKey={accessKey} name={gateway.name} />,
},
{
title: '操作',
key: 'actions',
@@ -190,6 +317,14 @@ export default function Page() {
...fixedRight<any>({}),
render: (_, gateway) => (
<Space>
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setTasksGateway(gateway)}>
任务记录
</Button>
<Popconfirm title={`重置 ${gateway.name} 的 access key?`} description="旧 key 立即失效,网关需重新配置。">
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => resetGatewayKey(gateway)}>
重置 key
</Button>
</Popconfirm>
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setEditing(gateway)}>
编辑
</Button>
@@ -206,42 +341,25 @@ export default function Page() {
<Card variant="outlined">
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">共 {gateways.length} 个网关</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
注册网关
</Button>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
创建网关
</Button>
</Space>
</Flex>
{created ? (
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
title={
<Space size={4}>
<span>网关 {created.name} 已注册。令牌</span>
<Typography.Text code>{created.token}</Typography.Text>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
aria-label={`复制 ${created.token}`}
onClick={() => navigator.clipboard?.writeText(created.token)}
/>
<Button size="small" onClick={() => setCreated(null)}>
知道了
</Button>
</Space>
}
/>
) : null}
{created ? <KeyGuideAlert name={created.name} accessKey={created.access_key} onClose={() => setCreated(null)} /> : null}
{resetKey ? <KeyGuideAlert name={resetKey.name} accessKey={resetKey.access_key} onClose={() => setResetKey(null)} /> : null}
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 960 : undefined }} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 1024 : undefined }} locale={{ emptyText: '暂无网关;创建后把签发的 key 配置到网关主机即可连入。' }} />
<GatewayFormModal
open={createOpen}
initial={null}
busy={busy === 'create'}
error={null}
onCancel={() => {
setCreateOpen(false);
}}
@@ -251,37 +369,10 @@ export default function Page() {
open={editing !== null}
initial={editing}
busy={busy === 'edit'}
error={null}
onCancel={() => setEditing(null)}
onSubmit={updateGateway}
/>
<TaskHistoryDrawer gateway={tasksGateway} onClose={() => setTasksGateway(null)} />
</Card>
);
}
function TokenCell({ token, name }: { token: string; name: string }) {
const [visible, setVisible] = useState(false);
return (
<Space size={0}>
<Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>
{visible ? token : '••••••••••••'}
</Typography.Text>
<Button
type="text"
size="small"
aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`}
icon={visible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => setVisible((current) => !current)}
/>
{visible ? (
<Button
type="text"
size="small"
aria-label={`复制 ${name} 的令牌`}
icon={<CopyOutlined />}
onClick={() => navigator.clipboard?.writeText(token)}
/>
) : null}
</Space>
);
}
+12 -1
View File
@@ -73,7 +73,7 @@ export async function update(resource: Resource, id: string | number, variables:
}
export async function remove(resource: Resource, id: string | number): Promise<void> {
if (!['accounts', 'creator-competitors'].includes(resource)) throw new Error(`${resource} 不支持 deleteOne`);
if (!['accounts', 'creator-competitors', 'gateways'].includes(resource)) throw new Error(`${resource} 不支持 deleteOne`);
await request(`${resourcePaths[resource]}/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
@@ -89,6 +89,17 @@ export async function networkExitAction(id: string, action: 'check' | 'disable'
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' });
}
// 网关领域动作:重置 access key(旧 key 立即失效);任务记录为独立分页查询。
export async function gatewayAction(name: string, action: 'reset-key'): Promise<any> {
if (action !== 'reset-key') throw new Error(`未知网关操作: ${action}`);
return request(`/gateways/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
}
export async function gatewayTasks(name: string, page = 1, pageSize = 10): Promise<{ data: any[]; total: number }> {
const query = new URLSearchParams({ page: `${page}`, page_size: `${pageSize}` });
return request(`/gateways/${encodeURIComponent(name)}/tasks?${query}`);
}
// creator-* 领域的透传请求(路径由调用方给出,含列表页/actions)。
export function creatorRequest(path: string, options?: RequestInit) {
return request(path, options);
+4 -5
View File
@@ -15,7 +15,6 @@ const cases = [
'pages/network-exits/index.tsx',
'pages/network-exits/$id/index.tsx',
'pages/accounts/import.tsx',
'pages/creator/events/index.tsx',
'pages/creator/messages/index.tsx',
];
@@ -99,10 +98,10 @@ function renderField(file, index, bindings) {
}
for (const visible of [false, true]) {
test(`gateway token hover respects visible=${visible}`, () => {
const token = 'complete-gateway-token'.repeat(20);
const field = renderField('pages/gateways/index.tsx', 1, { token, visible });
assert.equal(field.props.children, visible ? token : '••••••••••••');
test(`gateway access key respects visible=${visible}`, () => {
const accessKey = 'complete-gateway-key'.repeat(20);
const field = renderField('pages/gateways/index.tsx', 0, { accessKey, visible });
assert.equal(field.props.children, visible ? accessKey : '••••••••••••');
assert.equal(field.props.ellipsis.tooltip, true);
});
}
+16 -20
View File
@@ -3,7 +3,7 @@ const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const ts = require('typescript');
// 网关健康状态列:后台定时探活只落最近一次结果,列表直接展示,不保留历史。
// 网关在线状态列:出站 WS 会话维护 online/last_seen_at;从未连接与离线不得混用。
const source = ts.createSourceFile(
'gateways-page',
readFileSync(`${__dirname}/../src/pages/gateways/index.tsx`, 'utf8'),
@@ -13,28 +13,24 @@ const source = ts.createSourceFile(
);
const code = source.getFullText();
test('网关列表展示最近一次健康检查结果', () => {
assert.match(code, /健康状态/, '应有健康状态列');
assert.match(code, /dataIndex: 'health_status'/, '应绑定 health_status 字段');
assert.match(code, /gateway\.last_check_reason/, '应展示最近一次检查原因');
assert.match(code, /gateway\.last_checked_at/, '应展示最近一次检查时间');
assert.match(code, /online|在线/, '健康应展示为在线');
assert.match(code, /offline|离线/, '不健康应展示为离线');
test('网关列表展示会话维护的在线状态', () => {
assert.match(code, /在线状态/, '应有在线状态列');
assert.match(code, /online: boolean; lastSeenAt/, '应绑定 online 与 last_seen_at');
assert.match(code, /\['在线', 'success'\]/, '在线展示 success');
assert.match(code, /\['离线', 'error'\]/, '离线展示 error');
assert.match(code, /最近在线:/, '离线时在 Tooltip 展示最近在线时间');
});
test('健康状态未检查时不误报异常', () => {
// 未检查(unchecked)必须落在独立的静默分支,不允许与离线混用同一个颜色/文案。
const render = code.match(/function HealthCell[\s\S]*?\n}/);
assert.ok(render, '应有独立的 HealthCell 组件');
assert.match(render[0], /未检查/, '应存在未检查文案');
const branches = render[0].match(/status === '[a-z]+'/g) ?? [];
assert.ok(branches.includes("status === 'healthy'"), '应判断 healthy');
assert.ok(branches.includes("status === 'unhealthy'"), '应判断 unhealthy');
test('从未连接不冒充离线', () => {
const render = code.match(/function OnlineCell[\s\S]*?\n}/);
assert.ok(render, '应有独立的 OnlineCell 组件');
assert.match(render[0], /从未连接/, '应存在从未连接文案');
assert.match(render[0], /lastSeenAt\s*\?\s*\['离线', 'error'\]\s*:\s*\['从未连接', 'default'\]/, '无 last_seen_at 时落在从未连接静默分支');
});
test('检查原因与时间收进 Tooltip,不挤占表格密度', () => {
const render = code.match(/function HealthCell[\s\S]*?\n}/);
assert.ok(render, '应有独立的 HealthCell 组件');
assert.match(render[0], /Tooltip/, '原因与时间应放 Tooltip');
test('在线细节收进 Tooltip,不挤占表格密度', () => {
const render = code.match(/function OnlineCell[\s\S]*?\n}/);
assert.ok(render, '应有独立的 OnlineCell 组件');
assert.match(render[0], /Tooltip/, '细节应放 Tooltip');
assert.doesNotMatch(render[0], /Typography\.Text type="secondary"/, '不应在单元格内平铺长文本');
});
+68
View File
@@ -0,0 +1,68 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const ts = require('typescript');
const source = ts.createSourceFile(
'gateways.tsx',
readFileSync(`${__dirname}/../src/pages/gateways/index.tsx`, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const code = source.getFullText();
test('创建网关只需名称,access key 由平台生成', () => {
assert.ok(!code.includes('Endpoint'), '出站模型不再登记 endpoint');
assert.ok(!code.includes('GATEWAY_TOKEN'), '旧令牌语义已删除');
assert.ok(!code.includes('token'), '不应残留 token 字段');
const formItems = [...code.matchAll(/<Form\.Item[\s\S]*?name="(\w+)"/g)].map(match => match[1]);
assert.deepEqual(formItems, ['name'], '创建/编辑表单只含名称');
assert.match(code, /const record = await create\('gateways', values\);/);
assert.match(code, /setCreated\(record\?\.data \?\? record\);/);
});
test('key 生成后回显配置指引且只在本次显示', () => {
assert.match(code, /KeyGuideAlert/);
assert.match(code, /CREATOR_PLATFORM_URL=/);
assert.match(code, /CREATOR_GATEWAY_KEY=\$\{accessKey\}/);
assert.match(code, /只在本次显示/);
assert.match(code, /旧 key 立即失效/);
assert.match(code, /copyable=\{\{ text: guide/);
});
test('在线状态区分在线、离线与从未连接', () => {
assert.match(code, /\['在线', 'success'\]/);
assert.match(code, /\['离线', 'error'\]/);
assert.match(code, /\['从未连接', 'default'\]/);
assert.match(code, /online: boolean; lastSeenAt\?/);
assert.match(code, /最近在线:/);
});
test('任务记录 Drawer 展示方法路径状态与耗时,分页请求真实接口', () => {
assert.match(code, /TaskHistoryDrawer/);
assert.match(code, /await gatewayTasks\(gateway\.name, targetPage, pageSize\);/);
assert.match(code, /taskStatusMeta/);
assert.ok(taskStatusMetaSrc.includes('pending') && taskStatusMetaSrc.includes('dispatched') && taskStatusMetaSrc.includes('succeeded') && taskStatusMetaSrc.includes('failed'));
assert.match(code, /dataIndex: 'response_status'/);
assert.match(code, /dispatched_at/);
assert.match(code, /finished_at/);
});
test('任务队列列与重置 key 动作接入真实接口', () => {
assert.match(code, /dataIndex: 'pending_tasks'/);
assert.match(code, /await gatewayAction\(gateway\.name, 'reset-key'\);/);
assert.match(code, /旧 key 立即失效,网关需重新配置/);
assert.match(code, /setTasksGateway\(gateway\)/);
});
test('弃用 API 不回潮', () => {
assert.ok(!code.includes('Space direction'), '纵向堆叠用 Flex vertical');
assert.ok(!code.includes('Alert message='), 'Alert 用 title/description');
assert.match(code, /<Flex vertical gap=\{4\}>/);
});
const taskStatusMetaSrc = code.slice(
Math.max(0, code.indexOf('const taskStatusMeta')),
code.indexOf('function TaskHistoryDrawer'),
);
+2 -2
View File
@@ -10,7 +10,7 @@ const files = [
['src/components/accounts/AccountManagementList.tsx', 'max-content'],
['src/pages/accounts/import.tsx', 1000],
['src/pages/network-exits/index.tsx', 1100],
['src/pages/gateways/index.tsx', 960],
['src/pages/gateways/index.tsx', 1024],
];
for (const [file, width] of files) {
@@ -24,7 +24,7 @@ for (const [file, width] of files) {
const code = source.getFullText();
const tables = [];
function visit(node) {
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Table') tables.push(node);
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Table' && node.attributes.properties.some(prop => prop.name?.getText(source) === 'sticky')) tables.push(node);
ts.forEachChild(node, visit);
}
visit(source);