fix: 三端联调测试 2 P1 + 2 P2 + 4 P3 修复
P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额 P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens) P2-09: relay failover 成功后记录 tokens 并标记 completed P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量 P2-14: super_admin 合成 subscription + check_quota 放行 P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由 P3-15: antd destroyOnClose → destroyOnHidden (3处) P3-16: ProTable onSearch → onSubmit (2处)
This commit is contained in:
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
|
||||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { apiKeyService } from '@/services/api-keys'
|
||||
import type { TokenInfo } from '@/types'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ label: 'Relay Chat', value: 'relay:use' },
|
||||
{ label: 'Knowledge Read', value: 'knowledge:read' },
|
||||
{ label: 'Knowledge Write', value: 'knowledge:write' },
|
||||
{ label: 'Agent Read', value: 'agent:read' },
|
||||
{ label: 'Agent Write', value: 'agent:write' },
|
||||
]
|
||||
|
||||
export default function ApiKeys() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['api-keys', page, pageSize],
|
||||
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
|
||||
apiKeyService.create(values),
|
||||
onSuccess: (result: TokenInfo) => {
|
||||
message.success('API 密钥创建成功')
|
||||
if (result.token) {
|
||||
setNewToken(result.token)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||||
onSuccess: () => {
|
||||
message.success('密钥已吊销')
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '吊销失败'),
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
createMutation.mutate(values)
|
||||
}
|
||||
|
||||
const columns: ProColumns<TokenInfo>[] = [
|
||||
{ title: '名称', dataIndex: 'name', width: 180 },
|
||||
{
|
||||
title: '前缀',
|
||||
dataIndex: 'token_prefix',
|
||||
width: 120,
|
||||
render: (val: string) => <Text code>{val}...</Text>,
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
width: 240,
|
||||
render: (perms: string[]) =>
|
||||
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'last_used_at',
|
||||
width: 180,
|
||||
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">从未使用</Text>),
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expires_at',
|
||||
width: 180,
|
||||
render: (val: string) =>
|
||||
val ? new Date(val).toLocaleString() : <Text type="secondary">永不过期</Text>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: unknown, record: TokenInfo) => (
|
||||
<Popconfirm
|
||||
title="确定吊销此密钥?"
|
||||
description="吊销后使用该密钥的所有请求将被拒绝"
|
||||
onConfirm={() => revokeMutation.mutate(record.id)}
|
||||
>
|
||||
<Button danger size="small">吊销</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<ProTable<TokenInfo>
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建密钥
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建 API 密钥"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
|
||||
confirmLoading={createMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
{newToken ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Paragraph type="warning">
|
||||
请立即复制密钥,关闭后将无法再次查看。
|
||||
</Paragraph>
|
||||
<Space>
|
||||
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
size="small"
|
||||
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如: 生产环境 API Key" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expires_days" label="有效期 (天)">
|
||||
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
|
||||
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ function IndustryListPanel() {
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -225,7 +225,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={updateMutation.isPending}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spin /></div>
|
||||
@@ -300,7 +300,7 @@ function IndustryCreateModal({ open, onClose }: {
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -333,7 +333,7 @@ function ItemsPanel() {
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
|
||||
@@ -327,7 +327,7 @@ export default function ScheduledTasks() {
|
||||
onCancel={closeModal}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
|
||||
@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
||||
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||
|
||||
Reference in New Issue
Block a user