286 lines
7.7 KiB
TypeScript
286 lines
7.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
listServices,
|
||
registerService,
|
||
revokeService,
|
||
enableService,
|
||
deleteService,
|
||
type ServiceCreateResult,
|
||
type ServiceRow,
|
||
} from '../../api';
|
||
|
||
const STATUS_COLOR: Record<string, string> = {
|
||
active: 'green',
|
||
revoked: 'red',
|
||
};
|
||
|
||
export default function Services() {
|
||
const [rows, setRows] = useState<ServiceRow[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [created, setCreated] = useState<ServiceCreateResult | null>(null);
|
||
const [form] = Form.useForm();
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await listServices();
|
||
setRows(res.services);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
const onRevoke = async (id: number) => {
|
||
Modal.confirm({
|
||
title: '吊销此服务的 API Key?',
|
||
content: '吊销后该 MCP 服务将无法调用鉴权 API。吊销后可执行删除操作。',
|
||
okText: '吊销',
|
||
okType: 'danger',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await revokeService(id);
|
||
message.success('已吊销');
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.detail || '吊销失败');
|
||
} finally {
|
||
load();
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const onEnable = async (id: number) => {
|
||
await enableService(id);
|
||
message.success('已启用');
|
||
load();
|
||
};
|
||
|
||
const onDelete = async (id: number) => {
|
||
Modal.confirm({
|
||
title: '删除此服务?',
|
||
content: '删除后记录将永久移除,无法恢复。',
|
||
okText: '删除',
|
||
okType: 'danger',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await deleteService(id);
|
||
message.success('已删除');
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.detail || '删除失败');
|
||
} finally {
|
||
load();
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const onCreate = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
const res = await registerService({
|
||
service_name: values.service_name,
|
||
service_url: values.service_url,
|
||
description: values.description,
|
||
});
|
||
setCreated(res);
|
||
setCreateOpen(false);
|
||
form.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
if (e?.response?.data?.detail) {
|
||
message.error(e.response.data.detail);
|
||
} else if (!e?.errorFields) {
|
||
message.error('注册失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
const onCopy = (text: string) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
message.success('已复制');
|
||
});
|
||
};
|
||
|
||
const columns: ColumnsType<ServiceRow> = [
|
||
{
|
||
title: 'API Key',
|
||
dataIndex: 'api_key',
|
||
key: 'api_key',
|
||
width: 400,
|
||
render: (key: string) => (
|
||
<Space size="small">
|
||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||
{key}
|
||
</Typography.Text>
|
||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(key)} />
|
||
</Space>
|
||
),
|
||
},
|
||
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 150, ellipsis: true },
|
||
{
|
||
title: 'MCP服务地址',
|
||
dataIndex: 'service_url',
|
||
key: 'service_url',
|
||
width: 280,
|
||
ellipsis: true,
|
||
render: (u: string) => (
|
||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||
{u}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 90,
|
||
render: (s: string) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>,
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'created_at',
|
||
key: 'created_at',
|
||
width: 160,
|
||
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'),
|
||
},
|
||
{
|
||
title: '最近调用',
|
||
dataIndex: 'last_used_at',
|
||
key: 'last_used_at',
|
||
width: 160,
|
||
ellipsis: true,
|
||
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 180,
|
||
fixed: 'right',
|
||
render: (_, row: ServiceRow) =>
|
||
row.status === 'active' ? (
|
||
<Space size="small">
|
||
<Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}>
|
||
吊销
|
||
</Button>
|
||
<Button type="link" size="small" disabled>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
) : row.status === 'revoked' ? (
|
||
<Space size="small">
|
||
<Button type="link" size="small" onClick={() => onEnable(row.service_id)}>
|
||
启用
|
||
</Button>
|
||
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
) : (
|
||
<Typography.Text type="secondary">-</Typography.Text>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ marginBottom: 16 }}>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||
注册服务
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
columns={columns}
|
||
dataSource={rows}
|
||
rowKey="service_id"
|
||
loading={loading}
|
||
pagination={{ pageSize: 15 }}
|
||
tableLayout="fixed"
|
||
scroll={{ x: 1540 }}
|
||
/>
|
||
|
||
{/* 注册服务表单 */}
|
||
<Modal
|
||
title="注册 MCP 服务"
|
||
open={createOpen}
|
||
onOk={onCreate}
|
||
onCancel={() => setCreateOpen(false)}
|
||
okText="注册"
|
||
cancelText="取消"
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="service_name"
|
||
label="服务标识"
|
||
rules={[{ required: true, message: '请输入服务标识' }]}
|
||
>
|
||
<Input placeholder="如 erp / crm" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="service_url"
|
||
label="MCP 服务地址"
|
||
rules={[{ required: true, message: '请输入 MCP 服务地址' }]}
|
||
>
|
||
<Input placeholder="如 http://10.100.154.100:8001/mcp" />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="说明">
|
||
<Input.TextArea rows={2} placeholder="服务用途说明" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* 注册结果:API Key */}
|
||
<Modal
|
||
title="API Key 已生成"
|
||
open={created !== null}
|
||
onCancel={() => setCreated(null)}
|
||
footer={<Button type="primary" onClick={() => setCreated(null)}>关闭</Button>}
|
||
>
|
||
<Typography.Paragraph type="secondary">
|
||
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量:
|
||
</Typography.Paragraph>
|
||
<Space.Compact style={{ width: '100%' }}>
|
||
<Input.TextArea
|
||
rows={2}
|
||
value={created?.api_key || ''}
|
||
readOnly
|
||
style={{ fontFamily: 'monospace' }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<CopyOutlined />}
|
||
onClick={() => created?.api_key && onCopy(created.api_key)}
|
||
>
|
||
复制
|
||
</Button>
|
||
</Space.Compact>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|