🎨 优化UI
This commit is contained in:
@@ -1,11 +1,461 @@
|
||||
import { MinusCircleOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, Row, Space, Statistic, Tabs, Typography, message } from 'antd'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { systemApi } from '@/lib/api'
|
||||
|
||||
type ConfigMap = Record<string, unknown>
|
||||
type ConfigPath = Array<string | number>
|
||||
type ConfigOption = { label: string; value: string | number }
|
||||
type ConfigFieldSchema = {
|
||||
label?: string
|
||||
description?: string
|
||||
options?: ConfigOption[]
|
||||
template?: unknown
|
||||
inputType?: 'password' | 'textarea'
|
||||
}
|
||||
|
||||
const groupMetaMap: Record<string, { title: string; summary: string }> = {
|
||||
system: {
|
||||
title: '系统',
|
||||
summary: '服务监听、路由前缀、认证与运行策略。',
|
||||
},
|
||||
jwt: {
|
||||
title: 'JWT',
|
||||
summary: '签名密钥、过期时间与签发者配置。',
|
||||
},
|
||||
zap: {
|
||||
title: '日志',
|
||||
summary: '日志级别、格式、落盘目录与保留策略。',
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis',
|
||||
summary: '默认 Redis 连接与集群模式配置。',
|
||||
},
|
||||
'redis-list': {
|
||||
title: 'Redis 列表',
|
||||
summary: '多 Redis 实例配置。',
|
||||
},
|
||||
mysql: {
|
||||
title: 'MySQL',
|
||||
summary: 'MySQL 数据源连接参数。',
|
||||
},
|
||||
pgsql: {
|
||||
title: 'PostgreSQL',
|
||||
summary: 'PostgreSQL 数据源连接参数。',
|
||||
},
|
||||
sqlite: {
|
||||
title: 'SQLite',
|
||||
summary: 'SQLite 数据源连接参数。',
|
||||
},
|
||||
mssql: {
|
||||
title: 'MSSQL',
|
||||
summary: 'MSSQL 数据源连接参数。',
|
||||
},
|
||||
oracle: {
|
||||
title: 'Oracle',
|
||||
summary: 'Oracle 数据源连接参数。',
|
||||
},
|
||||
mongo: {
|
||||
title: 'MongoDB',
|
||||
summary: 'MongoDB 连接与连接池配置。',
|
||||
},
|
||||
captcha: {
|
||||
title: '验证码',
|
||||
summary: '验证码开关、有效期与尺寸。',
|
||||
},
|
||||
cors: {
|
||||
title: 'CORS',
|
||||
summary: '跨域白名单与请求头策略。',
|
||||
},
|
||||
email: {
|
||||
title: '邮件',
|
||||
summary: 'SMTP 发件配置。',
|
||||
},
|
||||
local: {
|
||||
title: '本地存储',
|
||||
summary: '本地文件上传路径配置。',
|
||||
},
|
||||
minio: {
|
||||
title: 'MinIO',
|
||||
summary: 'MinIO 对象存储配置。',
|
||||
},
|
||||
qiniu: {
|
||||
title: '七牛云',
|
||||
summary: '七牛对象存储配置。',
|
||||
},
|
||||
'aliyun-oss': {
|
||||
title: '阿里云 OSS',
|
||||
summary: '阿里云对象存储配置。',
|
||||
},
|
||||
'aws-s3': {
|
||||
title: 'AWS S3',
|
||||
summary: 'S3 存储桶与访问参数。',
|
||||
},
|
||||
'hua-wei-obs': {
|
||||
title: '华为云 OBS',
|
||||
summary: '华为云对象存储配置。',
|
||||
},
|
||||
'tencent-cos': {
|
||||
title: '腾讯云 COS',
|
||||
summary: '腾讯云对象存储配置。',
|
||||
},
|
||||
'cloudflare-r2': {
|
||||
title: 'Cloudflare R2',
|
||||
summary: 'R2 存储桶与密钥配置。',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
summary: 'MCP 服务监听、对外地址与鉴权头。',
|
||||
},
|
||||
autocode: {
|
||||
title: '代码生成',
|
||||
summary: '代码生成根目录、模块名与目标路径。',
|
||||
},
|
||||
excel: {
|
||||
title: 'Excel',
|
||||
summary: 'Excel 导出目录。',
|
||||
},
|
||||
'disk-list': {
|
||||
title: '磁盘监控',
|
||||
summary: '服务器状态页磁盘挂载点来源。',
|
||||
},
|
||||
'db-list': {
|
||||
title: '数据库列表',
|
||||
summary: '多业务数据库配置。',
|
||||
},
|
||||
}
|
||||
|
||||
const fieldLabelMap: Record<string, string> = {
|
||||
addr: '监听端口',
|
||||
'db-type': '数据库类型',
|
||||
'router-prefix': '路由前缀',
|
||||
'use-strict-auth': '严格权限校验',
|
||||
'use-multipoint': '多点登录',
|
||||
'use-redis': '启用 Redis',
|
||||
'use-mongo': '启用 MongoDB',
|
||||
'disable-auto-migrate': '禁用自动迁移',
|
||||
'signing-key': '签名密钥',
|
||||
'expires-time': '过期时间',
|
||||
'buffer-time': '缓冲时间',
|
||||
issuer: '签发者',
|
||||
level: '日志级别',
|
||||
format: '输出格式',
|
||||
director: '日志目录',
|
||||
'show-line': '显示行号',
|
||||
'log-in-console': '控制台输出',
|
||||
'retention-day': '保留天数',
|
||||
'open-captcha': '验证码开关',
|
||||
'open-captcha-timeout': '验证码超时',
|
||||
'img-width': '图片宽度',
|
||||
'img-height': '图片高度',
|
||||
'allow-origin': '允许来源',
|
||||
'allow-methods': '允许方法',
|
||||
'allow-headers': '允许请求头',
|
||||
'expose-headers': '暴露响应头',
|
||||
'allow-credentials': '允许携带凭证',
|
||||
'mount-point': '挂载点',
|
||||
'access-key-id': 'Access Key ID',
|
||||
'access-key-secret': 'Access Key Secret',
|
||||
'secret-access-key': 'Secret Access Key',
|
||||
'secret-key': 'Secret Key',
|
||||
'base-url': '基础地址',
|
||||
'base-path': '基础路径',
|
||||
'path-prefix': '路径前缀',
|
||||
'bucket-name': 'Bucket 名称',
|
||||
'bucket-url': 'Bucket 地址',
|
||||
'auth-header': '鉴权请求头',
|
||||
'request_timeout': '请求超时',
|
||||
}
|
||||
|
||||
const dbTypeOptions: ConfigOption[] = [
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'PostgreSQL', value: 'pgsql' },
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'MSSQL', value: 'mssql' },
|
||||
{ label: 'Oracle', value: 'oracle' },
|
||||
]
|
||||
|
||||
const logModeOptions: ConfigOption[] = [
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
]
|
||||
|
||||
const configFieldSchemaMap: Record<string, ConfigFieldSchema> = {
|
||||
'system.db-type': {
|
||||
description: '控制主业务数据库驱动类型。',
|
||||
options: dbTypeOptions,
|
||||
},
|
||||
'system.oss-type': {
|
||||
description: '控制上传模块实际使用的对象存储实现。',
|
||||
options: [
|
||||
{ label: '本地存储', value: 'local' },
|
||||
{ label: 'MinIO', value: 'minio' },
|
||||
{ label: '七牛云', value: 'qiniu' },
|
||||
{ label: '阿里云 OSS', value: 'aliyun-oss' },
|
||||
{ label: '华为云 OBS', value: 'hua-wei-obs' },
|
||||
{ label: '腾讯云 COS', value: 'tencent-cos' },
|
||||
{ label: 'AWS S3', value: 'aws-s3' },
|
||||
{ label: 'Cloudflare R2', value: 'cloudflare-r2' },
|
||||
],
|
||||
},
|
||||
'system.addr': {
|
||||
description: '服务监听端口。',
|
||||
},
|
||||
'system.router-prefix': {
|
||||
description: '接口统一前缀,留空表示不追加额外前缀。',
|
||||
},
|
||||
'system.use-strict-auth': {
|
||||
description: '开启后使用树形权限分配模式。',
|
||||
},
|
||||
'system.disable-auto-migrate': {
|
||||
description: '生产环境通常建议开启,避免启动时自动变更表结构。',
|
||||
},
|
||||
'jwt.signing-key': {
|
||||
description: 'JWT 签名密钥。修改后旧 token 会失效。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'jwt.expires-time': {
|
||||
description: 'JWT 过期时间,支持 7d、24h 这类时长表达。',
|
||||
},
|
||||
'jwt.buffer-time': {
|
||||
description: '刷新 token 的缓冲时间窗口。',
|
||||
},
|
||||
'zap.level': {
|
||||
description: '日志输出最低级别。',
|
||||
options: [
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'DPanic', value: 'dpanic' },
|
||||
{ label: 'Panic', value: 'panic' },
|
||||
{ label: 'Fatal', value: 'fatal' },
|
||||
],
|
||||
},
|
||||
'zap.format': {
|
||||
description: 'console 更便于本地查看,json 更适合日志采集。',
|
||||
options: [
|
||||
{ label: 'Console', value: 'console' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
],
|
||||
},
|
||||
'zap.encode-level': {
|
||||
description: '日志级别编码方式。',
|
||||
options: [
|
||||
{ label: 'LowercaseLevelEncoder', value: 'LowercaseLevelEncoder' },
|
||||
{ label: 'LowercaseColorLevelEncoder', value: 'LowercaseColorLevelEncoder' },
|
||||
{ label: 'CapitalLevelEncoder', value: 'CapitalLevelEncoder' },
|
||||
{ label: 'CapitalColorLevelEncoder', value: 'CapitalColorLevelEncoder' },
|
||||
],
|
||||
},
|
||||
'redis.addr': {
|
||||
description: '单实例模式使用的 Redis 地址。',
|
||||
},
|
||||
'redis.password': {
|
||||
description: 'Redis 访问密码。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'redis.clusterAddrs': {
|
||||
description: '集群节点地址列表。',
|
||||
template: '127.0.0.1:7000',
|
||||
},
|
||||
'redis-list': {
|
||||
description: '多实例 Redis 配置列表。',
|
||||
template: {
|
||||
name: '',
|
||||
addr: '127.0.0.1:6379',
|
||||
password: '',
|
||||
db: 0,
|
||||
useCluster: false,
|
||||
clusterAddrs: ['127.0.0.1:7000'],
|
||||
},
|
||||
},
|
||||
'redis-list.[].password': {
|
||||
description: 'Redis 访问密码。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'redis-list.[].clusterAddrs': {
|
||||
description: '集群节点地址列表。',
|
||||
template: '127.0.0.1:7000',
|
||||
},
|
||||
'mongo.hosts': {
|
||||
description: 'MongoDB 主机列表。',
|
||||
template: {
|
||||
host: '127.0.0.1',
|
||||
port: '27017',
|
||||
},
|
||||
},
|
||||
'mongo.password': {
|
||||
description: 'MongoDB 访问密码。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'email.secret': {
|
||||
description: '邮箱 SMTP 授权码,不建议直接填写登录密码。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'captcha.open-captcha': {
|
||||
description: '0 表示每次登录都需要验证码,其他数字表示连续错误次数阈值。',
|
||||
},
|
||||
'cors.mode': {
|
||||
description: '跨域处理策略。',
|
||||
options: [
|
||||
{ label: 'Allow All', value: 'allow-all' },
|
||||
{ label: 'Whitelist', value: 'whitelist' },
|
||||
{ label: 'Strict Whitelist', value: 'strict-whitelist' },
|
||||
],
|
||||
},
|
||||
'cors.whitelist': {
|
||||
description: '跨域白名单条目。',
|
||||
template: {
|
||||
'allow-origin': 'https://example.com',
|
||||
'allow-methods': 'POST, GET',
|
||||
'allow-headers': 'Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id',
|
||||
'expose-headers': 'Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type',
|
||||
'allow-credentials': true,
|
||||
},
|
||||
},
|
||||
'mysql.log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'pgsql.log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'sqlite.log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'mssql.log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'oracle.log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'db-list': {
|
||||
description: '附加业务数据库列表。',
|
||||
template: {
|
||||
type: 'pgsql',
|
||||
'alias-name': '',
|
||||
prefix: '',
|
||||
port: '5432',
|
||||
config: '',
|
||||
'db-name': '',
|
||||
username: '',
|
||||
password: '',
|
||||
path: '127.0.0.1',
|
||||
engine: '',
|
||||
'log-mode': 'error',
|
||||
'max-idle-conns': 10,
|
||||
'max-open-conns': 100,
|
||||
singular: false,
|
||||
'log-zap': false,
|
||||
disable: false,
|
||||
},
|
||||
},
|
||||
'db-list.[].type': {
|
||||
description: '数据库驱动类型。',
|
||||
options: dbTypeOptions,
|
||||
},
|
||||
'db-list.[].password': {
|
||||
description: '数据库访问密码。',
|
||||
inputType: 'password',
|
||||
},
|
||||
'db-list.[].log-mode': {
|
||||
description: 'Gorm SQL 日志级别。',
|
||||
options: logModeOptions,
|
||||
},
|
||||
'qiniu.zone': {
|
||||
description: '七牛云存储区域。',
|
||||
options: [
|
||||
{ label: 'ZoneHuaDong', value: 'ZoneHuaDong' },
|
||||
{ label: 'ZoneHuaBei', value: 'ZoneHuaBei' },
|
||||
{ label: 'ZoneHuaNan', value: 'ZoneHuaNan' },
|
||||
{ label: 'ZoneBeiMei', value: 'ZoneBeiMei' },
|
||||
{ label: 'ZoneXinJiaPo', value: 'ZoneXinJiaPo' },
|
||||
],
|
||||
},
|
||||
'disk-list': {
|
||||
description: '服务器状态页读取的磁盘挂载点列表。',
|
||||
template: {
|
||||
'mount-point': '/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is ConfigMap {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function cloneConfigValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneConfigValue(item)) as T
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneConfigValue(item)]),
|
||||
) as T
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeSchemaPath(path: ConfigPath) {
|
||||
return path.map((segment) => (typeof segment === 'number' ? '[]' : String(segment))).join('.')
|
||||
}
|
||||
|
||||
function getFieldSchema(path: ConfigPath) {
|
||||
return configFieldSchemaMap[normalizeSchemaPath(path)]
|
||||
}
|
||||
|
||||
function formatLabel(key: string) {
|
||||
if (fieldLabelMap[key]) {
|
||||
return fieldLabelMap[key]
|
||||
}
|
||||
|
||||
return key
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function isSensitiveField(path: ConfigPath) {
|
||||
return /(password|secret|signing-key|auth-header|token)/i.test(path.join('.'))
|
||||
}
|
||||
|
||||
function shouldUseTextArea(path: ConfigPath, value: string) {
|
||||
const leaf = String(path.at(-1) || '')
|
||||
return value.includes('\n') || value.length > 60 || /(headers|methods|config|options)/i.test(leaf)
|
||||
}
|
||||
|
||||
export function SystemConfigPage() {
|
||||
const [form] = Form.useForm<ConfigMap>()
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [text, setText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const system = (config.system || {}) as Record<string, unknown>
|
||||
@@ -22,23 +472,266 @@ export function SystemConfigPage() {
|
||||
try {
|
||||
const response = await systemApi.getSystemConfig()
|
||||
setConfig(response.data.config)
|
||||
setText(JSON.stringify(response.data.config, null, 2))
|
||||
form.setFieldsValue(cloneConfigValue(response.data.config) as never)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadConfig()
|
||||
}, [])
|
||||
let ignore = false
|
||||
|
||||
const saveConfig = async () => {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
await systemApi.setSystemConfig(parsed)
|
||||
message.success('配置已保存')
|
||||
setConfig(parsed)
|
||||
const bootstrap = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await systemApi.getSystemConfig()
|
||||
if (ignore) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(response.data.config)
|
||||
form.setFieldsValue(cloneConfigValue(response.data.config) as never)
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const saveConfig = async (reloadAfterSave: boolean) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await form.validateFields()
|
||||
const nextConfig = cloneConfigValue(form.getFieldsValue(true))
|
||||
await systemApi.setSystemConfig({ config: nextConfig })
|
||||
setConfig(nextConfig)
|
||||
|
||||
if (reloadAfterSave) {
|
||||
await systemApi.reloadSystem()
|
||||
await reloadConfig()
|
||||
message.success('配置已保存,系统已按新配置重新初始化')
|
||||
return
|
||||
}
|
||||
|
||||
message.success('配置已保存')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderPrimitiveField = (
|
||||
namePath: ConfigPath,
|
||||
key: string,
|
||||
value: unknown,
|
||||
labelOverride?: string,
|
||||
) => {
|
||||
const fieldKey = namePath.join('.')
|
||||
const schema = getFieldSchema(namePath)
|
||||
const label = labelOverride || schema?.label || formatLabel(key)
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div key={fieldKey}>
|
||||
<Form.Item label={label} name={namePath} valuePropName="checked" extra={schema?.description}>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<div key={fieldKey}>
|
||||
<Form.Item label={label} name={namePath} extra={schema?.description}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
const input = schema?.options ? (
|
||||
<Select options={schema.options} />
|
||||
) : schema?.inputType === 'textarea' || shouldUseTextArea(namePath, stringValue) ? (
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||
) : schema?.inputType === 'password' || isSensitiveField(namePath) ? (
|
||||
<Input.Password />
|
||||
) : (
|
||||
<Input />
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={fieldKey}>
|
||||
<Form.Item label={label} name={namePath} extra={schema?.description}>
|
||||
{input}
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = (namePath: ConfigPath, key: string, value: unknown): React.ReactNode => {
|
||||
const fieldKey = namePath.join('.')
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const schema = getFieldSchema(namePath)
|
||||
const sampleItem = cloneConfigValue(
|
||||
schema?.template !== undefined ? schema.template : value.find((item) => item !== undefined) ?? '',
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={fieldKey} className="config-grid-span-full">
|
||||
<Form.List name={namePath}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Card
|
||||
size="small"
|
||||
className="config-section-card"
|
||||
title={formatLabel(key)}
|
||||
extra={
|
||||
<Space size={12}>
|
||||
{schema?.description ? (
|
||||
<Typography.Text className="text-muted">{schema.description}</Typography.Text>
|
||||
) : null}
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add(cloneConfigValue(sampleItem))}
|
||||
>
|
||||
新增项
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{fields.length ? (
|
||||
<div className="config-array-list">
|
||||
{fields.map((field, index) => {
|
||||
const sourceItem = value[index] ?? sampleItem
|
||||
|
||||
if (isPlainObject(sourceItem)) {
|
||||
return (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
className="config-array-item"
|
||||
title={`${formatLabel(key)} ${index + 1}`}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="config-form-grid">
|
||||
{Object.entries(sourceItem).map(([childKey, childValue]) =>
|
||||
renderField([...namePath, field.name, childKey], childKey, childValue),
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
className="config-array-item"
|
||||
title={`${formatLabel(key)} ${index + 1}`}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{renderPrimitiveField(
|
||||
[...namePath, field.name],
|
||||
key,
|
||||
sourceItem,
|
||||
`${formatLabel(key)} ${index + 1}`,
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={`暂无${formatLabel(key)}配置项`} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return (
|
||||
<div key={fieldKey} className="config-grid-span-full">
|
||||
<Card size="small" className="config-section-card" title={formatLabel(key)}>
|
||||
<div className="config-form-grid">
|
||||
{Object.entries(value).map(([childKey, childValue]) => renderField([...namePath, childKey], childKey, childValue))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return renderPrimitiveField(namePath, key, value)
|
||||
}
|
||||
|
||||
const groupItems = Object.entries(config).map(([groupKey, groupValue]) => {
|
||||
const meta = groupMetaMap[groupKey] || {
|
||||
title: formatLabel(groupKey),
|
||||
summary: '当前分组未登记说明,使用原始配置结构渲染。',
|
||||
}
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
label: (
|
||||
<div className="config-tab-label">
|
||||
<span>{meta.title}</span>
|
||||
<small>{meta.summary}</small>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="config-panel-stack">
|
||||
<div className="config-group-header">
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{meta.title}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ margin: '8px 0 0' }}>
|
||||
{meta.summary}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Tag color="processing">{groupKey}</Tag>
|
||||
</div>
|
||||
<div className="config-form-grid">
|
||||
{isPlainObject(groupValue)
|
||||
? Object.entries(groupValue).map(([childKey, childValue]) => renderField([groupKey, childKey], childKey, childValue))
|
||||
: renderField([groupKey], groupKey, groupValue)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
@@ -48,24 +741,28 @@ export function SystemConfigPage() {
|
||||
系统配置
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页以原始 JSON 为主视图,优先保证配置项覆盖完整,不丢字段。
|
||||
顶部按配置分组切换,表单直接对应服务端配置结构。保存后可以立即触发系统重新初始化,使用新配置生效。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={reloadConfig} loading={loading}>
|
||||
重新加载
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void reloadConfig()} loading={loading}>
|
||||
重新拉取
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={async () => {
|
||||
await systemApi.reloadSystem()
|
||||
message.success('系统重载命令已发送')
|
||||
}}
|
||||
>
|
||||
重载系统
|
||||
仅重新初始化
|
||||
</Button>
|
||||
<Button type="primary" onClick={saveConfig}>
|
||||
<Button icon={<SaveOutlined />} loading={saving} onClick={() => void saveConfig(false)}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button type="primary" icon={<SyncOutlined />} loading={saving} onClick={() => void saveConfig(true)}>
|
||||
保存并重新初始化
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
@@ -92,38 +789,9 @@ export function SystemConfigPage() {
|
||||
</Row>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel" loading={loading}>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON 编辑器',
|
||||
children: (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 540,
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(16, 37, 66, 0.12)',
|
||||
padding: 16,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '结构预览',
|
||||
children: (
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form form={form} layout="vertical" initialValues={config}>
|
||||
<Tabs className="config-tabs" tabPosition="top" items={groupItems} />
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user