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 type ConfigPath = Array type ConfigOption = { label: string; value: string | number } type ConfigFieldSchema = { label?: string description?: string options?: ConfigOption[] template?: unknown inputType?: 'password' | 'textarea' } const groupMetaMap: Record = { 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 = { 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 = { '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(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() const [config, setConfig] = useState>({}) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const summary = useMemo(() => { const system = (config.system || {}) as Record 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 (
) } if (typeof value === 'number') { return (
) } const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value) const input = schema?.options ? ( ) return (
{input}
) } 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 (
{(fields, { add, remove }) => ( {schema?.description ? ( {schema.description} ) : null} } > {fields.length ? (
{fields.map((field, index) => { const sourceItem = value[index] ?? sampleItem if (isPlainObject(sourceItem)) { return ( } onClick={() => remove(field.name)} > 删除 } >
{Object.entries(sourceItem).map(([childKey, childValue]) => renderField([...namePath, field.name, childKey], childKey, childValue), )}
) } return ( } onClick={() => remove(field.name)} > 删除 } > {renderPrimitiveField( [...namePath, field.name], key, sourceItem, `${formatLabel(key)} ${index + 1}`, )} ) })}
) : ( )}
)}
) } if (isPlainObject(value)) { return (
{Object.entries(value).map(([childKey, childValue]) => renderField([...namePath, childKey], childKey, childValue))}
) } 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: (
{meta.title} {meta.summary}
), children: (
{meta.title} {meta.summary}
{groupKey}
{isPlainObject(groupValue) ? Object.entries(groupValue).map(([childKey, childValue]) => renderField([groupKey, childKey], childKey, childValue)) : renderField([groupKey], groupKey, groupValue)}
), } }) return (
系统配置 顶部按配置分组切换,表单直接对应服务端配置结构。保存后可以立即触发系统重新初始化,使用新配置生效。
) }