Files
mcp-auth/frontend/src/pages/Services/index.tsx
T
2026-09-02 17:31:52 +08:00

286 lines
7.7 KiB
TypeScript
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 {
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>
);
}