fix: M4 audit — add DELETE decisions, PATCH user-agreements, regular user list endpoint
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s
- ReleaseController: add DELETE decisions/:id - ComplianceController: add PATCH user-agreements/:id - AdminUsersMgmtController: add GET /admin-api/users (regular user listing with search) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
050fb554f0
commit
7e5ec80456
@ -58,11 +58,11 @@ export default function Dashboard() {
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="总用户数" value={stats?.totalUsers} loading={statsLoading} prefix={<UserOutlined />} trend="up" trendValue={`+${stats?.newUsersToday ?? 0}`} trendLabel="今日新增" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="知识库总数" value={stats?.totalKnowledgeBases} loading={statsLoading} prefix={<BookOutlined />} trend="up" trendValue={`+${stats?.newKbsToday ?? 0}`} trendLabel="今日新增" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="今日 AI 调用" value={stats?.totalAiCallsToday} loading={statsLoading} prefix={<CloudOutlined />} /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="今日 AI 调用" value={stats?.todayAiCalls} loading={statsLoading} prefix={<CloudOutlined />} /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="文件存储" value={stats ? formatStorage(stats.totalStorageBytes) : undefined} loading={statsLoading} prefix={<FileOutlined />} suffix={`${stats?.totalFiles ?? 0} 个文件`} /></Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="今日 AI 成本" value={stats?.todayAiCost != null ? `¥${stats.todayAiCost.toFixed(2)}` : undefined} loading={statsLoading} prefix={<DollarOutlined />} trend="up" trendValue={`${stats?.totalAiCallsToday ?? 0} 次`} trendLabel="今日调用" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="今日 AI 成本" value={stats?.todayAiCost != null ? `¥${stats.todayAiCost.toFixed(2)}` : undefined} loading={statsLoading} prefix={<DollarOutlined />} trend="up" trendValue={`${stats?.todayAiCalls ?? 0} 次`} trendLabel="今日调用" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="今日导入任务" value={stats?.todayImportCount ?? 0} loading={statsLoading} prefix={<ImportOutlined />} trend={stats?.failedImportCount ? 'down' : 'up'} trendValue={stats?.failedImportCount ? `${stats.failedImportCount} 失败` : '全部成功'} trendLabel="状态" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="近期失败任务" value={stats?.failedTasks ?? 0} loading={statsLoading} prefix={<AlertOutlined />} trend={stats?.failedTasks ? 'down' : 'up'} trendValue={stats?.failedTasks ? '需要关注' : '无异常'} trendLabel="7 天内" /></Col>
|
||||
<Col xs={12} sm={12} lg={6}><MetricCard title="即将到期密钥" value={stats?.upcomingExpirations ?? 0} loading={statsLoading} prefix={<SafetyOutlined />} trend={stats?.upcomingExpirations ? 'down' : 'up'} trendValue="30 天内" trendLabel="到期" /></Col>
|
||||
|
||||
@ -4,6 +4,7 @@ import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/services/http-client'
|
||||
import type { NotificationTemplate, NotificationLog } from '@/types/api'
|
||||
import { BellOutlined, SendOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@ -115,7 +116,7 @@ export default function NotificationAdmin() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="发送日志(最近 50 条)">
|
||||
<Card title="发送日志(最近 50 条)" style={{ marginBottom: 24 }}>
|
||||
<Table
|
||||
dataSource={sendLogs || []}
|
||||
columns={logColumns}
|
||||
@ -124,6 +125,8 @@ export default function NotificationAdmin() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AdminNotificationsCard />
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑模板' : '新建模板'}
|
||||
open={modalOpen}
|
||||
@ -156,3 +159,44 @@ export default function NotificationAdmin() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminNotificationsCard() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: adminNotifs } = useQuery({
|
||||
queryKey: ['admin', 'admin-notifications'],
|
||||
queryFn: (): Promise<any[]> => api.get('/admin-api/admin-notifications').then(d => d ?? []),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const sendAlert = useMutation({
|
||||
mutationFn: (body: { type: string; title: string; content?: string }) =>
|
||||
api.post('/admin-api/admin-notifications/send', body),
|
||||
onSuccess: () => { message.success('通知已发送'); qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }) },
|
||||
})
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/admin-api/admin-notifications/${id}/read`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (t: string) => <Tag color={t === 'cost_alert' ? 'red' : t === 'import_failure' ? 'orange' : t === 'key_expiring' ? 'gold' : 'blue'}>{t}</Tag> },
|
||||
{ title: '标题', dataIndex: 'title', width: 250, ellipsis: true },
|
||||
{ title: '内容', dataIndex: 'content', width: 200, ellipsis: true, render: (c: string) => c || '-' },
|
||||
{ title: '已读', dataIndex: 'readAt', width: 70, render: (d: string | null) => d ? <Tag color="green">是</Tag> : <Tag color="red">否</Tag> },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 120, render: (d: string) => new Date(d).toLocaleString() },
|
||||
{ title: '操作', width: 80, render: (_: any, r: any) => !r.readAt ? <Button size="small" onClick={() => markRead.mutate(r.id)}>标记已读</Button> : null },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card title={<><BellOutlined /> Admin 通知</>} extra={
|
||||
<Space>
|
||||
<Button size="small" icon={<SendOutlined />} onClick={() => sendAlert.mutate({ type: 'admin_manual', title: '手动测试通知', content: '这是一条手动发送的 Admin 通知' })}>发送测试</Button>
|
||||
<Button size="small" icon={<ExclamationCircleOutlined />} danger onClick={() => sendAlert.mutate({ type: 'cost_alert', title: '成本预警测试', content: '当日成本已超过预设阈值' })}>成本预警</Button>
|
||||
</Space>
|
||||
}>
|
||||
<Table dataSource={adminNotifs || []} columns={columns} rowKey="id" size="small" pagination={{ pageSize: 20 }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ export default function ReleaseAdmin() {
|
||||
const [tab, setTab] = useState('changelogs')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [checklistVer, setChecklistVer] = useState('1.0')
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
|
||||
@ -29,7 +30,7 @@ export default function ReleaseAdmin() {
|
||||
|
||||
const { data: checklist } = useQuery({
|
||||
queryKey: ['release', 'checklist'],
|
||||
queryFn: () => api.get<any[]>('/admin-api/release/checklists/v1.0').then(d => d ?? []),
|
||||
queryFn: () => api.get<any[]>(`/admin-api/release/checklists/${checklistVer}`).then(d => d ?? []),
|
||||
enabled: tab === 'checklist',
|
||||
})
|
||||
|
||||
@ -121,6 +122,10 @@ export default function ReleaseAdmin() {
|
||||
key: 'checklist', label: '回归清单',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<span>版本:</span>
|
||||
<Input value={checklistVer} onChange={e => setChecklistVer(e.target.value)} style={{ width: 100 }} placeholder="1.0" />
|
||||
</Space>
|
||||
{(checklist || []).map((item: any) => (
|
||||
<div key={item.id} style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Checkbox checked={item.checked} onChange={e => toggleCheck.mutate({ id: item.id, checked: e.target.checked })}>
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import { PlusOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, DeleteOutlined, KeyOutlined, DollarOutlined, RotateRightOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Tabs, DatePicker } from 'antd'
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Tabs, DatePicker, InputNumber, message } from 'antd'
|
||||
import { api } from '@/services/http-client'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
function SecretsPage() {
|
||||
const { modal, message } = App.useApp()
|
||||
const { modal } = App.useApp()
|
||||
const qc = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [billOpen, setBillOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', provider: 'deepseek', value: '', expiresAt: '' })
|
||||
const [billForm, setBillForm] = useState({ provider: 'deepseek', billMonth: dayjs().format('YYYY-MM'), amount: 0, notes: '' })
|
||||
|
||||
const { data: secrets } = useQuery({ queryKey: ['secrets'], queryFn: (): Promise<any> => api.get('/admin-api/secrets') })
|
||||
const { data: logs } = useQuery({ queryKey: ['secrets', 'logs'], queryFn: (): Promise<any> => api.get('/admin-api/secrets/logs') })
|
||||
const { data: bills } = useQuery({ queryKey: ['vendor', 'bills'], queryFn: (): Promise<any[]> => api.get('/admin-api/vendor/bills').then(d => d ?? []) })
|
||||
|
||||
const addSecret = async () => {
|
||||
await api.post('/admin-api/secrets', form)
|
||||
@ -22,18 +25,45 @@ function SecretsPage() {
|
||||
setAddOpen(false); qc.invalidateQueries({ queryKey: ['secrets'] })
|
||||
}
|
||||
|
||||
const rotateKey = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/admin-api/vendor/secrets/${id}/rotate`),
|
||||
onSuccess: () => { message.success('已轮换'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||
})
|
||||
|
||||
const revokeKey = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/admin-api/vendor/secrets/${id}/revoke`),
|
||||
onSuccess: () => { message.success('已吊销'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||
})
|
||||
|
||||
const deleteSecret = (id: string) => modal.confirm({
|
||||
title: '删除密钥', okType: 'danger',
|
||||
onOk: async () => { await api.delete(`/admin-api/secrets/${id}`); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||
})
|
||||
|
||||
const addBill = async () => {
|
||||
await api.post('/admin-api/vendor/bills', billForm)
|
||||
message.success('账单已录入')
|
||||
setBillOpen(false); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] })
|
||||
}
|
||||
|
||||
const deleteBill = (id: string) => modal.confirm({
|
||||
title: '删除账单', okType: 'danger',
|
||||
onOk: async () => { await api.delete(`/admin-api/vendor/bills/${id}`); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] }) },
|
||||
})
|
||||
|
||||
const secCols = [
|
||||
{ title: '名称', dataIndex: 'name', width: 150 },
|
||||
{ title: '服务商', dataIndex: 'provider', width: 100, render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='siliconflow'?'green':v==='minimax'?'purple':'default'}>{v}</Tag> },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '服务商', dataIndex: 'provider', width: 90, render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='siliconflow'?'green':v==='minimax'?'purple':'default'}>{v}</Tag> },
|
||||
{ title: '末四位', dataIndex: 'maskLast4', width: 80, render: (v: string) => <Text code>****{v}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v==='active'?'green':'red'}>{v}</Tag> },
|
||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v==='active'?'green':v==='expired'?'red':v==='rotated'?'orange':v==='revoked'?'default':v}>{v}</Tag> },
|
||||
{ title: '到期', dataIndex: 'expiresAt', width: 100, render: (d: string) => d ? dayjs(d).format('MM-DD') : <Text type="secondary">永久</Text> },
|
||||
{ title: '操作', width: 80, render: (_:any, r:any) => <Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteSecret(r.id)} /> },
|
||||
{ title: '操作', width: 150, render: (_:any, r:any) => (
|
||||
<>
|
||||
<Button type="link" size="small" icon={<RotateRightOutlined />} onClick={() => rotateKey.mutate(r.id)}>轮换</Button>
|
||||
<Button type="link" size="small" danger icon={<StopOutlined />} onClick={() => revokeKey.mutate(r.id)}>吊销</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteSecret(r.id)} />
|
||||
</>
|
||||
) },
|
||||
]
|
||||
|
||||
const logCols = [
|
||||
@ -42,15 +72,25 @@ function SecretsPage() {
|
||||
{ title: '访问者', dataIndex: 'accessedBy', width: 120 },
|
||||
]
|
||||
|
||||
const billCols = [
|
||||
{ title: '服务商', dataIndex: 'provider', width: 100, render: (v: string) => <Tag color={v==='deepseek'?'blue':v==='siliconflow'?'green':v==='baidu'?'orange':v==='cos'?'cyan':'default'}>{v}</Tag> },
|
||||
{ title: '月份', dataIndex: 'billMonth', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 100, render: (a: string) => `¥${Number(a).toFixed(2)}` },
|
||||
{ title: '币种', dataIndex: 'currency', width: 60 },
|
||||
{ title: '备注', dataIndex: 'notes', width: 150, ellipsis: true, render: (n: string) => n || '-' },
|
||||
{ title: '支付时间', dataIndex: 'paidAt', width: 110, render: (d: string) => d ? dayjs(d).format('YYYY-MM-DD') : <Tag>未付</Tag> },
|
||||
{ title: '操作', width: 60, render: (_:any, r:any) => <Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteBill(r.id)} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={5} style={{ margin: 0 }}><KeyOutlined /> 密钥管理</Title>
|
||||
<Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)}>新增密钥</Button>
|
||||
<Title level={5} style={{ margin: 0 }}><KeyOutlined /> 密钥与供应商</Title>
|
||||
</div>
|
||||
<Tabs items={[
|
||||
{ key: 'keys', label: '密钥列表', children: <Table dataSource={secrets || []} columns={secCols} rowKey="id" pagination={false} size="small" /> },
|
||||
{ key: 'keys', label: '密钥列表', children: <div><Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)} style={{ marginBottom: 12 }}>新增密钥</Button><Table dataSource={secrets || []} columns={secCols} rowKey="id" pagination={false} size="small" /></div> },
|
||||
{ key: 'logs', label: '访问日志', children: <Table dataSource={logs || []} columns={logCols} rowKey="id" pagination={{ pageSize: 20 }} size="small" /> },
|
||||
{ key: 'bills', label: '服务商账单', children: <div><Button icon={<PlusOutlined />} type="primary" onClick={() => setBillOpen(true)} style={{ marginBottom: 12 }}>录入账单</Button><Table dataSource={bills || []} columns={billCols} rowKey="id" pagination={false} size="small" /></div> },
|
||||
]} />
|
||||
<Modal title="新增密钥" open={addOpen} onOk={addSecret} onCancel={() => setAddOpen(false)} okText="添加">
|
||||
<Input placeholder="名称" value={form.name} onChange={e => setForm({...form, name: e.target.value})} style={{ marginBottom: 12 }} />
|
||||
@ -58,6 +98,12 @@ function SecretsPage() {
|
||||
<Input.Password placeholder="Key 值" value={form.value} onChange={e => setForm({...form, value: e.target.value})} style={{ marginBottom: 12 }} />
|
||||
<DatePicker placeholder="到期日期" onChange={d => setForm({...form, expiresAt: d?.toISOString() || ''})} style={{ width: '100%' }} />
|
||||
</Modal>
|
||||
<Modal title="录入账单" open={billOpen} onOk={addBill} onCancel={() => setBillOpen(false)} okText="录入">
|
||||
<Select value={billForm.provider} onChange={v => setBillForm({...billForm, provider: v})} style={{ width: '100%', marginBottom: 12 }} options={[{label:'DeepSeek',value:'deepseek'},{label:'硅基流动',value:'siliconflow'},{label:'MiniMax',value:'minimax'},{label:'百度OCR',value:'baidu'},{label:'腾讯COS',value:'cos'}]} />
|
||||
<Input placeholder="月份 (YYYY-MM)" value={billForm.billMonth} onChange={e => setBillForm({...billForm, billMonth: e.target.value})} style={{ marginBottom: 12 }} />
|
||||
<InputNumber placeholder="金额" value={billForm.amount} onChange={v => setBillForm({...billForm, amount: v || 0})} style={{ width: '100%', marginBottom: 12 }} min={0} step={0.01} />
|
||||
<Input placeholder="备注" value={billForm.notes} onChange={e => setBillForm({...billForm, notes: e.target.value})} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export const MOCK_DASHBOARD_STATS: DashboardStats = {
|
||||
activeUsersToday: 347,
|
||||
totalKnowledgeBases: 892,
|
||||
newKbsToday: 15,
|
||||
totalAiCallsToday: 4521,
|
||||
todayAiCalls: 4521,
|
||||
totalFiles: 3412,
|
||||
totalStorageBytes: 15_728_640_000,
|
||||
userTrend: makeTrend(30, 40, 30),
|
||||
|
||||
@ -23,7 +23,7 @@ export interface DashboardStats {
|
||||
activeUsersToday: number
|
||||
totalKnowledgeBases: number
|
||||
newKbsToday: number
|
||||
totalAiCallsToday: number
|
||||
todayAiCalls: number
|
||||
totalFiles: number
|
||||
totalStorageBytes: number
|
||||
todayImportCount: number
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user