🎨 优化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

@@ -141,12 +141,14 @@ export function AdminShell({ children }: Props) {
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Menu <Menu
className="admin-nav-menu"
mode="inline" mode="inline"
theme="dark" theme="dark"
inlineIndent={16}
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={handleMenuClick}
style={{ background: 'transparent', borderInlineEnd: 'none' }} style={{ width: '100%', background: 'transparent', borderInlineEnd: 'none' }}
/> />
</aside> </aside>
<main className="admin-main"> <main className="admin-main">

View File

@@ -1,70 +1,246 @@
import { useEffect, useState } from 'react' import { CloudServerOutlined, HddOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Card, Descriptions, Skeleton, Typography } from 'antd' import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Col, Progress, Row, Skeleton, Space, Statistic, Typography } from 'antd'
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { inventoryApi } from '@/lib/api' import { inventoryApi } from '@/lib/api'
import type { ServerState } from '@/types/system'
function renderValue(value: unknown) { function formatStorageFromMb(valueMb: number) {
if (value === null || value === undefined) { if (valueMb >= 1024 * 1024) {
return '-' return `${(valueMb / (1024 * 1024)).toFixed(2)} TB`
} }
if (typeof value === 'object') { if (valueMb >= 1024) {
return ( return `${(valueMb / 1024).toFixed(1)} GB`
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(value, null, 2)}
</pre>
)
} }
return String(value) return `${valueMb} MB`
} }
export function ServerStatePage() { export function ServerStatePage() {
const [server, setServer] = useState<Record<string, unknown> | null>(null) const [server, setServer] = useState<ServerState | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { const loadServerInfo = async () => {
let ignore = false setLoading(true)
try {
inventoryApi const response = await inventoryApi.getServerInfo()
.getServerInfo()
.then((response) => {
if (!ignore) {
setServer(response.data.server) setServer(response.data.server)
} } finally {
})
.finally(() => {
if (!ignore) {
setLoading(false) setLoading(false)
} }
})
return () => {
ignore = true
} }
useEffect(() => {
void loadServerInfo()
}, []) }, [])
const cpuChartData = useMemo(
() =>
server?.cpu.cpus.map((usage, index) => ({
name: `CPU ${index + 1}`,
usage: Number(usage.toFixed(1)),
})) || [],
[server],
)
const averageCpuUsage = useMemo(() => {
if (!cpuChartData.length) {
return 0
}
const total = cpuChartData.reduce((sum, item) => sum + item.usage, 0)
return Number((total / cpuChartData.length).toFixed(1))
}, [cpuChartData])
const diskSummary = useMemo(() => {
const disks = server?.disk || []
const totalMb = disks.reduce((sum, item) => sum + item.totalMb, 0)
const usedMb = disks.reduce((sum, item) => sum + item.usedMb, 0)
const usedPercent = totalMb ? Number(((usedMb / totalMb) * 100).toFixed(1)) : 0
return { totalMb, usedMb, usedPercent }
}, [server])
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card className="glass-panel page-panel"> <Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}> <Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title> </Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}> <Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
`/system/getServerInfo` CPU便
</Typography.Paragraph> </Typography.Paragraph>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void loadServerInfo()} loading={loading}>
</Button>
</div>
</Card> </Card>
<Card className="glass-panel page-panel"> <Card className="glass-panel page-panel">
{loading ? ( {loading ? (
<Skeleton active /> <Skeleton active />
) : ( ) : server ? (
<Descriptions bordered column={1} size="middle"> <div className="page-stack">
{Object.entries(server || {}).map(([key, value]) => ( <div className="hero-grid">
<Descriptions.Item key={key} label={key}> <Card bordered={false} style={{ background: 'rgba(255, 255, 255, 0.86)' }}>
{renderValue(value)} <Space direction="vertical" size={10} style={{ width: '100%' }}>
</Descriptions.Item> <Space size={10}>
<span className="status-dot ready"></span>
<Typography.Text strong></Typography.Text>
</Space>
<Typography.Title level={3} style={{ margin: 0 }}>
{server.os.goos.toUpperCase()} · Go Runtime
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ margin: 0 }}>
{server.os.numGoroutine} goroutine {server.os.compiler}
Go {server.os.goVersion}
</Typography.Paragraph>
<div className="server-runtime-grid">
<div className="metric-card">
<Typography.Text className="text-muted"> CPU</Typography.Text>
<Typography.Title level={4} style={{ margin: '8px 0 0' }}>
{server.os.numCpu}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted">CPU </Typography.Text>
<Typography.Title level={4} style={{ margin: '8px 0 0' }}>
{averageCpuUsage}%
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={4} style={{ margin: '8px 0 0' }}>
{server.ram.usedPercent}%
</Typography.Title>
</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ background: 'rgba(16, 37, 66, 0.04)' }}>
<div className="server-meter-grid">
<div className="server-meter-card">
<Typography.Text className="text-muted">CPU </Typography.Text>
<Progress
type="dashboard"
percent={Math.round(averageCpuUsage)}
strokeColor="#d16f3f"
/>
</div>
<div className="server-meter-card">
<Typography.Text className="text-muted"></Typography.Text>
<Progress
type="dashboard"
percent={Math.round(server.ram.usedPercent)}
strokeColor="#1f9d78"
/>
</div>
</div>
</Card>
</div>
<div className="metric-grid">
<div className="metric-card">
<Typography.Text className="text-muted">CPU </Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{server.cpu.cores}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted">Goroutine</Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{server.os.numGoroutine}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{formatStorageFromMb(server.ram.usedMb)}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{diskSummary.usedPercent}%
</Typography.Title>
</div>
</div>
<Row gutter={[20, 20]}>
<Col xs={24} xl={15}>
<Card className="glass-panel" title="CPU 核心负载走势" extra={<ThunderboltOutlined />}>
<div style={{ width: '100%', height: 320 }}>
<ResponsiveContainer>
<BarChart data={cpuChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(16, 37, 66, 0.08)" />
<XAxis dataKey="name" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} width={44} unit="%" />
<Tooltip
formatter={(value) => [`${Number(value ?? 0).toFixed(1)}%`, '占用率']}
/>
<Bar dataKey="usage" radius={[10, 10, 0, 0]}>
{cpuChartData.map((item) => (
<Cell
key={item.name}
fill={item.usage >= 80 ? '#c34747' : item.usage >= 60 ? '#d29b2f' : '#d16f3f'}
/>
))} ))}
</Descriptions> </Bar>
)} </BarChart>
</ResponsiveContainer>
</div>
</Card>
</Col>
<Col xs={24} xl={9}>
<Card className="glass-panel" title="运行时概览" extra={<CloudServerOutlined />}>
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Statistic title="操作系统" value={server.os.goos.toUpperCase()} />
<Statistic title="编译器" value={server.os.compiler} />
<Statistic title="Go 版本" value={server.os.goVersion} />
<Statistic title="总内存" value={formatStorageFromMb(server.ram.totalMb)} />
</Space>
</Card>
</Col>
</Row>
<Row gutter={[20, 20]}>
<Col xs={24} xl={8}>
<Card className="glass-panel" title="内存占用" extra={`${server.ram.usedPercent}%`}>
<Space direction="vertical" size={18} style={{ width: '100%' }}>
<Progress percent={server.ram.usedPercent} strokeColor="#1f9d78" />
<Statistic title="已用内存" value={formatStorageFromMb(server.ram.usedMb)} />
<Statistic title="总内存" value={formatStorageFromMb(server.ram.totalMb)} />
</Space>
</Card>
</Col>
<Col xs={24} xl={16}>
<Card className="glass-panel" title="磁盘分区" extra={<HddOutlined />}>
<div className="server-disk-grid">
{server.disk.map((diskItem) => (
<Card key={diskItem.mountPoint} size="small" className="server-disk-card">
<div className="section-heading" style={{ marginBottom: 12 }}>
<div>
<Typography.Text strong>{diskItem.mountPoint}</Typography.Text>
<Typography.Paragraph className="text-muted" style={{ margin: '4px 0 0' }}>
{formatStorageFromMb(diskItem.usedMb)} / {formatStorageFromMb(diskItem.totalMb)}
</Typography.Paragraph>
</div>
<Typography.Title level={4} style={{ margin: 0 }}>
{diskItem.usedPercent}%
</Typography.Title>
</div>
<Progress percent={diskItem.usedPercent} strokeColor="#d16f3f" />
</Card>
))}
</div>
</Card>
</Col>
</Row>
</div>
) : null}
</Card> </Card>
</div> </div>
) )

