791 lines
24 KiB
TypeScript
791 lines
24 KiB
TypeScript
import { MinusCircleOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
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 服务监听、对外地址与鉴权头。',
|
||
},
|
||
'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 [loading, setLoading] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const summary = useMemo(() => {
|
||
const system = (config.system || {}) as Record<string, unknown>
|
||
return {
|
||
addr: system.addr || '-',
|
||
dbType: system['db-type'] || '-',
|
||
routerPrefix: system['router-prefix'] || '/',
|
||
strictAuth: String(system['use-strict-auth'] ?? false),
|
||
}
|
||
}, [config])
|
||
|
||
const reloadConfig = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const response = await systemApi.getSystemConfig()
|
||
setConfig(response.data.config)
|
||
form.setFieldsValue(cloneConfigValue(response.data.config) as never)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
let ignore = false
|
||
|
||
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">
|
||
<div className="section-heading">
|
||
<div>
|
||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||
系统配置
|
||
</Typography.Title>
|
||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||
顶部按配置分组切换,表单直接对应服务端配置结构。保存后可以立即触发系统重新初始化,使用新配置生效。
|
||
</Typography.Paragraph>
|
||
</div>
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void reloadConfig()} loading={loading}>
|
||
重新拉取
|
||
</Button>
|
||
<Button
|
||
icon={<SyncOutlined />}
|
||
onClick={async () => {
|
||
await systemApi.reloadSystem()
|
||
message.success('系统重载命令已发送')
|
||
}}
|
||
>
|
||
仅重新初始化
|
||
</Button>
|
||
<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}>
|
||
<Col xs={24} md={6}>
|
||
<Card bordered={false}>
|
||
<Statistic title="服务端口" value={String(summary.addr)} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} md={6}>
|
||
<Card bordered={false}>
|
||
<Statistic title="数据库类型" value={String(summary.dbType)} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} md={6}>
|
||
<Card bordered={false}>
|
||
<Statistic title="路由前缀" value={String(summary.routerPrefix)} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} md={6}>
|
||
<Card bordered={false}>
|
||
<Statistic title="严格权限模式" value={String(summary.strictAuth)} />
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
<Card className="glass-panel page-panel" loading={loading}>
|
||
<Form form={form} layout="vertical" initialValues={config}>
|
||
<Tabs className="config-tabs" tabPosition="top" items={groupItems} />
|
||
</Form>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|