🎨 优化UI

This commit is contained in:
2026-04-08 12:48:09 +08:00
parent 7599146f24
commit 6a80fcc388
6 changed files with 1123 additions and 97 deletions

View File

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