View File

@@ -1,11 +1,461 @@
import { MinusCircleOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons'
import { useEffect, useMemo, useState } from 'react' 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' 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() { export function SystemConfigPage() {
const [form] = Form.useForm<ConfigMap>()
const [config, setConfig] = useState<Record<string, unknown>>({}) const [config, setConfig] = useState<Record<string, unknown>>({})
const [text, setText] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const summary = useMemo(() => { const summary = useMemo(() => {
const system = (config.system || {}) as Record<string, unknown> const system = (config.system || {}) as Record<string, unknown>
@@ -22,23 +472,266 @@ export function SystemConfigPage() {
try { try {
const response = await systemApi.getSystemConfig() const response = await systemApi.getSystemConfig()
setConfig(response.data.config) setConfig(response.data.config)
setText(JSON.stringify(response.data.config, null, 2)) form.setFieldsValue(cloneConfigValue(response.data.config) as never)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
useEffect(() => { useEffect(() => {
reloadConfig() let ignore = false
}, [])
const saveConfig = async () => { const bootstrap = async () => {
const parsed = JSON.parse(text) as Record<string, unknown> setLoading(true)
await systemApi.setSystemConfig(parsed) try {
message.success('配置已保存') const response = await systemApi.getSystemConfig()
setConfig(parsed) 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 ( return (
<div className="page-stack"> <div className="page-stack">
<Card className="glass-panel page-panel"> <Card className="glass-panel page-panel">
@@ -48,24 +741,28 @@ export function SystemConfigPage() {
</Typography.Title> </Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}> <Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
JSON 使
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Space> <Space>
<Button onClick={reloadConfig} loading={loading}> <Button icon={<ReloadOutlined />} onClick={() => void reloadConfig()} loading={loading}>
</Button> </Button>
<Button <Button
icon={<SyncOutlined />}
onClick={async () => { onClick={async () => {
await systemApi.reloadSystem() await systemApi.reloadSystem()
message.success('系统重载命令已发送') message.success('系统重载命令已发送')
}} }}
> >
</Button> </Button>
<Button type="primary" onClick={saveConfig}> <Button icon={<SaveOutlined />} loading={saving} onClick={() => void saveConfig(false)}>
</Button> </Button>
<Button type="primary" icon={<SyncOutlined />} loading={saving} onClick={() => void saveConfig(true)}>
</Button>
</Space> </Space>
</div> </div>
<Row gutter={16}> <Row gutter={16}>
@@ -92,38 +789,9 @@ export function SystemConfigPage() {
</Row> </Row>
</Card> </Card>
<Card className="glass-panel page-panel" loading={loading}> <Card className="glass-panel page-panel" loading={loading}>
<Tabs <Form form={form} layout="vertical" initialValues={config}>
items={[ <Tabs className="config-tabs" tabPosition="top" items={groupItems} />
{ </Form>
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>
),
},
]}
/>
</Card> </Card>
</div> </div>
) )

View File

@@ -65,18 +65,21 @@ img {
.admin-shell { .admin-shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 312px 1fr; grid-template-columns: 300px minmax(0, 1fr);
} }
.admin-sidebar { .admin-sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
width: 300px;
min-width: 300px;
padding: 24px 18px; padding: 24px 18px;
background: background:
linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%); linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%);
color: var(--text-light); color: var(--text-light);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
} }
.admin-brand { .admin-brand {
@@ -90,9 +93,140 @@ img {
} }
.admin-main { .admin-main {
min-width: 0;
padding: 24px; padding: 24px;
} }
.admin-nav-menu {
width: 100%;
min-width: 0;
background: transparent !important;
border-inline-end: none !important;
}
.admin-nav-menu.ant-menu-inline {
border-inline-end: none !important;
}
.admin-nav-menu .ant-menu-item,
.admin-nav-menu .ant-menu-submenu-title {
width: 100%;
margin-inline: 0 !important;
margin-block: 6px;
border-radius: 16px;
}
.admin-nav-menu .ant-menu-sub.ant-menu-inline {
margin: 8px 0 12px;
padding: 8px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-nav-menu.ant-menu-dark .ant-menu-item,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-title,
.admin-nav-menu.ant-menu-dark .ant-menu-item a,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-title a {
color: rgba(255, 255, 255, 0.82);
}
.admin-nav-menu.ant-menu-dark .ant-menu-item:hover,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-title:hover {
background: rgba(255, 255, 255, 0.08) !important;
color: rgba(255, 255, 255, 0.96) !important;
}
.admin-nav-menu.ant-menu-dark .ant-menu-item-selected,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title {
background:
linear-gradient(135deg, rgba(209, 111, 63, 0.95), rgba(235, 146, 96, 0.86)) !important;
color: #fff !important;
box-shadow: 0 12px 24px rgba(209, 111, 63, 0.22);
}
.admin-nav-menu.ant-menu-dark .ant-menu-item-selected::after,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title::after {
display: none;
}
.config-tabs .ant-tabs-nav {
margin-bottom: 20px;
}
.config-tabs .ant-tabs-tab {
padding: 14px 16px;
border-radius: 18px;
}
.config-tab-label {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-tab-label small {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.config-panel-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-group-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.config-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.config-grid-span-full {
grid-column: 1 / -1;
}
.config-section-card,
.config-array-item,
.server-disk-card {
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
}
.config-array-list,
.server-meter-grid,
.server-disk-grid,
.server-runtime-grid {
display: grid;
gap: 16px;
}
.server-meter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
}
.server-meter-card {
text-align: center;
}
.server-runtime-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.server-disk-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.glass-panel { .glass-panel {
background: var(--surface); background: var(--surface);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@@ -229,6 +363,11 @@ img {
.catalog-grid { .catalog-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.server-runtime-grid,
.server-disk-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -247,4 +386,9 @@ img {
.catalog-grid { .catalog-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.config-form-grid,
.server-meter-grid {
grid-template-columns: 1fr;
}
} }

View File

@@ -18,6 +18,7 @@ import type {
MenuNode, MenuNode,
OperationRecord, OperationRecord,
PagePayload, PagePayload,
ServerState,
SysErrorRecord, SysErrorRecord,
SysParam, SysParam,
UserInfo, UserInfo,
@@ -142,7 +143,7 @@ export const systemApi = {
getSystemConfig() { getSystemConfig() {
return http.post<{ config: Record<string, unknown> }>('/system/getSystemConfig') return http.post<{ config: Record<string, unknown> }>('/system/getSystemConfig')
}, },
setSystemConfig(payload: Record<string, unknown>) { setSystemConfig(payload: { config: Record<string, unknown> }) {
return http.post<Record<string, never>>('/system/setSystemConfig', payload) return http.post<Record<string, never>>('/system/setSystemConfig', payload)
}, },
reloadSystem() { reloadSystem() {
@@ -277,7 +278,7 @@ export const sysErrorApi = {
export const inventoryApi = { export const inventoryApi = {
getServerInfo() { getServerInfo() {
return http.post<{ server: Record<string, unknown> }>('/system/getServerInfo') return http.post<{ server: ServerState }>('/system/getServerInfo')
}, },
} }

View File

@@ -180,6 +180,41 @@ export type JsonSchema = {
default?: unknown default?: unknown
} }
export type ServerOs = {
goos: string
numCpu: number
compiler: string
goVersion: string
numGoroutine: number
}
export type ServerCpu = {
cpus: number[]
cores: number
}
export type ServerRam = {
usedMb: number
totalMb: number
usedPercent: number
}
export type ServerDisk = {
mountPoint: string
usedMb: number
usedGb: number
totalMb: number
totalGb: number
usedPercent: number
}
export type ServerState = {
os: ServerOs
cpu: ServerCpu
ram: ServerRam
disk: ServerDisk[]
}
export type McpContent = { export type McpContent = {
type: string type: string
text?: string text?: string