Files
Go-Web-Template/web-admin/src/features/system/SystemConfigPage.tsx
2026-04-10 20:41:28 +08:00

791 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}