From 6a80fcc3889e7b3f1aa3ae1c43e9c8c652145148 Mon Sep 17 00:00:00 2001 From: Eg <1711788888@qq.com> Date: Wed, 8 Apr 2026 12:48:09 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E4=BC=98=E5=8C=96UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/features/layout/AdminShell.tsx | 4 +- .../src/features/server/ServerStatePage.tsx | 268 ++++-- .../src/features/system/SystemConfigPage.tsx | 762 ++++++++++++++++-- web-admin/src/index.css | 146 +++- web-admin/src/lib/api.ts | 5 +- web-admin/src/types/system.ts | 35 + 6 files changed, 1123 insertions(+), 97 deletions(-) diff --git a/web-admin/src/features/layout/AdminShell.tsx b/web-admin/src/features/layout/AdminShell.tsx index 8d87b45..9685f23 100644 --- a/web-admin/src/features/layout/AdminShell.tsx +++ b/web-admin/src/features/layout/AdminShell.tsx @@ -141,12 +141,14 @@ export function AdminShell({ children }: Props) {
diff --git a/web-admin/src/features/server/ServerStatePage.tsx b/web-admin/src/features/server/ServerStatePage.tsx index 7a5d8bb..ba97fd6 100644 --- a/web-admin/src/features/server/ServerStatePage.tsx +++ b/web-admin/src/features/server/ServerStatePage.tsx @@ -1,70 +1,246 @@ -import { useEffect, useState } from 'react' -import { Card, Descriptions, Skeleton, Typography } from 'antd' +import { CloudServerOutlined, HddOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons' +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 type { ServerState } from '@/types/system' -function renderValue(value: unknown) { - if (value === null || value === undefined) { - return '-' +function formatStorageFromMb(valueMb: number) { + if (valueMb >= 1024 * 1024) { + return `${(valueMb / (1024 * 1024)).toFixed(2)} TB` } - if (typeof value === 'object') { - return ( -
-        {JSON.stringify(value, null, 2)}
-      
- ) + if (valueMb >= 1024) { + return `${(valueMb / 1024).toFixed(1)} GB` } - return String(value) + return `${valueMb} MB` } export function ServerStatePage() { - const [server, setServer] = useState | null>(null) + const [server, setServer] = useState(null) const [loading, setLoading] = useState(true) - useEffect(() => { - let ignore = false - - inventoryApi - .getServerInfo() - .then((response) => { - if (!ignore) { - setServer(response.data.server) - } - }) - .finally(() => { - if (!ignore) { - setLoading(false) - } - }) - - return () => { - ignore = true + const loadServerInfo = async () => { + setLoading(true) + try { + const response = await inventoryApi.getServerInfo() + setServer(response.data.server) + } finally { + setLoading(false) } + } + + 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 (
- - 服务器状态 - - - 该页面直接读取 `/system/getServerInfo` 的结构化结果,不预设字段,优先保证信息完整。 - +
+
+ + 服务器状态 + + + 页面按 CPU、内存、磁盘和运行时信息聚合展示,便于快速判断资源压力和运行状态。 + +
+ +
{loading ? ( - ) : ( - - {Object.entries(server || {}).map(([key, value]) => ( - - {renderValue(value)} - - ))} - - )} + ) : server ? ( +
+
+ + + + + 运行中 + + + {server.os.goos.toUpperCase()} · Go Runtime + + + 当前运行时包含 {server.os.numGoroutine} 个 goroutine,编译器为 {server.os.compiler}, + Go 版本为 {server.os.goVersion}。 + +
+
+ 逻辑 CPU + + {server.os.numCpu} + +
+
+ CPU 平均负载 + + {averageCpuUsage}% + +
+
+ 内存占用 + + {server.ram.usedPercent}% + +
+
+
+
+ +
+
+ CPU 负载 + +
+
+ 内存负载 + +
+
+
+
+ +
+
+ CPU 核心数 + + {server.cpu.cores} + +
+
+ Goroutine + + {server.os.numGoroutine} + +
+
+ 已用内存 + + {formatStorageFromMb(server.ram.usedMb)} + +
+
+ 磁盘占用 + + {diskSummary.usedPercent}% + +
+
+ + + + }> +
+ + + + + + [`${Number(value ?? 0).toFixed(1)}%`, '占用率']} + /> + + {cpuChartData.map((item) => ( + = 80 ? '#c34747' : item.usage >= 60 ? '#d29b2f' : '#d16f3f'} + /> + ))} + + + +
+
+ + + }> + + + + + + + + +
+ + + + + + + + + + + + + }> +
+ {server.disk.map((diskItem) => ( + +
+
+ {diskItem.mountPoint} + + 已用 {formatStorageFromMb(diskItem.usedMb)} / {formatStorageFromMb(diskItem.totalMb)} + +
+ + {diskItem.usedPercent}% + +
+ +
+ ))} +
+
+ +
+
+ ) : null}
) diff --git a/web-admin/src/features/system/SystemConfigPage.tsx b/web-admin/src/features/system/SystemConfigPage.tsx index f280a4c..f4bea50 100644 --- a/web-admin/src/features/system/SystemConfigPage.tsx +++ b/web-admin/src/features/system/SystemConfigPage.tsx @@ -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 +type ConfigPath = Array +type ConfigOption = { label: string; value: string | number } +type ConfigFieldSchema = { + label?: string + description?: string + options?: ConfigOption[] + template?: unknown + inputType?: 'password' | 'textarea' +} + +const groupMetaMap: Record = { + system: { + title: '系统', + summary: '服务监听、路由前缀、认证与运行策略。', + }, + jwt: { + title: 'JWT', + summary: '签名密钥、过期时间与签发者配置。', + }, + zap: { + title: '日志', + summary: '日志级别、格式、落盘目录与保留策略。', + }, + redis: { + title: 'Redis', + summary: '默认 Redis 连接与集群模式配置。', + }, + 'redis-list': { + title: 'Redis 列表', + summary: '多 Redis 实例配置。', + }, + mysql: { + title: 'MySQL', + summary: 'MySQL 数据源连接参数。', + }, + pgsql: { + title: 'PostgreSQL', + summary: 'PostgreSQL 数据源连接参数。', + }, + sqlite: { + title: 'SQLite', + summary: 'SQLite 数据源连接参数。', + }, + mssql: { + title: 'MSSQL', + summary: 'MSSQL 数据源连接参数。', + }, + oracle: { + title: 'Oracle', + summary: 'Oracle 数据源连接参数。', + }, + mongo: { + title: 'MongoDB', + summary: 'MongoDB 连接与连接池配置。', + }, + captcha: { + title: '验证码', + summary: '验证码开关、有效期与尺寸。', + }, + cors: { + title: 'CORS', + summary: '跨域白名单与请求头策略。', + }, + email: { + title: '邮件', + summary: 'SMTP 发件配置。', + }, + local: { + title: '本地存储', + summary: '本地文件上传路径配置。', + }, + minio: { + title: 'MinIO', + summary: 'MinIO 对象存储配置。', + }, + qiniu: { + title: '七牛云', + summary: '七牛对象存储配置。', + }, + 'aliyun-oss': { + title: '阿里云 OSS', + summary: '阿里云对象存储配置。', + }, + 'aws-s3': { + title: 'AWS S3', + summary: 'S3 存储桶与访问参数。', + }, + 'hua-wei-obs': { + title: '华为云 OBS', + summary: '华为云对象存储配置。', + }, + 'tencent-cos': { + title: '腾讯云 COS', + summary: '腾讯云对象存储配置。', + }, + 'cloudflare-r2': { + title: 'Cloudflare R2', + summary: 'R2 存储桶与密钥配置。', + }, + mcp: { + title: 'MCP', + summary: 'MCP 服务监听、对外地址与鉴权头。', + }, + autocode: { + title: '代码生成', + summary: '代码生成根目录、模块名与目标路径。', + }, + excel: { + title: 'Excel', + summary: 'Excel 导出目录。', + }, + 'disk-list': { + title: '磁盘监控', + summary: '服务器状态页磁盘挂载点来源。', + }, + 'db-list': { + title: '数据库列表', + summary: '多业务数据库配置。', + }, +} + +const fieldLabelMap: Record = { + addr: '监听端口', + 'db-type': '数据库类型', + 'router-prefix': '路由前缀', + 'use-strict-auth': '严格权限校验', + 'use-multipoint': '多点登录', + 'use-redis': '启用 Redis', + 'use-mongo': '启用 MongoDB', + 'disable-auto-migrate': '禁用自动迁移', + 'signing-key': '签名密钥', + 'expires-time': '过期时间', + 'buffer-time': '缓冲时间', + issuer: '签发者', + level: '日志级别', + format: '输出格式', + director: '日志目录', + 'show-line': '显示行号', + 'log-in-console': '控制台输出', + 'retention-day': '保留天数', + 'open-captcha': '验证码开关', + 'open-captcha-timeout': '验证码超时', + 'img-width': '图片宽度', + 'img-height': '图片高度', + 'allow-origin': '允许来源', + 'allow-methods': '允许方法', + 'allow-headers': '允许请求头', + 'expose-headers': '暴露响应头', + 'allow-credentials': '允许携带凭证', + 'mount-point': '挂载点', + 'access-key-id': 'Access Key ID', + 'access-key-secret': 'Access Key Secret', + 'secret-access-key': 'Secret Access Key', + 'secret-key': 'Secret Key', + 'base-url': '基础地址', + 'base-path': '基础路径', + 'path-prefix': '路径前缀', + 'bucket-name': 'Bucket 名称', + 'bucket-url': 'Bucket 地址', + 'auth-header': '鉴权请求头', + 'request_timeout': '请求超时', +} + +const dbTypeOptions: ConfigOption[] = [ + { label: 'MySQL', value: 'mysql' }, + { label: 'PostgreSQL', value: 'pgsql' }, + { label: 'SQLite', value: 'sqlite' }, + { label: 'MSSQL', value: 'mssql' }, + { label: 'Oracle', value: 'oracle' }, +] + +const logModeOptions: ConfigOption[] = [ + { label: 'Silent', value: 'silent' }, + { label: 'Error', value: 'error' }, + { label: 'Warn', value: 'warn' }, + { label: 'Info', value: 'info' }, +] + +const configFieldSchemaMap: Record = { + 'system.db-type': { + description: '控制主业务数据库驱动类型。', + options: dbTypeOptions, + }, + 'system.oss-type': { + description: '控制上传模块实际使用的对象存储实现。', + options: [ + { label: '本地存储', value: 'local' }, + { label: 'MinIO', value: 'minio' }, + { label: '七牛云', value: 'qiniu' }, + { label: '阿里云 OSS', value: 'aliyun-oss' }, + { label: '华为云 OBS', value: 'hua-wei-obs' }, + { label: '腾讯云 COS', value: 'tencent-cos' }, + { label: 'AWS S3', value: 'aws-s3' }, + { label: 'Cloudflare R2', value: 'cloudflare-r2' }, + ], + }, + 'system.addr': { + description: '服务监听端口。', + }, + 'system.router-prefix': { + description: '接口统一前缀,留空表示不追加额外前缀。', + }, + 'system.use-strict-auth': { + description: '开启后使用树形权限分配模式。', + }, + 'system.disable-auto-migrate': { + description: '生产环境通常建议开启,避免启动时自动变更表结构。', + }, + 'jwt.signing-key': { + description: 'JWT 签名密钥。修改后旧 token 会失效。', + inputType: 'password', + }, + 'jwt.expires-time': { + description: 'JWT 过期时间,支持 7d、24h 这类时长表达。', + }, + 'jwt.buffer-time': { + description: '刷新 token 的缓冲时间窗口。', + }, + 'zap.level': { + description: '日志输出最低级别。', + options: [ + { label: 'Debug', value: 'debug' }, + { label: 'Info', value: 'info' }, + { label: 'Warn', value: 'warn' }, + { label: 'Error', value: 'error' }, + { label: 'DPanic', value: 'dpanic' }, + { label: 'Panic', value: 'panic' }, + { label: 'Fatal', value: 'fatal' }, + ], + }, + 'zap.format': { + description: 'console 更便于本地查看,json 更适合日志采集。', + options: [ + { label: 'Console', value: 'console' }, + { label: 'JSON', value: 'json' }, + ], + }, + 'zap.encode-level': { + description: '日志级别编码方式。', + options: [ + { label: 'LowercaseLevelEncoder', value: 'LowercaseLevelEncoder' }, + { label: 'LowercaseColorLevelEncoder', value: 'LowercaseColorLevelEncoder' }, + { label: 'CapitalLevelEncoder', value: 'CapitalLevelEncoder' }, + { label: 'CapitalColorLevelEncoder', value: 'CapitalColorLevelEncoder' }, + ], + }, + 'redis.addr': { + description: '单实例模式使用的 Redis 地址。', + }, + 'redis.password': { + description: 'Redis 访问密码。', + inputType: 'password', + }, + 'redis.clusterAddrs': { + description: '集群节点地址列表。', + template: '127.0.0.1:7000', + }, + 'redis-list': { + description: '多实例 Redis 配置列表。', + template: { + name: '', + addr: '127.0.0.1:6379', + password: '', + db: 0, + useCluster: false, + clusterAddrs: ['127.0.0.1:7000'], + }, + }, + 'redis-list.[].password': { + description: 'Redis 访问密码。', + inputType: 'password', + }, + 'redis-list.[].clusterAddrs': { + description: '集群节点地址列表。', + template: '127.0.0.1:7000', + }, + 'mongo.hosts': { + description: 'MongoDB 主机列表。', + template: { + host: '127.0.0.1', + port: '27017', + }, + }, + 'mongo.password': { + description: 'MongoDB 访问密码。', + inputType: 'password', + }, + 'email.secret': { + description: '邮箱 SMTP 授权码,不建议直接填写登录密码。', + inputType: 'password', + }, + 'captcha.open-captcha': { + description: '0 表示每次登录都需要验证码,其他数字表示连续错误次数阈值。', + }, + 'cors.mode': { + description: '跨域处理策略。', + options: [ + { label: 'Allow All', value: 'allow-all' }, + { label: 'Whitelist', value: 'whitelist' }, + { label: 'Strict Whitelist', value: 'strict-whitelist' }, + ], + }, + 'cors.whitelist': { + description: '跨域白名单条目。', + template: { + 'allow-origin': 'https://example.com', + 'allow-methods': 'POST, GET', + 'allow-headers': 'Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id', + 'expose-headers': 'Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type', + 'allow-credentials': true, + }, + }, + 'mysql.log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'pgsql.log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'sqlite.log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'mssql.log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'oracle.log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'db-list': { + description: '附加业务数据库列表。', + template: { + type: 'pgsql', + 'alias-name': '', + prefix: '', + port: '5432', + config: '', + 'db-name': '', + username: '', + password: '', + path: '127.0.0.1', + engine: '', + 'log-mode': 'error', + 'max-idle-conns': 10, + 'max-open-conns': 100, + singular: false, + 'log-zap': false, + disable: false, + }, + }, + 'db-list.[].type': { + description: '数据库驱动类型。', + options: dbTypeOptions, + }, + 'db-list.[].password': { + description: '数据库访问密码。', + inputType: 'password', + }, + 'db-list.[].log-mode': { + description: 'Gorm SQL 日志级别。', + options: logModeOptions, + }, + 'qiniu.zone': { + description: '七牛云存储区域。', + options: [ + { label: 'ZoneHuaDong', value: 'ZoneHuaDong' }, + { label: 'ZoneHuaBei', value: 'ZoneHuaBei' }, + { label: 'ZoneHuaNan', value: 'ZoneHuaNan' }, + { label: 'ZoneBeiMei', value: 'ZoneBeiMei' }, + { label: 'ZoneXinJiaPo', value: 'ZoneXinJiaPo' }, + ], + }, + 'disk-list': { + description: '服务器状态页读取的磁盘挂载点列表。', + template: { + 'mount-point': '/', + }, + }, +} + +function isPlainObject(value: unknown): value is ConfigMap { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function cloneConfigValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneConfigValue(item)) as T + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneConfigValue(item)]), + ) as T + } + + return value +} + +function normalizeSchemaPath(path: ConfigPath) { + return path.map((segment) => (typeof segment === 'number' ? '[]' : String(segment))).join('.') +} + +function getFieldSchema(path: ConfigPath) { + return configFieldSchemaMap[normalizeSchemaPath(path)] +} + +function formatLabel(key: string) { + if (fieldLabelMap[key]) { + return fieldLabelMap[key] + } + + return key + .split(/[-_]/) + .filter(Boolean) + .map((segment) => segment[0].toUpperCase() + segment.slice(1)) + .join(' ') +} + +function isSensitiveField(path: ConfigPath) { + return /(password|secret|signing-key|auth-header|token)/i.test(path.join('.')) +} + +function shouldUseTextArea(path: ConfigPath, value: string) { + const leaf = String(path.at(-1) || '') + return value.includes('\n') || value.length > 60 || /(headers|methods|config|options)/i.test(leaf) +} + export function SystemConfigPage() { + const [form] = Form.useForm() const [config, setConfig] = useState>({}) - const [text, setText] = useState('') const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) const summary = useMemo(() => { const system = (config.system || {}) as Record @@ -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 - 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 ( +
+ + + +
+ ) + } + + if (typeof value === 'number') { + return ( +
+ + + +
+ ) + } + + const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value) + const input = schema?.options ? ( + + ) + + return ( +
+ + {input} + +
+ ) + } + + const renderField = (namePath: ConfigPath, key: string, value: unknown): React.ReactNode => { + const fieldKey = namePath.join('.') + + if (Array.isArray(value)) { + const schema = getFieldSchema(namePath) + const sampleItem = cloneConfigValue( + schema?.template !== undefined ? schema.template : value.find((item) => item !== undefined) ?? '', + ) + + return ( +
+ + {(fields, { add, remove }) => ( + + {schema?.description ? ( + {schema.description} + ) : null} + + + } + > + {fields.length ? ( +
+ {fields.map((field, index) => { + const sourceItem = value[index] ?? sampleItem + + if (isPlainObject(sourceItem)) { + return ( + } + onClick={() => remove(field.name)} + > + 删除 + + } + > +
+ {Object.entries(sourceItem).map(([childKey, childValue]) => + renderField([...namePath, field.name, childKey], childKey, childValue), + )} +
+
+ ) + } + + return ( + } + onClick={() => remove(field.name)} + > + 删除 + + } + > + {renderPrimitiveField( + [...namePath, field.name], + key, + sourceItem, + `${formatLabel(key)} ${index + 1}`, + )} + + ) + })} +
+ ) : ( + + )} +
+ )} +
+
+ ) + } + + if (isPlainObject(value)) { + return ( +
+ +
+ {Object.entries(value).map(([childKey, childValue]) => renderField([...namePath, childKey], childKey, childValue))} +
+
+
+ ) + } + + return renderPrimitiveField(namePath, key, value) + } + + const groupItems = Object.entries(config).map(([groupKey, groupValue]) => { + const meta = groupMetaMap[groupKey] || { + title: formatLabel(groupKey), + summary: '当前分组未登记说明,使用原始配置结构渲染。', + } + + return { + key: groupKey, + label: ( +
+ {meta.title} + {meta.summary} +
+ ), + children: ( +
+
+
+ + {meta.title} + + + {meta.summary} + +
+ {groupKey} +
+
+ {isPlainObject(groupValue) + ? Object.entries(groupValue).map(([childKey, childValue]) => renderField([groupKey, childKey], childKey, childValue)) + : renderField([groupKey], groupKey, groupValue)} +
+
+ ), + } + }) + return (
@@ -48,24 +741,28 @@ export function SystemConfigPage() { 系统配置 - 当前页以原始 JSON 为主视图,优先保证配置项覆盖完整,不丢字段。 + 顶部按配置分组切换,表单直接对应服务端配置结构。保存后可以立即触发系统重新初始化,使用新配置生效。
- - + @@ -92,38 +789,9 @@ export function SystemConfigPage() { - 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: ( -
-                  {JSON.stringify(config, null, 2)}
-                
- ), - }, - ]} - /> +
+ +
) diff --git a/web-admin/src/index.css b/web-admin/src/index.css index 3dcee03..a03b457 100644 --- a/web-admin/src/index.css +++ b/web-admin/src/index.css @@ -65,18 +65,21 @@ img { .admin-shell { min-height: 100vh; display: grid; - grid-template-columns: 312px 1fr; + grid-template-columns: 300px minmax(0, 1fr); } .admin-sidebar { position: sticky; top: 0; height: 100vh; + width: 300px; + min-width: 300px; padding: 24px 18px; background: linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%); color: var(--text-light); overflow-y: auto; + overflow-x: hidden; } .admin-brand { @@ -90,9 +93,140 @@ img { } .admin-main { + min-width: 0; 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 { background: var(--surface); backdrop-filter: blur(18px); @@ -229,6 +363,11 @@ img { .catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .server-runtime-grid, + .server-disk-grid { + grid-template-columns: 1fr; + } } @media (max-width: 980px) { @@ -247,4 +386,9 @@ img { .catalog-grid { grid-template-columns: 1fr; } + + .config-form-grid, + .server-meter-grid { + grid-template-columns: 1fr; + } } diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index dbe4b4c..6a3b433 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -18,6 +18,7 @@ import type { MenuNode, OperationRecord, PagePayload, + ServerState, SysErrorRecord, SysParam, UserInfo, @@ -142,7 +143,7 @@ export const systemApi = { getSystemConfig() { return http.post<{ config: Record }>('/system/getSystemConfig') }, - setSystemConfig(payload: Record) { + setSystemConfig(payload: { config: Record }) { return http.post>('/system/setSystemConfig', payload) }, reloadSystem() { @@ -277,7 +278,7 @@ export const sysErrorApi = { export const inventoryApi = { getServerInfo() { - return http.post<{ server: Record }>('/system/getServerInfo') + return http.post<{ server: ServerState }>('/system/getServerInfo') }, } diff --git a/web-admin/src/types/system.ts b/web-admin/src/types/system.ts index 53f364d..383bcc3 100644 --- a/web-admin/src/types/system.ts +++ b/web-admin/src/types/system.ts @@ -180,6 +180,41 @@ export type JsonSchema = { 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 = { type: string text?: string