feat: M0-03 config management admin page
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s

This commit is contained in:
WangDL 2026-05-22 22:37:28 +08:00
parent 13834af55d
commit a5483cf37f
5 changed files with 107 additions and 0 deletions

View File

@ -100,6 +100,10 @@ function App() {
<Suspense fallback={<PageLoading />}><GiteaEmbed /></Suspense> <Suspense fallback={<PageLoading />}><GiteaEmbed /></Suspense>
} }
/> />
<Route
path="config"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><ConfigPage /></Suspense></PermissionGuard>}
/>
<Route <Route
path="events" path="events"
element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>} element={<PermissionGuard requiredRole="SUPER_ADMIN"><Suspense fallback={<PageLoading />}><EventsPage /></Suspense></PermissionGuard>}

View File

@ -30,6 +30,7 @@ export const adminMenuItems: AdminMenuItem[] = [
{ path: '/billing', name: 'API 用量', icon: <DollarOutlined />, requiredRole: 'SUPER_ADMIN' }, { path: '/billing', name: 'API 用量', icon: <DollarOutlined />, requiredRole: 'SUPER_ADMIN' },
{ path: '/git', name: '代码仓库', icon: <CodeOutlined /> }, { path: '/git', name: '代码仓库', icon: <CodeOutlined /> },
{ path: '/ops', name: '系统运维', icon: <CloudServerOutlined />, requiredRole: 'SUPER_ADMIN', children: [ { path: '/ops', name: '系统运维', icon: <CloudServerOutlined />, requiredRole: 'SUPER_ADMIN', children: [
{ path: '/config', name: '配置管理' },
{ path: '/servers', name: '服务器' }, { path: '/servers', name: '服务器' },
{ path: '/events', name: '事件队列' }, { path: '/events', name: '事件队列' },
]}, ]},

View File

@ -24,6 +24,7 @@ const breadcrumbMap: Record<string, string> = {
'/billing': 'API 用量', '/billing': 'API 用量',
'/git': '代码仓库', '/git': '代码仓库',
'/servers': '服务器运维', '/servers': '服务器运维',
'/config': '配置管理',
'/events': '事件队列', '/events': '事件队列',
'/audit': '审计日志', '/audit': '审计日志',
} }

89
src/pages/Config.tsx Normal file
View File

@ -0,0 +1,89 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Table, Switch, Button, Typography, App, Modal, Input, Tabs, Space } from 'antd'
import { ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { getConfig, setConfig, updateConfig, deleteConfig, toggleFlag, getChangeLog } from '@/services/config-api'
import dayjs from 'dayjs'
const { Title, Text } = Typography
function ConfigPage() {
const { modal, message } = App.useApp()
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [editKey, setEditKey] = useState('')
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const { data } = useQuery({ queryKey: ['config'], queryFn: getConfig, staleTime: 30_000 })
const { data: changelog } = useQuery({ queryKey: ['config', 'changelog'], queryFn: getChangeLog, staleTime: 10_000 })
const handleSave = async () => {
if (editKey) { await updateConfig(editKey, newValue); message.success('已更新') }
else { await setConfig(newKey, newValue); message.success('已添加') }
setAddOpen(false); setEditKey(''); setNewKey(''); setNewValue('')
qc.invalidateQueries({ queryKey: ['config'] })
}
const handleDelete = (key: string) => modal.confirm({
title: '删除配置', content: `确定删除 ${key}`, okType: 'danger',
onOk: async () => { await deleteConfig(key); qc.invalidateQueries({ queryKey: ['config'] }) },
})
const configColumns = [
{ title: 'Key', dataIndex: 'key', width: 180, ellipsis: true },
{ title: 'Value', dataIndex: 'value', width: 300, ellipsis: true, render: (v: string) => <Text code>{v}</Text> },
{ title: '环境', dataIndex: 'environment', width: 80 },
{ title: '更新', dataIndex: 'updatedAt', width: 140, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
{ title: '操作', width: 100, render: (_: any, r: any) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditKey(r.key); setNewKey(r.key); setNewValue(r.value); setAddOpen(true) }} />
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r.key)} />
</Space>
)},
]
const flagColumns = [
{ title: '开关', dataIndex: 'name', width: 180 },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => (
<Switch checked={v} onChange={(checked) => { toggleFlag(r.name, checked); message.success('已切换'); qc.invalidateQueries({ queryKey: ['config'] }) }} />
)},
]
const logColumns = [
{ title: '时间', dataIndex: 'createdAt', width: 130, render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss') },
{ title: '类型', dataIndex: 'entityType', width: 100 },
{ title: '字段', dataIndex: 'field', width: 100 },
{ title: '旧值', dataIndex: 'oldValue', ellipsis: true, render: (v: string) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '-' },
{ title: '新值', dataIndex: 'newValue', ellipsis: true, render: (v: string) => <Text code style={{ fontSize: 11 }}>{v}</Text> },
{ title: '操作人', dataIndex: 'changedBy', width: 150 },
]
const items = [
{ key: 'config', label: '配置', children: <Table dataSource={data?.configs || []} columns={configColumns} rowKey="key" pagination={false} size="small" /> },
{ key: 'flags', label: '功能开关', children: <Table dataSource={data?.flags || []} columns={flagColumns} rowKey="name" pagination={false} size="small" /> },
{ key: 'log', label: '变更历史', children: <Table dataSource={changelog || []} columns={logColumns} rowKey="id" pagination={{ pageSize: 30 }} size="small" /> },
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}></Title>
<Space>
<Button icon={<PlusOutlined />} type="primary" onClick={() => { setEditKey(''); setNewKey(''); setNewValue(''); setAddOpen(true) }}></Button>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['config'] })}></Button>
</Space>
</div>
<Tabs items={items} />
<Modal title={editKey ? '编辑配置' : '新增配置'} open={addOpen} onOk={handleSave} onCancel={() => setAddOpen(false)} okText="保存">
<Input placeholder="Key" value={newKey} onChange={e => setNewKey(e.target.value)} disabled={!!editKey} style={{ marginBottom: 12 }} />
<Input.TextArea placeholder="Value" value={newValue} onChange={e => setNewValue(e.target.value)} rows={3} />
</Modal>
</div>
)
}
export default ConfigPage

View File

@ -0,0 +1,12 @@
import { api } from './http-client'
export interface AppConfigItem { id: string; key: string; value: string; description: string | null; environment: string; updatedAt: string }
export interface FeatureFlagItem { id: string; name: string; enabled: boolean; description: string | null; rolloutPct: number }
export interface ChangeLogItem { id: string; entityType: string; entityId: string; field: string; oldValue: string | null; newValue: string | null; changedBy: string | null; createdAt: string }
export function getConfig(): Promise<{ configs: AppConfigItem[]; flags: FeatureFlagItem[] }> { return api.get('/admin-api/config') }
export function setConfig(key: string, value: string): Promise<any> { return api.post('/admin-api/config', { key, value }) }
export function updateConfig(key: string, value: string): Promise<any> { return api.patch(`/admin-api/config/${key}`, { value }) }
export function deleteConfig(key: string): Promise<any> { return api.delete(`/admin-api/config/${key}`) }
export function toggleFlag(name: string, enabled: boolean): Promise<any> { return api.post(`/admin-api/config/flags/${name}`, { enabled }) }
export function getChangeLog(): Promise<ChangeLogItem[]> { return api.get('/admin-api/config/changelog') }