🎨 优化UI
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
setServer(response.data.server)
|
||||||
.then((response) => {
|
} finally {
|
||||||
if (!ignore) {
|
setLoading(false)
|
||||||
setServer(response.data.server)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!ignore) {
|
|
||||||
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">
|
||||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
<div className="section-heading">
|
||||||
服务器状态
|
<div>
|
||||||
</Typography.Title>
|
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
服务器状态
|
||||||
该页面直接读取 `/system/getServerInfo` 的结构化结果,不预设字段,优先保证信息完整。
|
</Typography.Title>
|
||||||
</Typography.Paragraph>
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
页面按 CPU、内存、磁盘和运行时信息聚合展示,便于快速判断资源压力和运行状态。
|
||||||
|
</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>
|
||||||
</Descriptions>
|
<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'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user