Initial commit
This commit is contained in:
1
web-admin/.env.development
Normal file
1
web-admin/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_API=/api
|
||||
1
web-admin/.env.production
Normal file
1
web-admin/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_BASE_API=/api
|
||||
24
web-admin/.gitignore
vendored
Normal file
24
web-admin/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
47
web-admin/README.md
Normal file
47
web-admin/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# web-admin
|
||||
|
||||
新的 React 管理后台。
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 方案 | 作用 | 选择原因 |
|
||||
| --- | --- | --- |
|
||||
| React 19 + Vite | 前端运行与构建 | 启动快,适合独立新后台落地 |
|
||||
| Ant Design | 表格、表单、抽屉、树控件 | 现阶段后台治理页密度高,组件成熟,交互成本低 |
|
||||
| Zustand | 会话状态 | 结构轻,适合 token、用户、菜单这类状态 |
|
||||
| React Query | 请求缓存基础设施 | 先接入,后续复杂页面可以逐步改成 query 模式 |
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认开发地址:`http://localhost:8081`
|
||||
|
||||
默认后端代理:`http://127.0.0.1:8888`
|
||||
|
||||
## 已接入页面
|
||||
|
||||
- 登录
|
||||
- 仪表盘
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 菜单管理
|
||||
- API 管理
|
||||
- 字典管理
|
||||
- 参数管理
|
||||
- 登录日志
|
||||
- 操作历史
|
||||
- 系统配置
|
||||
- API Token
|
||||
- 错误日志
|
||||
- 个人中心
|
||||
- 服务器状态
|
||||
|
||||
## 盘点文档
|
||||
|
||||
接口和功能梳理见:
|
||||
|
||||
- [`docs/system-inventory.md`](/Users/lee-mac/Desktop/rmk-new/web-admin/docs/system-inventory.md)
|
||||
191
web-admin/docs/system-inventory.md
Normal file
191
web-admin/docs/system-inventory.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 系统接口与功能梳理
|
||||
|
||||
## 菜单结构
|
||||
|
||||
后端默认菜单初始化文件:`server/source/system/menu.go`
|
||||
|
||||
### 总览
|
||||
|
||||
- `dashboard` 仪表盘
|
||||
- `about` 关于系统
|
||||
- `person` 个人中心
|
||||
- `state` 服务器状态
|
||||
|
||||
### 超级管理员
|
||||
|
||||
- `authority` 角色管理
|
||||
- `menu` 菜单管理
|
||||
- `api` API 管理
|
||||
- `user` 用户管理
|
||||
- `dictionary` 字典管理
|
||||
- `operation` 操作历史
|
||||
- `sysParams` 参数管理
|
||||
- `system` 系统配置
|
||||
- `apiToken` API Token
|
||||
- `loginLog` 登录日志
|
||||
- `sysVersion` 版本管理
|
||||
- `sysError` 错误日志
|
||||
|
||||
### 示例模块
|
||||
|
||||
- `breakpoint` 断点续传
|
||||
- `customer` 客户列表示例
|
||||
|
||||
### 公共模块
|
||||
|
||||
- `upload` 媒体库
|
||||
|
||||
### 编程辅助
|
||||
|
||||
- `autoPkg` 模板配置
|
||||
- `autoCode` 代码生成器
|
||||
- `autoCodeAdmin` 自动化代码管理
|
||||
- `formCreate` 表单生成器
|
||||
- `aiWorkflow` AI 需求工作流
|
||||
- `exportTemplate` 导出模板
|
||||
- `mcpTest` Mcp Tools 管理
|
||||
- `mcpTool` Mcp Tools 模板
|
||||
- `skills` Skills 管理
|
||||
- `picture` AI 页面绘制
|
||||
- `autoCodeEdit/:id` 隐藏编辑路由
|
||||
|
||||
## 主要接口分组
|
||||
|
||||
后端默认接口初始化文件:`server/source/system/api.go`
|
||||
|
||||
### 认证与会话
|
||||
|
||||
- `POST /base/login`
|
||||
- `POST /base/captcha`
|
||||
- `GET /user/getUserInfo`
|
||||
- `POST /jwt/jsonInBlacklist`
|
||||
- `POST /user/setUserAuthority`
|
||||
|
||||
### 用户管理
|
||||
|
||||
- `POST /user/getUserList`
|
||||
- `POST /user/admin_register`
|
||||
- `PUT /user/setUserInfo`
|
||||
- `POST /user/resetPassword`
|
||||
- `PUT /user/setSelfInfo`
|
||||
- `POST /user/changePassword`
|
||||
|
||||
### 角色与权限
|
||||
|
||||
- `POST /authority/getAuthorityList`
|
||||
- `POST /authority/createAuthority`
|
||||
- `PUT /authority/updateAuthority`
|
||||
- `POST /authority/deleteAuthority`
|
||||
- `POST /authority/copyAuthority`
|
||||
- `GET /authority/getUsersByAuthority`
|
||||
- `POST /authority/setRoleUsers`
|
||||
- `POST /menu/getMenuAuthority`
|
||||
- `POST /menu/addMenuAuthority`
|
||||
- `POST /casbin/getPolicyPathByAuthorityId`
|
||||
- `POST /casbin/updateCasbin`
|
||||
|
||||
### 菜单管理
|
||||
|
||||
- `POST /menu/getBaseMenuTree`
|
||||
- `POST /menu/getMenuList`
|
||||
- `POST /menu/addBaseMenu`
|
||||
- `POST /menu/updateBaseMenu`
|
||||
- `POST /menu/deleteBaseMenu`
|
||||
- `POST /menu/getBaseMenuById`
|
||||
- `GET /menu/getMenuRoles`
|
||||
- `POST /menu/setMenuRoles`
|
||||
|
||||
### API 管理
|
||||
|
||||
- `POST /api/getApiList`
|
||||
- `POST /api/getAllApis`
|
||||
- `POST /api/createApi`
|
||||
- `POST /api/updateApi`
|
||||
- `POST /api/deleteApi`
|
||||
- `POST /api/getApiById`
|
||||
- `GET /api/freshCasbin`
|
||||
- `GET /api/getApiRoles`
|
||||
- `POST /api/setApiRoles`
|
||||
|
||||
### 字典与参数
|
||||
|
||||
- `GET /sysDictionary/getSysDictionaryList`
|
||||
- `POST /sysDictionary/createSysDictionary`
|
||||
- `PUT /sysDictionary/updateSysDictionary`
|
||||
- `DELETE /sysDictionary/deleteSysDictionary`
|
||||
- `GET /sysDictionaryDetail/getDictionaryTreeList`
|
||||
- `POST /sysDictionaryDetail/createSysDictionaryDetail`
|
||||
- `PUT /sysDictionaryDetail/updateSysDictionaryDetail`
|
||||
- `DELETE /sysDictionaryDetail/deleteSysDictionaryDetail`
|
||||
- `GET /sysParams/getSysParamsList`
|
||||
- `POST /sysParams/createSysParams`
|
||||
- `PUT /sysParams/updateSysParams`
|
||||
- `DELETE /sysParams/deleteSysParams`
|
||||
|
||||
### 审计与运维
|
||||
|
||||
- `GET /sysLoginLog/getLoginLogList`
|
||||
- `DELETE /sysLoginLog/deleteLoginLog`
|
||||
- `DELETE /sysLoginLog/deleteLoginLogByIds`
|
||||
- `GET /sysOperationRecord/getSysOperationRecordList`
|
||||
- `DELETE /sysOperationRecord/deleteSysOperationRecord`
|
||||
- `DELETE /sysOperationRecord/deleteSysOperationRecordByIds`
|
||||
- `POST /system/getSystemConfig`
|
||||
- `POST /system/setSystemConfig`
|
||||
- `POST /system/reloadSystem`
|
||||
- `POST /system/getServerInfo`
|
||||
- `POST /sysApiToken/getApiTokenList`
|
||||
- `POST /sysApiToken/createApiToken`
|
||||
- `POST /sysApiToken/deleteApiToken`
|
||||
- `GET /sysError/getSysErrorList`
|
||||
- `PUT /sysError/updateSysError`
|
||||
- `DELETE /sysError/deleteSysError`
|
||||
- `GET /sysError/getSysErrorSolution`
|
||||
|
||||
## React 新后台覆盖情况
|
||||
|
||||
### 已做成可用页
|
||||
|
||||
- 登录
|
||||
- 仪表盘
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 菜单管理
|
||||
- API 管理
|
||||
- 字典管理
|
||||
- 参数管理
|
||||
- 登录日志
|
||||
- 操作历史
|
||||
- 系统配置
|
||||
- API Token
|
||||
- 错误日志
|
||||
- 个人中心
|
||||
- 服务器状态
|
||||
|
||||
### 已做成模块入口页
|
||||
|
||||
- 关于系统
|
||||
- 版本管理
|
||||
- 媒体库
|
||||
- 断点续传
|
||||
- 客户列表示例
|
||||
- 模板配置
|
||||
- 代码生成器
|
||||
- 自动化代码管理
|
||||
- 表单生成器
|
||||
- AI 需求工作流
|
||||
- 导出模板
|
||||
- Mcp Tools 管理
|
||||
- Mcp Tools 模板
|
||||
- Skills 管理
|
||||
- AI 页面绘制
|
||||
- 插件系统
|
||||
- 插件安装
|
||||
- 打包插件
|
||||
- 邮件插件
|
||||
- 公告管理
|
||||
|
||||
## 说明
|
||||
|
||||
- 新后台没有修改后端协议,仍然复用原有 token、菜单、权限和接口格式。
|
||||
- 当前仍有部分研发辅助类重模块保留为入口页,原因不是无法实现,而是这些模块交互面太大,适合单独拆阶段继续重构。
|
||||
23
web-admin/eslint.config.js
Normal file
23
web-admin/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web-admin/index.html
Normal file
13
web-admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4732
web-admin/package-lock.json
generated
Normal file
4732
web-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web-admin/package.json
Normal file
39
web-admin/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "web-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"antd": "^6.3.5",
|
||||
"axios": "^1.14.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
1
web-admin/public/favicon.svg
Normal file
1
web-admin/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web-admin/public/icons.svg
Normal file
24
web-admin/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
198
web-admin/src/App.tsx
Normal file
198
web-admin/src/App.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Alert, Result, Spin } from 'antd'
|
||||
import { authApi, menuApi } from '@/lib/api'
|
||||
import { buildFullMenus, findDefaultRoute, flattenMenus, isExternalMenu } from '@/lib/menu'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { AdminShell } from '@/features/layout/AdminShell'
|
||||
import { DashboardPage } from '@/features/dashboard/DashboardPage'
|
||||
import { ModuleLandingPage } from '@/features/discovery/ModuleLandingPage'
|
||||
import { ProfilePage } from '@/features/person/ProfilePage'
|
||||
import { ServerStatePage } from '@/features/server/ServerStatePage'
|
||||
import { UserManagementPage } from '@/features/users/UserManagementPage'
|
||||
import { RoleManagementPage } from '@/features/roles/RoleManagementPage'
|
||||
import { MenuManagementPage } from '@/features/menus/MenuManagementPage'
|
||||
import { ApiManagementPage } from '@/features/apis/ApiManagementPage'
|
||||
import { DictionaryManagementPage } from '@/features/dictionaries/DictionaryManagementPage'
|
||||
import { ParamsManagementPage } from '@/features/params/ParamsManagementPage'
|
||||
import { LoginLogPage } from '@/features/logs/LoginLogPage'
|
||||
import { OperationLogPage } from '@/features/logs/OperationLogPage'
|
||||
import { SystemConfigPage } from '@/features/system/SystemConfigPage'
|
||||
import { ApiTokenPage } from '@/features/tokens/ApiTokenPage'
|
||||
import { ErrorLogPage } from '@/features/errors/ErrorLogPage'
|
||||
|
||||
type AppRoute = {
|
||||
path: string
|
||||
menuName: string
|
||||
element: React.ReactNode
|
||||
}
|
||||
|
||||
const appRoutes: AppRoute[] = [
|
||||
{ path: 'dashboard', menuName: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'about', menuName: 'about', element: <ModuleLandingPage moduleName="about" /> },
|
||||
{ path: 'admin', menuName: 'superAdmin', element: <ModuleLandingPage moduleName="superAdmin" /> },
|
||||
{ path: 'admin/authority', menuName: 'authority', element: <RoleManagementPage /> },
|
||||
{ path: 'admin/menu', menuName: 'menu', element: <MenuManagementPage /> },
|
||||
{ path: 'admin/api', menuName: 'api', element: <ApiManagementPage /> },
|
||||
{ path: 'admin/user', menuName: 'user', element: <UserManagementPage /> },
|
||||
{ path: 'admin/dictionary', menuName: 'dictionary', element: <DictionaryManagementPage /> },
|
||||
{ path: 'admin/operation', menuName: 'operation', element: <OperationLogPage /> },
|
||||
{ path: 'admin/sysParams', menuName: 'sysParams', element: <ParamsManagementPage /> },
|
||||
{ path: 'admin/system', menuName: 'system', element: <SystemConfigPage /> },
|
||||
{ path: 'admin/apiToken', menuName: 'apiToken', element: <ApiTokenPage /> },
|
||||
{ path: 'admin/loginLog', menuName: 'loginLog', element: <LoginLogPage /> },
|
||||
{ path: 'admin/sysVersion', menuName: 'sysVersion', element: <ModuleLandingPage moduleName="sysVersion" /> },
|
||||
{ path: 'admin/sysError', menuName: 'sysError', element: <ErrorLogPage /> },
|
||||
{ path: 'common', menuName: 'common', element: <ModuleLandingPage moduleName="common" /> },
|
||||
{ path: 'common/upload', menuName: 'upload', element: <ModuleLandingPage moduleName="upload" /> },
|
||||
{ path: 'example', menuName: 'example', element: <ModuleLandingPage moduleName="example" /> },
|
||||
{ path: 'example/breakpoint', menuName: 'breakpoint', element: <ModuleLandingPage moduleName="breakpoint" /> },
|
||||
{ path: 'example/customer', menuName: 'customer', element: <ModuleLandingPage moduleName="customer" /> },
|
||||
{ path: 'systemTools', menuName: 'systemTools', element: <ModuleLandingPage moduleName="systemTools" /> },
|
||||
{ path: 'systemTools/autoPkg', menuName: 'autoPkg', element: <ModuleLandingPage moduleName="autoPkg" /> },
|
||||
{ path: 'systemTools/autoCode', menuName: 'autoCode', element: <ModuleLandingPage moduleName="autoCode" /> },
|
||||
{ path: 'systemTools/autoCodeAdmin', menuName: 'autoCodeAdmin', element: <ModuleLandingPage moduleName="autoCodeAdmin" /> },
|
||||
{ path: 'systemTools/formCreate', menuName: 'formCreate', element: <ModuleLandingPage moduleName="formCreate" /> },
|
||||
{ path: 'systemTools/aiWorkflow', menuName: 'aiWorkflow', element: <ModuleLandingPage moduleName="aiWorkflow" /> },
|
||||
{ path: 'systemTools/exportTemplate', menuName: 'exportTemplate', element: <ModuleLandingPage moduleName="exportTemplate" /> },
|
||||
{ path: 'systemTools/mcpTest', menuName: 'mcpTest', element: <ModuleLandingPage moduleName="mcpTest" /> },
|
||||
{ path: 'systemTools/mcpTool', menuName: 'mcpTool', element: <ModuleLandingPage moduleName="mcpTool" /> },
|
||||
{ path: 'systemTools/skills', menuName: 'skills', element: <ModuleLandingPage moduleName="skills" /> },
|
||||
{ path: 'systemTools/picture', menuName: 'picture', element: <ModuleLandingPage moduleName="picture" /> },
|
||||
{ path: 'systemTools/autoCodeEdit/:id', menuName: 'autoCodeEdit', element: <ModuleLandingPage moduleName="autoCodeEdit" /> },
|
||||
{ path: 'person', menuName: 'person', element: <ProfilePage /> },
|
||||
{ path: 'state', menuName: 'state', element: <ServerStatePage /> },
|
||||
]
|
||||
|
||||
function BootstrapGate() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const menus = useAuthStore((state) => state.menus)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const setMenus = useAuthStore((state) => state.setMenus)
|
||||
const clearSession = useAuthStore((state) => state.clearSession)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!token) {
|
||||
navigate('/login', { replace: true, state: { redirectTo: location.pathname } })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
|
||||
if (ignore) {
|
||||
return
|
||||
}
|
||||
|
||||
setUser(userInfo.data.userInfo)
|
||||
setMenus(buildFullMenus(menuRes.data.menus))
|
||||
setLoadError(null)
|
||||
} catch (error) {
|
||||
if (ignore) {
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '会话恢复失败'
|
||||
clearSession()
|
||||
setLoadError(message)
|
||||
navigate('/login', { replace: true })
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [clearSession, location.pathname, navigate, setMenus, setUser, token])
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (loading && menus.length === 0) {
|
||||
return (
|
||||
<div className="fullscreen-status">
|
||||
<Spin size="large" />
|
||||
<span>正在恢复管理台会话...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="fullscreen-status">
|
||||
<Alert type="error" message="会话恢复失败" description={loadError} showIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function RouteGuard({ route }: { route: AppRoute }) {
|
||||
const menus = useAuthStore((state) => state.menus)
|
||||
const allowedPaths = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
flattenMenus(menus)
|
||||
.filter((item) => !isExternalMenu(item))
|
||||
.map((item) => item.fullPath),
|
||||
),
|
||||
[menus],
|
||||
)
|
||||
|
||||
if (!allowedPaths.has(`/${route.path}`)) {
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="当前角色无权访问此页面"
|
||||
subTitle="这个路由仍然存在于系统内,但当前角色没有对应菜单授权。"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{route.element}</>
|
||||
}
|
||||
|
||||
function LayoutFrame() {
|
||||
const menus = useAuthStore((state) => state.menus)
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const defaultPath = findDefaultRoute(menus, user?.authority?.defaultRouter)
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={defaultPath || '/dashboard'} />} />
|
||||
{appRoutes.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={<RouteGuard route={route} />} />
|
||||
))}
|
||||
<Route path="*" element={<Result status="404" title="页面不存在" subTitle="请从左侧导航重新进入页面。" />} />
|
||||
</Routes>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<BootstrapGate />}>
|
||||
<Route path="/*" element={<LayoutFrame />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
BIN
web-admin/src/assets/hero.png
Normal file
BIN
web-admin/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
web-admin/src/assets/react.svg
Normal file
1
web-admin/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
web-admin/src/assets/vite.svg
Normal file
1
web-admin/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
318
web-admin/src/features/apis/ApiManagementPage.tsx
Normal file
318
web-admin/src/features/apis/ApiManagementPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { apiRegistryApi, authorityApi } from '@/lib/api'
|
||||
import { flattenAuthorities } from '@/lib/tree'
|
||||
import type { ApiRecord, Authority } from '@/types/system'
|
||||
|
||||
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
|
||||
export function ApiManagementPage() {
|
||||
const [searchForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [apis, setApis] = useState<ApiRecord[]>([])
|
||||
const [roles, setRoles] = useState<Authority[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingApi, setEditingApi] = useState<ApiRecord | null>(null)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [activeApi, setActiveApi] = useState<ApiRecord | null>(null)
|
||||
const [selectedRoles, setSelectedRoles] = useState<number[]>([])
|
||||
const [savingRoles, setSavingRoles] = useState(false)
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
flattenAuthorities(roles).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
})),
|
||||
[roles],
|
||||
)
|
||||
|
||||
const apiGroupOptions = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(apis.map((item) => item.apiGroup)))
|
||||
.filter(Boolean)
|
||||
.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
[apis],
|
||||
)
|
||||
|
||||
const reloadApis = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [apiRes, roleRes] = await Promise.all([
|
||||
apiRegistryApi.getApiList({
|
||||
page,
|
||||
pageSize,
|
||||
...searchForm.getFieldsValue(),
|
||||
}),
|
||||
authorityApi.getAuthorityList(),
|
||||
])
|
||||
setApis(apiRes.data.list)
|
||||
setTotal(apiRes.data.total)
|
||||
setRoles(roleRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
reloadApis()
|
||||
}, [reloadApis])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingApi(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({ method: 'POST' })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: ApiRecord) => {
|
||||
const response = await apiRegistryApi.getApiById(record.ID)
|
||||
setEditingApi(response.data.api)
|
||||
editForm.setFieldsValue(response.data.api)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const saveApi = async () => {
|
||||
const values = await editForm.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingApi) {
|
||||
await apiRegistryApi.updateApi({
|
||||
ID: editingApi.ID,
|
||||
...values,
|
||||
})
|
||||
message.success('接口已更新')
|
||||
} else {
|
||||
await apiRegistryApi.createApi(values)
|
||||
message.success('接口已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
reloadApis()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApi = (record: ApiRecord) => {
|
||||
Modal.confirm({
|
||||
title: `删除接口 ${record.path}`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await apiRegistryApi.deleteApi({ ID: record.ID })
|
||||
message.success('接口已删除')
|
||||
reloadApis()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openRoleDrawer = async (record: ApiRecord) => {
|
||||
const response = await apiRegistryApi.getApiRoles(record.path, record.method)
|
||||
setActiveApi(record)
|
||||
setSelectedRoles(response.data)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const saveRoles = async () => {
|
||||
if (!activeApi) {
|
||||
return
|
||||
}
|
||||
setSavingRoles(true)
|
||||
try {
|
||||
await apiRegistryApi.setApiRoles({
|
||||
path: activeApi.path,
|
||||
method: activeApi.method,
|
||||
authorityIds: selectedRoles,
|
||||
})
|
||||
message.success('接口角色关系已更新')
|
||||
setDrawerOpen(false)
|
||||
} finally {
|
||||
setSavingRoles(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<ApiRecord> = [
|
||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
||||
{ title: '路径', dataIndex: 'path', width: 260 },
|
||||
{ title: '分组', dataIndex: 'apiGroup', width: 160 },
|
||||
{ title: '描述', dataIndex: 'description', width: 240 },
|
||||
{
|
||||
title: '方法',
|
||||
dataIndex: 'method',
|
||||
width: 100,
|
||||
render: (value: string) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : value === 'PUT' ? 'orange' : 'red'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openRoleDrawer(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteApi(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
API 管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页覆盖接口目录、接口元数据和接口角色关系。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await apiRegistryApi.freshCasbin()
|
||||
message.success('Casbin 缓存已刷新')
|
||||
}}
|
||||
>
|
||||
刷新 Casbin
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建接口
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadApis() }}>
|
||||
<Row gutter={[16, 0]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="path" label="路径">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={4}>
|
||||
<Form.Item name="apiGroup" label="分组">
|
||||
<Select allowClear options={apiGroupOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={4}>
|
||||
<Form.Item name="method" label="方法">
|
||||
<Select allowClear options={methodOptions.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
reloadApis()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={apis}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingApi ? '编辑接口' : '新建接口'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveApi}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="path" label="路径" rules={[{ required: true, message: '请输入路径' }]}>
|
||||
<Input placeholder="/api/example" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述" rules={[{ required: true, message: '请输入描述' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="apiGroup" label="分组" rules={[{ required: true, message: '请输入分组' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="method" label="方法" rules={[{ required: true, message: '请选择方法' }]}>
|
||||
<Select options={methodOptions.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title={activeApi ? `接口角色分配 · ${activeApi.path}` : '接口角色分配'}
|
||||
width={520}
|
||||
extra={
|
||||
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedRoles}
|
||||
options={roleOptions}
|
||||
onChange={setSelectedRoles}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
web-admin/src/features/auth/LoginPage.tsx
Normal file
183
web-admin/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Alert, Button, Card, Form, Input, Typography, message } from 'antd'
|
||||
import { authApi, menuApi } from '@/lib/api'
|
||||
import { buildFullMenus, findDefaultRoute } from '@/lib/menu'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { CaptchaInfo } from '@/types/system'
|
||||
|
||||
type LoginForm = {
|
||||
username: string
|
||||
password: string
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
function normalizeCaptchaSrc(picPath?: string) {
|
||||
if (!picPath) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (picPath.startsWith('data:image')) {
|
||||
return picPath
|
||||
}
|
||||
|
||||
return `data:image/png;base64,${picPath}`
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const applySession = useAuthStore((state) => state.applySession)
|
||||
const setMenus = useAuthStore((state) => state.setMenus)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [form] = Form.useForm<LoginForm>()
|
||||
const [captcha, setCaptcha] = useState<CaptchaInfo | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loadCaptchaError, setLoadCaptchaError] = useState<string | null>(null)
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const state = location.state as { redirectTo?: string } | null
|
||||
return state?.redirectTo
|
||||
}, [location.state])
|
||||
|
||||
const fetchCaptcha = async () => {
|
||||
try {
|
||||
const response = await authApi.getCaptcha()
|
||||
setCaptcha(response.data)
|
||||
setLoadCaptchaError(null)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : '验证码加载失败'
|
||||
setLoadCaptchaError(messageText)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptcha()
|
||||
}, [])
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const loginResponse = await authApi.login({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
captcha: values.captcha,
|
||||
captchaId: captcha?.captchaId,
|
||||
})
|
||||
|
||||
applySession({
|
||||
token: loginResponse.data.token,
|
||||
user: loginResponse.data.user,
|
||||
})
|
||||
|
||||
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
|
||||
const menus = buildFullMenus(menuRes.data.menus)
|
||||
setUser(userInfo.data.userInfo)
|
||||
setMenus(menus)
|
||||
message.success('登录成功')
|
||||
|
||||
const fallback = findDefaultRoute(menus, userInfo.data.userInfo.authority?.defaultRouter) || '/dashboard'
|
||||
navigate(redirectTarget || fallback, { replace: true })
|
||||
} catch {
|
||||
form.setFieldValue('captcha', '')
|
||||
fetchCaptcha()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Gin-Vue-Admin · React 重设计版</span>
|
||||
<div style={{ maxWidth: 620, marginTop: 72 }}>
|
||||
<Typography.Title style={{ color: 'rgba(255,255,255,0.96)', fontSize: 52, marginBottom: 18 }}>
|
||||
把现有后端协议
|
||||
<br />
|
||||
接成一套更清晰的管理后台
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.78)', fontSize: 18, lineHeight: 1.8 }}>
|
||||
当前登录页兼容原有验证码与 JWT 流程。登录成功后会直接拉取真实菜单树,按当前角色动态生成可访问页面。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-form-wrap">
|
||||
<Card className="glass-panel login-card" bordered={false}>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
登录管理后台
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted">
|
||||
首版默认沿用原系统认证协议,避免后端改造。
|
||||
</Typography.Paragraph>
|
||||
{loadCaptchaError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="验证码加载失败"
|
||||
description={loadCaptchaError}
|
||||
action={
|
||||
<Button size="small" onClick={fetchCaptcha}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
username: 'admin',
|
||||
}}
|
||||
onFinish={submit}
|
||||
>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, message: '请输入用户名' }]}>
|
||||
<Input size="large" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '请输入至少 6 位密码' }]}>
|
||||
<Input.Password size="large" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
{captcha?.openCaptcha ? (
|
||||
<Form.Item
|
||||
name="captcha"
|
||||
label={`验证码(${captcha.captchaLength} 位)`}
|
||||
rules={[{ required: true, len: captcha.captchaLength, message: `请输入 ${captcha.captchaLength} 位验证码` }]}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<div
|
||||
onClick={fetchCaptcha}
|
||||
style={{
|
||||
width: 128,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(16, 37, 66, 0.12)',
|
||||
background: '#f4efe8',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="captcha"
|
||||
src={normalizeCaptchaSrc(captcha.picPath)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Button block size="large" type="primary" htmlType="submit" loading={submitting}>
|
||||
进入后台
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
web-admin/src/features/dashboard/DashboardPage.tsx
Normal file
123
web-admin/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Card, Col, List, Progress, Row, Statistic, Tag, Typography } from 'antd'
|
||||
import { moduleList } from '@/features/discovery/systemInventory'
|
||||
import { flattenMenus } from '@/lib/menu'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export function DashboardPage() {
|
||||
const menus = useAuthStore((state) => state.menus)
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
const flatMenus = flattenMenus(menus)
|
||||
const implementedCount = moduleList.filter((item) => item.status === 'ready').length
|
||||
const partialCount = moduleList.filter((item) => item.status === 'partial').length
|
||||
const allowedModules = flatMenus.length
|
||||
const allEndpoints = moduleList.reduce((total, item) => total + item.endpoints.length, 0)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="hero-grid">
|
||||
<div>
|
||||
<Tag color="processing" style={{ marginBottom: 12 }}>
|
||||
React 管理后台重构中
|
||||
</Tag>
|
||||
<Typography.Title level={2} style={{ marginBottom: 12 }}>
|
||||
{user?.nickName || user?.userName || '管理员'},欢迎回来
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ maxWidth: 720, marginBottom: 20 }}>
|
||||
新后台已经基于真实菜单树和后端接口协议接管路由结构。当前仪表盘会直接反映已有菜单权限、模块覆盖进度和需要继续迁移的能力。
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Card bordered={false} style={{ background: 'var(--accent-soft)' }}>
|
||||
<Statistic title="当前角色默认首页" value={user?.authority?.defaultRouter || 'dashboard'} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card bordered={false} style={{ background: 'var(--success-soft)' }}>
|
||||
<Statistic title="可访问内部菜单" value={allowedModules} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<Card bordered={false} style={{ background: 'rgba(16, 37, 66, 0.04)' }}>
|
||||
<Typography.Title level={4}>重构覆盖率</Typography.Title>
|
||||
<Progress
|
||||
percent={Math.round((implementedCount / moduleList.length) * 100)}
|
||||
strokeColor="#d16f3f"
|
||||
/>
|
||||
<List
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
dataSource={[
|
||||
`已接入模块 ${implementedCount} 个`,
|
||||
`待继续迁移 ${partialCount} 个`,
|
||||
`模块登记总数 ${moduleList.length} 个`,
|
||||
`清点出的接口触点 ${allEndpoints} 个`,
|
||||
]}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="metric-grid">
|
||||
<div className="metric-card">
|
||||
<Typography.Text className="text-muted">模块总数</Typography.Text>
|
||||
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
|
||||
{moduleList.length}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<Typography.Text className="text-muted">已接入</Typography.Text>
|
||||
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
|
||||
{implementedCount}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<Typography.Text className="text-muted">待继续</Typography.Text>
|
||||
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
|
||||
{partialCount}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<Typography.Text className="text-muted">已登记接口触点</Typography.Text>
|
||||
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
|
||||
{allEndpoints}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="glass-panel page-panel" title="模块清单">
|
||||
<div className="catalog-grid">
|
||||
{moduleList.map((item) => (
|
||||
<div className="catalog-card" key={item.name}>
|
||||
<div className="section-heading" style={{ marginBottom: 8 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{item.title}
|
||||
</Typography.Title>
|
||||
<span className={`status-dot ${item.status}`}></span>
|
||||
</div>
|
||||
<Typography.Paragraph className="text-muted" style={{ minHeight: 66 }}>
|
||||
{item.summary}
|
||||
</Typography.Paragraph>
|
||||
<Tag>{item.group}</Tag>
|
||||
<Tag bordered={false}>{item.endpoints.length} 个接口触点</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel" title="当前角色菜单">
|
||||
<List
|
||||
dataSource={flatMenus.filter((item) => !item.hidden)}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={item.meta.title}
|
||||
description={`${item.name} · ${item.fullPath}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
web-admin/src/features/dictionaries/DictionaryManagementPage.tsx
Normal file
334
web-admin/src/features/dictionaries/DictionaryManagementPage.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { dictionaryApi } from '@/lib/api'
|
||||
import type { Dictionary, DictionaryDetail } from '@/types/system'
|
||||
|
||||
export function DictionaryManagementPage() {
|
||||
const [dictForm] = Form.useForm()
|
||||
const [detailForm] = Form.useForm()
|
||||
const [dictionaries, setDictionaries] = useState<Dictionary[]>([])
|
||||
const [activeDictionary, setActiveDictionary] = useState<Dictionary | null>(null)
|
||||
const [details, setDetails] = useState<DictionaryDetail[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dictModalOpen, setDictModalOpen] = useState(false)
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||
const [editingDictionary, setEditingDictionary] = useState<Dictionary | null>(null)
|
||||
const [editingDetail, setEditingDetail] = useState<DictionaryDetail | null>(null)
|
||||
|
||||
const reloadDictionaries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await dictionaryApi.getDictionaryList()
|
||||
const list = response.data
|
||||
setDictionaries(list)
|
||||
if (!activeDictionary && list.length) {
|
||||
setActiveDictionary(list[0])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeDictionary])
|
||||
|
||||
const reloadDetails = useCallback(async (dictionary?: Dictionary | null) => {
|
||||
const target = dictionary || activeDictionary
|
||||
if (!target) {
|
||||
setDetails([])
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dictionaryApi.getDictionaryTree(target.ID)
|
||||
setDetails(response.data.list)
|
||||
}, [activeDictionary])
|
||||
|
||||
useEffect(() => {
|
||||
reloadDictionaries()
|
||||
}, [reloadDictionaries])
|
||||
|
||||
useEffect(() => {
|
||||
reloadDetails(activeDictionary)
|
||||
}, [activeDictionary, reloadDetails])
|
||||
|
||||
const openCreateDictionary = () => {
|
||||
setEditingDictionary(null)
|
||||
dictForm.resetFields()
|
||||
dictForm.setFieldsValue({ status: true })
|
||||
setDictModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditDictionary = (record: Dictionary) => {
|
||||
setEditingDictionary(record)
|
||||
dictForm.setFieldsValue({
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
desc: record.desc,
|
||||
status: record.status ?? true,
|
||||
parentID: record.parentID,
|
||||
})
|
||||
setDictModalOpen(true)
|
||||
}
|
||||
|
||||
const saveDictionary = async () => {
|
||||
const values = await dictForm.validateFields()
|
||||
const payload = {
|
||||
ID: editingDictionary?.ID,
|
||||
...values,
|
||||
}
|
||||
|
||||
if (editingDictionary) {
|
||||
await dictionaryApi.updateDictionary(payload)
|
||||
message.success('字典已更新')
|
||||
} else {
|
||||
await dictionaryApi.createDictionary(payload)
|
||||
message.success('字典已创建')
|
||||
}
|
||||
setDictModalOpen(false)
|
||||
reloadDictionaries()
|
||||
}
|
||||
|
||||
const deleteDictionary = (record: Dictionary) => {
|
||||
Modal.confirm({
|
||||
title: `删除字典 ${record.name}`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await dictionaryApi.deleteDictionary({ ID: record.ID })
|
||||
message.success('字典已删除')
|
||||
if (activeDictionary?.ID === record.ID) {
|
||||
setActiveDictionary(null)
|
||||
}
|
||||
reloadDictionaries()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openCreateDetail = () => {
|
||||
if (!activeDictionary) {
|
||||
message.warning('请先选择字典')
|
||||
return
|
||||
}
|
||||
setEditingDetail(null)
|
||||
detailForm.resetFields()
|
||||
detailForm.setFieldsValue({
|
||||
sysDictionaryID: activeDictionary.ID,
|
||||
status: true,
|
||||
sort: 1,
|
||||
})
|
||||
setDetailModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditDetail = (record: DictionaryDetail) => {
|
||||
setEditingDetail(record)
|
||||
detailForm.setFieldsValue({
|
||||
label: record.label,
|
||||
value: record.value,
|
||||
extend: record.extend,
|
||||
status: record.status ?? true,
|
||||
sort: record.sort,
|
||||
parentID: record.parentID,
|
||||
})
|
||||
setDetailModalOpen(true)
|
||||
}
|
||||
|
||||
const saveDetail = async () => {
|
||||
if (!activeDictionary) {
|
||||
return
|
||||
}
|
||||
const values = await detailForm.validateFields()
|
||||
const payload = {
|
||||
ID: editingDetail?.ID,
|
||||
sysDictionaryID: activeDictionary.ID,
|
||||
...values,
|
||||
}
|
||||
if (editingDetail) {
|
||||
await dictionaryApi.updateDictionaryDetail(payload)
|
||||
message.success('字典项已更新')
|
||||
} else {
|
||||
await dictionaryApi.createDictionaryDetail(payload)
|
||||
message.success('字典项已创建')
|
||||
}
|
||||
setDetailModalOpen(false)
|
||||
reloadDetails()
|
||||
}
|
||||
|
||||
const deleteDetail = (record: DictionaryDetail) => {
|
||||
Modal.confirm({
|
||||
title: `删除字典项 ${record.label}`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await dictionaryApi.deleteDictionaryDetail({ ID: record.ID })
|
||||
message.success('字典项已删除')
|
||||
reloadDetails()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const detailColumns: ColumnsType<DictionaryDetail> = [
|
||||
{ title: '展示值', dataIndex: 'label', width: 180 },
|
||||
{ title: '字典值', dataIndex: 'value', width: 180 },
|
||||
{ title: '扩展值', dataIndex: 'extend', width: 180 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_, record) => (record.status ? '启用' : '停用'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openEditDetail(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteDetail(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
字典管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页覆盖字典主表和树形字典项管理。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={openCreateDetail} disabled={!activeDictionary}>
|
||||
新建字典项
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreateDictionary}>
|
||||
新建字典
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Row gutter={20}>
|
||||
<Col xs={24} xl={8}>
|
||||
<Card title="字典列表" bordered={false}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{dictionaries.map((item) => (
|
||||
<Card
|
||||
key={item.ID}
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => setActiveDictionary(item)}
|
||||
style={{
|
||||
borderColor: activeDictionary?.ID === item.ID ? '#d16f3f' : undefined,
|
||||
}}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" onClick={(event) => { event.stopPropagation(); openEditDictionary(item) }}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger type="link" size="small" onClick={(event) => { event.stopPropagation(); deleteDictionary(item) }}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Text strong>{item.name}</Typography.Text>
|
||||
<div className="text-muted">{item.type}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={16}>
|
||||
<Card
|
||||
title={activeDictionary ? `字典项 · ${activeDictionary.name}` : '字典项'}
|
||||
bordered={false}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
rowKey="ID"
|
||||
columns={detailColumns}
|
||||
dataSource={details}
|
||||
pagination={false}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={dictModalOpen}
|
||||
title={editingDictionary ? '编辑字典' : '新建字典'}
|
||||
onCancel={() => setDictModalOpen(false)}
|
||||
onOk={saveDictionary}
|
||||
>
|
||||
<Form form={dictForm} layout="vertical">
|
||||
<Form.Item name="parentID" label="父级字典">
|
||||
<Select allowClear options={dictionaries.map((item) => ({ label: `${item.name} (${item.type})`, value: item.ID }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="字典名(中)" rules={[{ required: true, message: '请输入字典名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="字典名(英)" rules={[{ required: true, message: '请输入字典类型' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="desc" label="描述">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={detailModalOpen}
|
||||
title={editingDetail ? '编辑字典项' : '新建字典项'}
|
||||
onCancel={() => setDetailModalOpen(false)}
|
||||
onOk={saveDetail}
|
||||
>
|
||||
<Form form={detailForm} layout="vertical">
|
||||
<Form.Item name="parentID" label="父级字典项">
|
||||
<Select
|
||||
allowClear
|
||||
options={details.map((item) => ({ label: item.label, value: item.ID }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="展示值" rules={[{ required: true, message: '请输入展示值' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="字典值" rules={[{ required: true, message: '请输入字典值' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="extend" label="扩展值">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
web-admin/src/features/discovery/ModuleLandingPage.tsx
Normal file
69
web-admin/src/features/discovery/ModuleLandingPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Card, List, Tag, Typography } from 'antd'
|
||||
import { moduleCatalog } from './systemInventory'
|
||||
|
||||
const statusLabel = {
|
||||
ready: '已接入',
|
||||
partial: '待继续',
|
||||
planned: '待迁移',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
moduleName: string
|
||||
}
|
||||
|
||||
export function ModuleLandingPage({ moduleName }: Props) {
|
||||
const moduleInfo = moduleCatalog[moduleName]
|
||||
|
||||
if (!moduleInfo) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Typography.Title level={3}>未登记模块</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted">
|
||||
当前路由已经进入新后台,但模块注册表还没有补充它的功能描述。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
{moduleInfo.title}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
{moduleInfo.summary}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Tag color={moduleInfo.status === 'ready' ? 'green' : moduleInfo.status === 'partial' ? 'orange' : 'default'}>
|
||||
{statusLabel[moduleInfo.status]}
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="hero-grid">
|
||||
<Card className="glass-panel page-panel" title="模块职责">
|
||||
<List
|
||||
dataSource={moduleInfo.features}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel" title="关联接口">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={moduleInfo.endpoints}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<code>{item}</code>
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{ emptyText: '当前页面没有直接绑定接口。' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
web-admin/src/features/discovery/systemInventory.ts
Normal file
303
web-admin/src/features/discovery/systemInventory.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type { ModuleDescriptor } from '@/types/system'
|
||||
|
||||
export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
||||
dashboard: {
|
||||
name: 'dashboard',
|
||||
title: '仪表盘',
|
||||
group: '总览',
|
||||
status: 'ready',
|
||||
summary: '展示系统结构、路由覆盖情况和后端模块分布。',
|
||||
features: ['菜单覆盖总览', 'API 分组概览', '已重构模块状态', '快捷入口'],
|
||||
endpoints: ['/menu/getMenu', '/user/getUserInfo', '/system/getServerInfo'],
|
||||
},
|
||||
about: {
|
||||
name: 'about',
|
||||
title: '关于系统',
|
||||
group: '总览',
|
||||
status: 'partial',
|
||||
summary: '用于展示项目来源、版本背景和运维提示。',
|
||||
features: ['项目说明', '技术栈梳理', '外部链接入口'],
|
||||
endpoints: [],
|
||||
},
|
||||
superAdmin: {
|
||||
name: 'superAdmin',
|
||||
title: '超级管理员',
|
||||
group: '平台治理',
|
||||
status: 'partial',
|
||||
summary: '聚合用户、角色、菜单、接口、字典和参数等平台治理功能。',
|
||||
features: ['权限治理', '配置管理', '审计日志', 'Token 管理'],
|
||||
endpoints: ['/authority/getAuthorityList', '/menu/getBaseMenuTree', '/api/getApiList'],
|
||||
},
|
||||
authority: {
|
||||
name: 'authority',
|
||||
title: '角色管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理角色树、默认首页、菜单权限、API 权限和角色用户关系。',
|
||||
features: ['角色树', '菜单授权', 'API 授权', '角色分配用户'],
|
||||
endpoints: ['/authority/getAuthorityList', '/menu/addMenuAuthority', '/casbin/updateCasbin', '/authority/setRoleUsers'],
|
||||
},
|
||||
menu: {
|
||||
name: 'menu',
|
||||
title: '菜单管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '维护后台菜单树、路由属性、按钮定义和角色关联。',
|
||||
features: ['菜单树编辑', '路由路径维护', '角色分配', '按钮权限入口'],
|
||||
endpoints: ['/menu/getBaseMenuTree', '/menu/addBaseMenu', '/menu/updateBaseMenu', '/menu/setMenuRoles'],
|
||||
},
|
||||
api: {
|
||||
name: 'api',
|
||||
title: 'API 管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理接口目录、接口角色授权和 Casbin 刷新。',
|
||||
features: ['接口列表', '接口增删改', '角色授权', '接口分组'],
|
||||
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/setApiRoles'],
|
||||
},
|
||||
user: {
|
||||
name: 'user',
|
||||
title: '用户管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理账号、启停状态、角色集合、密码和头像等资料。',
|
||||
features: ['用户列表', '新建用户', '重置密码', '角色切换'],
|
||||
endpoints: ['/user/getUserList', '/user/admin_register', '/user/setUserInfo', '/user/resetPassword'],
|
||||
},
|
||||
dictionary: {
|
||||
name: 'dictionary',
|
||||
title: '字典管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理字典主表与树形字典项,支持导入导出。',
|
||||
features: ['字典列表', '树形详情', '导入导出', '状态控制'],
|
||||
endpoints: ['/sysDictionary/getSysDictionaryList', '/sysDictionary/exportSysDictionary', '/sysDictionaryDetail/getDictionaryTreeList'],
|
||||
},
|
||||
operation: {
|
||||
name: 'operation',
|
||||
title: '操作历史',
|
||||
group: '审计',
|
||||
status: 'ready',
|
||||
summary: '查看系统请求日志、响应体和异常信息。',
|
||||
features: ['操作审计', '请求明细', '批量删除'],
|
||||
endpoints: ['/sysOperationRecord/getSysOperationRecordList', '/sysOperationRecord/deleteSysOperationRecordByIds'],
|
||||
},
|
||||
sysParams: {
|
||||
name: 'sysParams',
|
||||
title: '参数管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理运行期参数键值,供前后端按键读取。',
|
||||
features: ['参数列表', '键值维护', '说明管理'],
|
||||
endpoints: ['/sysParams/getSysParamsList', '/sysParams/createSysParams', '/sysParams/updateSysParams'],
|
||||
},
|
||||
system: {
|
||||
name: 'system',
|
||||
title: '系统配置',
|
||||
group: '运维',
|
||||
status: 'ready',
|
||||
summary: '编辑服务配置文件并触发系统重载。',
|
||||
features: ['配置读取', '配置保存', '服务重载'],
|
||||
endpoints: ['/system/getSystemConfig', '/system/setSystemConfig', '/system/reloadSystem'],
|
||||
},
|
||||
apiToken: {
|
||||
name: 'apiToken',
|
||||
title: 'API Token',
|
||||
group: '运维',
|
||||
status: 'ready',
|
||||
summary: '为用户签发临时 JWT,并追踪失效状态和过期时间。',
|
||||
features: ['Token 列表', '签发 Token', '作废 Token'],
|
||||
endpoints: ['/sysApiToken/getApiTokenList', '/sysApiToken/createApiToken', '/sysApiToken/deleteApiToken'],
|
||||
},
|
||||
loginLog: {
|
||||
name: 'loginLog',
|
||||
title: '登录日志',
|
||||
group: '审计',
|
||||
status: 'ready',
|
||||
summary: '审计登录成功与失败事件,定位登录问题。',
|
||||
features: ['登录结果', '失败原因', '设备信息'],
|
||||
endpoints: ['/sysLoginLog/getLoginLogList', '/sysLoginLog/deleteLoginLogByIds'],
|
||||
},
|
||||
sysVersion: {
|
||||
name: 'sysVersion',
|
||||
title: '版本管理',
|
||||
group: '运维',
|
||||
status: 'partial',
|
||||
summary: '围绕版本导出、导入和回滚的发布管理模块。',
|
||||
features: ['版本列表', '版本导出', '版本同步'],
|
||||
endpoints: ['/sysVersion/getSysVersionList', '/sysVersion/exportVersion', '/sysVersion/importVersion'],
|
||||
},
|
||||
sysError: {
|
||||
name: 'sysError',
|
||||
title: '错误日志',
|
||||
group: '审计',
|
||||
status: 'ready',
|
||||
summary: '管理系统错误记录和 AI 处理状态。',
|
||||
features: ['错误列表', '状态流转', 'AI 处理触发'],
|
||||
endpoints: ['/sysError/getSysErrorList', '/sysError/getSysErrorSolution', '/sysError/updateSysError'],
|
||||
},
|
||||
common: {
|
||||
name: 'common',
|
||||
title: '公共能力',
|
||||
group: '公共模块',
|
||||
status: 'partial',
|
||||
summary: '承载多端复用的公共功能,不再挂在示例目录下。',
|
||||
features: ['公共上传能力', '后续 app 复用入口'],
|
||||
endpoints: ['/fileUploadAndDownload/getFileList', '/attachmentCategory/getCategoryList'],
|
||||
},
|
||||
example: {
|
||||
name: 'example',
|
||||
title: '示例文件',
|
||||
group: '业务示例',
|
||||
status: 'partial',
|
||||
summary: '保留和业务演示直接相关的示例页面。',
|
||||
features: ['断点续传', '客户资源示例'],
|
||||
endpoints: ['/customer/customerList'],
|
||||
},
|
||||
upload: {
|
||||
name: 'upload',
|
||||
title: '媒体库',
|
||||
group: '公共模块',
|
||||
status: 'partial',
|
||||
summary: '管理上传文件和资源分类,后续可直接复用到 app 模块。',
|
||||
features: ['文件列表', '文件上传', '分类管理'],
|
||||
endpoints: ['/fileUploadAndDownload/getFileList', '/attachmentCategory/getCategoryList'],
|
||||
},
|
||||
breakpoint: {
|
||||
name: 'breakpoint',
|
||||
title: '断点续传',
|
||||
group: '业务示例',
|
||||
status: 'partial',
|
||||
summary: '展示大文件切片上传流程。',
|
||||
features: ['文件校验', '分片上传', '合并完成'],
|
||||
endpoints: ['/fileUploadAndDownload/findFile', '/fileUploadAndDownload/breakpointContinue'],
|
||||
},
|
||||
customer: {
|
||||
name: 'customer',
|
||||
title: '客户列表',
|
||||
group: '业务示例',
|
||||
status: 'partial',
|
||||
summary: '示例业务资源表,用于展示普通 CRUD 模式。',
|
||||
features: ['客户列表', '增删改查'],
|
||||
endpoints: ['/customer/customerList', '/customer/customer'],
|
||||
},
|
||||
systemTools: {
|
||||
name: 'systemTools',
|
||||
title: '编程辅助',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '聚合代码生成、模板配置、技能管理和 AI 工作流。',
|
||||
features: ['代码生成器', '模板配置', 'Skills 管理', 'AI 页面绘制'],
|
||||
endpoints: ['/autoCode/getDB', '/skills/getSkillList'],
|
||||
},
|
||||
autoPkg: {
|
||||
name: 'autoPkg',
|
||||
title: '模板配置',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '维护自动代码生成模板。',
|
||||
features: ['模板列表', '模板文件'],
|
||||
endpoints: ['/autoCode/createPackage', '/autoCode/getPackage', '/autoCode/getTemplates'],
|
||||
},
|
||||
autoCode: {
|
||||
name: 'autoCode',
|
||||
title: '代码生成器',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '自动生成表单、接口、服务和路由代码。',
|
||||
features: ['库表发现', '代码预览', '代码生成'],
|
||||
endpoints: ['/autoCode/getDB', '/autoCode/getTables', '/autoCode/preview', '/autoCode/createTemp'],
|
||||
},
|
||||
autoCodeAdmin: {
|
||||
name: 'autoCodeAdmin',
|
||||
title: '自动化代码管理',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '管理自动化代码任务和函数模板。',
|
||||
features: ['任务管理', '模板方法'],
|
||||
endpoints: ['/autoCode/getMeta', '/autoCode/addFunc'],
|
||||
},
|
||||
formCreate: {
|
||||
name: 'formCreate',
|
||||
title: '表单生成器',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '拖拽式表单构建入口。',
|
||||
features: ['表单设计'],
|
||||
endpoints: [],
|
||||
},
|
||||
aiWorkflow: {
|
||||
name: 'aiWorkflow',
|
||||
title: 'AI 需求工作流',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '管理 AI 会话和需求工作流结果。',
|
||||
features: ['会话列表', '会话详情', 'Markdown 落盘'],
|
||||
endpoints: ['/autoCode/getAIWorkflowSessionList', '/autoCode/getAIWorkflowSessionDetail'],
|
||||
},
|
||||
exportTemplate: {
|
||||
name: 'exportTemplate',
|
||||
title: '导出模板',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '维护 Excel 导入导出模板。',
|
||||
features: ['模板管理', 'SQL 预览'],
|
||||
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/sysExportTemplate/previewSQL'],
|
||||
},
|
||||
mcpTest: {
|
||||
name: 'mcpTest',
|
||||
title: 'Mcp Tools 管理',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '围绕 MCP Tool 的状态、测试和服务控制。',
|
||||
features: ['服务状态', '测试调用'],
|
||||
endpoints: ['/autoCode/mcpStatus', '/autoCode/mcpTest', '/autoCode/mcpList'],
|
||||
},
|
||||
mcpTool: {
|
||||
name: 'mcpTool',
|
||||
title: 'Mcp Tools 模板',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '生成 MCP Tool 模板代码。',
|
||||
features: ['模板生成'],
|
||||
endpoints: ['/autoCode/mcp'],
|
||||
},
|
||||
skills: {
|
||||
name: 'skills',
|
||||
title: 'Skills 管理',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '管理技能定义、脚本、资源、模板和全局约束。',
|
||||
features: ['技能列表', '脚本资源管理', '技能打包'],
|
||||
endpoints: ['/skills/getSkillList', '/skills/saveSkill', '/skills/packageSkill'],
|
||||
},
|
||||
picture: {
|
||||
name: 'picture',
|
||||
title: 'AI 页面绘制',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '基于 AI 描述生成页面草图。',
|
||||
features: ['图像工作台'],
|
||||
endpoints: ['/autoCode/llmAuto'],
|
||||
},
|
||||
person: {
|
||||
name: 'person',
|
||||
title: '个人中心',
|
||||
group: '个人',
|
||||
status: 'ready',
|
||||
summary: '维护当前登录人的资料与密码。',
|
||||
features: ['资料更新', '密码修改'],
|
||||
endpoints: ['/user/setSelfInfo', '/user/changePassword'],
|
||||
},
|
||||
state: {
|
||||
name: 'state',
|
||||
title: '服务器状态',
|
||||
group: '运维',
|
||||
status: 'ready',
|
||||
summary: '读取后端运行状态与资源信息。',
|
||||
features: ['原始状态树', '运行信息'],
|
||||
endpoints: ['/system/getServerInfo'],
|
||||
},
|
||||
}
|
||||
|
||||
export const moduleList = Object.values(moduleCatalog)
|
||||
184
web-admin/src/features/errors/ErrorLogPage.tsx
Normal file
184
web-admin/src/features/errors/ErrorLogPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { sysErrorApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { SysErrorRecord } from '@/types/system'
|
||||
|
||||
export function ErrorLogPage() {
|
||||
const [editForm] = Form.useForm()
|
||||
const [rows, setRows] = useState<SysErrorRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [editingRow, setEditingRow] = useState<SysErrorRecord | null>(null)
|
||||
|
||||
const reloadRows = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await sysErrorApi.getErrorList({
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
setRows(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
reloadRows()
|
||||
}, [reloadRows])
|
||||
|
||||
const saveRow = async () => {
|
||||
const values = await editForm.validateFields()
|
||||
if (!editingRow) {
|
||||
return
|
||||
}
|
||||
await sysErrorApi.updateError({
|
||||
...editingRow,
|
||||
...values,
|
||||
})
|
||||
message.success('错误日志已更新')
|
||||
setEditingRow(null)
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const columns: ColumnsType<SysErrorRecord> = [
|
||||
{
|
||||
title: '时间',
|
||||
width: 180,
|
||||
render: (_, record) => formatDate(record.CreatedAt),
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'form',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '等级',
|
||||
dataIndex: 'level',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 120,
|
||||
render: (_, record) => <Tag>{record.status || '未处理'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '错误内容',
|
||||
dataIndex: 'info',
|
||||
},
|
||||
{
|
||||
title: '解决方案',
|
||||
dataIndex: 'solution',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setEditingRow(record)
|
||||
editForm.setFieldsValue({
|
||||
status: record.status,
|
||||
solution: record.solution,
|
||||
})
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
await sysErrorApi.handleError(record.ID)
|
||||
message.success('AI 处理任务已触发')
|
||||
reloadRows()
|
||||
}}
|
||||
>
|
||||
AI 处理
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
await sysErrorApi.deleteError(record.ID)
|
||||
message.success('错误日志已删除')
|
||||
reloadRows()
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
错误日志
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页提供错误列表、状态更新和 AI 处理触发入口。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal open={Boolean(editingRow)} title="编辑错误日志" onCancel={() => setEditingRow(null)} onOk={saveRow}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="status" label="处理状态">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '未处理', value: '未处理' },
|
||||
{ label: '处理中', value: '处理中' },
|
||||
{ label: '处理完成', value: '处理完成' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="solution" label="解决方案">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
web-admin/src/features/layout/AdminShell.tsx
Normal file
208
web-admin/src/features/layout/AdminShell.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Dropdown, Menu, Select, Space, Typography, message } from 'antd'
|
||||
import type { ItemType } from 'antd/es/menu/interface'
|
||||
import {
|
||||
ApiOutlined,
|
||||
AppstoreOutlined,
|
||||
BookOutlined,
|
||||
CodeOutlined,
|
||||
DashboardOutlined,
|
||||
LockOutlined,
|
||||
PartitionOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { authApi, menuApi } from '@/lib/api'
|
||||
import { buildFullMenus, findDefaultRoute, flattenMenus, isExternalMenu } from '@/lib/menu'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { AppMenu } from '@/types/system'
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
dashboard: <DashboardOutlined />,
|
||||
authority: <LockOutlined />,
|
||||
menu: <PartitionOutlined />,
|
||||
api: <ApiOutlined />,
|
||||
user: <UserOutlined />,
|
||||
dictionary: <BookOutlined />,
|
||||
sysParams: <SettingOutlined />,
|
||||
system: <SettingOutlined />,
|
||||
apiToken: <LockOutlined />,
|
||||
loginLog: <AppstoreOutlined />,
|
||||
systemTools: <CodeOutlined />,
|
||||
common: <BookOutlined />,
|
||||
}
|
||||
|
||||
function buildMenuItems(menus: AppMenu[]): ItemType[] {
|
||||
return menus
|
||||
.filter((menu) => !menu.hidden)
|
||||
.map((menu) => {
|
||||
const icon = iconMap[menu.name] || <AppstoreOutlined />
|
||||
|
||||
if (menu.children?.length) {
|
||||
return {
|
||||
key: menu.fullPathKey || menu.name,
|
||||
icon,
|
||||
label: menu.meta.title,
|
||||
children: buildMenuItems(menu.children),
|
||||
} satisfies ItemType
|
||||
}
|
||||
|
||||
return {
|
||||
key: menu.fullPathKey || menu.name,
|
||||
icon,
|
||||
label: menu.meta.title,
|
||||
} satisfies ItemType
|
||||
})
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AdminShell({ children }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const menus = useAuthStore((state) => state.menus)
|
||||
const clearSession = useAuthStore((state) => state.clearSession)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const setMenus = useAuthStore((state) => state.setMenus)
|
||||
const selectedKey = location.pathname.replace(/^\//, '')
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const target = flattenMenus(menus).find((menu) => menu.fullPath === location.pathname)
|
||||
if (!target) {
|
||||
return []
|
||||
}
|
||||
|
||||
const chain: string[] = []
|
||||
if (target.parentName) {
|
||||
const parent = flattenMenus(menus).find((menu) => menu.name === target.parentName)
|
||||
if (parent) {
|
||||
chain.push(parent.meta.title)
|
||||
}
|
||||
}
|
||||
chain.push(target.meta.title)
|
||||
return chain
|
||||
}, [location.pathname, menus])
|
||||
|
||||
const menuItems = useMemo(() => buildMenuItems(menus), [menus])
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
const target = flattenMenus(menus).find((menu) => menu.fullPathKey === key)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isExternalMenu(target)) {
|
||||
window.open(target.path, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
navigate(target.fullPath)
|
||||
}
|
||||
|
||||
const handleRoleSwitch = async (value: number) => {
|
||||
try {
|
||||
await authApi.changeAuthority(value)
|
||||
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
|
||||
const mappedMenus = buildFullMenus(menuRes.data.menus)
|
||||
setUser(userInfo.data.userInfo)
|
||||
setMenus(mappedMenus)
|
||||
message.success('角色切换成功')
|
||||
navigate(findDefaultRoute(mappedMenus, userInfo.data.userInfo.authority?.defaultRouter) || '/dashboard')
|
||||
} catch {
|
||||
message.error('角色切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// 忽略退出失败,统一清会话。
|
||||
} finally {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-brand">
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
|
||||
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
|
||||
RMK Control Deck
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
|
||||
基于现有 Gin 后端协议构建的新管理台。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ background: 'transparent', borderInlineEnd: 'none' }}
|
||||
/>
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<div className="page-stack">
|
||||
<div className="glass-panel page-panel" style={{ padding: 20 }}>
|
||||
<div className="section-heading" style={{ marginBottom: 4 }}>
|
||||
<div>
|
||||
<Typography.Text className="text-muted">
|
||||
{breadcrumb.length ? breadcrumb.join(' / ') : '后台总览'}
|
||||
</Typography.Text>
|
||||
<Typography.Title level={3} style={{ margin: '8px 0 0' }}>
|
||||
{breadcrumb.at(-1) || '管理后台'}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Space size="middle" wrap>
|
||||
{user?.authorities?.length ? (
|
||||
<Select
|
||||
value={user.authority?.authorityId}
|
||||
options={user.authorities.map((item) => ({
|
||||
label: `${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
}))}
|
||||
style={{ minWidth: 220 }}
|
||||
onChange={handleRoleSwitch}
|
||||
/>
|
||||
) : null}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/person'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" style={{ height: 'auto', padding: 0 }}>
|
||||
<Space>
|
||||
<Avatar src={user?.headerImg} icon={<UserOutlined />} />
|
||||
<span>{user?.nickName || user?.userName || '未登录'}</span>
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
337
web-admin/src/features/menus/MenuManagementPage.tsx
Normal file
337
web-admin/src/features/menus/MenuManagementPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { authorityApi, menuApi } from '@/lib/api'
|
||||
import { flattenAuthorities, flattenMenusForOptions } from '@/lib/tree'
|
||||
import type { Authority, MenuNode } from '@/types/system'
|
||||
|
||||
export function MenuManagementPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [menus, setMenus] = useState<MenuNode[]>([])
|
||||
const [roles, setRoles] = useState<Authority[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingMenu, setEditingMenu] = useState<MenuNode | null>(null)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [activeMenu, setActiveMenu] = useState<MenuNode | null>(null)
|
||||
const [selectedRoles, setSelectedRoles] = useState<number[]>([])
|
||||
const [defaultRouterRoles, setDefaultRouterRoles] = useState<number[]>([])
|
||||
const [savingRoles, setSavingRoles] = useState(false)
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
flattenAuthorities(roles).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
})),
|
||||
[roles],
|
||||
)
|
||||
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
flattenMenusForOptions(menus).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.meta.title}`,
|
||||
value: item.ID,
|
||||
})),
|
||||
[menus],
|
||||
)
|
||||
|
||||
const reloadMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [menuRes, roleRes] = await Promise.all([menuApi.getBaseMenuTree(), authorityApi.getAuthorityList()])
|
||||
setMenus(menuRes.data.menus)
|
||||
setRoles(roleRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadMenus()
|
||||
}, [])
|
||||
|
||||
const openCreate = (parentId = 0) => {
|
||||
setEditingMenu(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
parentId,
|
||||
sort: 1,
|
||||
hidden: false,
|
||||
keepAlive: false,
|
||||
closeTab: false,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: MenuNode) => {
|
||||
const response = await menuApi.getBaseMenuById(record.ID)
|
||||
const menu = response.data.menu
|
||||
setEditingMenu(menu)
|
||||
form.setFieldsValue({
|
||||
parentId: menu.parentId,
|
||||
title: menu.meta.title,
|
||||
name: menu.name,
|
||||
path: menu.path,
|
||||
component: menu.component,
|
||||
icon: menu.meta.icon,
|
||||
sort: menu.sort,
|
||||
hidden: menu.hidden,
|
||||
keepAlive: menu.meta.keepAlive,
|
||||
closeTab: menu.meta.closeTab,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const saveMenu = async () => {
|
||||
const values = await form.validateFields()
|
||||
const payload = {
|
||||
ID: editingMenu?.ID,
|
||||
parentId: values.parentId || 0,
|
||||
name: values.name,
|
||||
path: values.path,
|
||||
component: values.component,
|
||||
sort: values.sort,
|
||||
hidden: Boolean(values.hidden),
|
||||
meta: {
|
||||
title: values.title,
|
||||
icon: values.icon,
|
||||
keepAlive: Boolean(values.keepAlive),
|
||||
closeTab: Boolean(values.closeTab),
|
||||
},
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingMenu) {
|
||||
await menuApi.updateBaseMenu(payload)
|
||||
message.success('菜单已更新')
|
||||
} else {
|
||||
await menuApi.addBaseMenu(payload)
|
||||
message.success('菜单已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
reloadMenus()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMenu = (record: MenuNode) => {
|
||||
Modal.confirm({
|
||||
title: `删除菜单 ${record.meta.title}`,
|
||||
content: '如果该菜单已被角色使用,删除前请先调整菜单授权。',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await menuApi.deleteBaseMenu(record.ID)
|
||||
message.success('菜单已删除')
|
||||
reloadMenus()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openRoleDrawer = async (record: MenuNode) => {
|
||||
const response = await menuApi.getMenuRoles(record.ID)
|
||||
setActiveMenu(record)
|
||||
setSelectedRoles(response.data.authorityIds)
|
||||
setDefaultRouterRoles(response.data.defaultRouterAuthorityIds)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const saveRoles = async () => {
|
||||
if (!activeMenu) {
|
||||
return
|
||||
}
|
||||
setSavingRoles(true)
|
||||
try {
|
||||
await menuApi.setMenuRoles({
|
||||
menuId: activeMenu.ID,
|
||||
authorityIds: selectedRoles,
|
||||
})
|
||||
message.success('菜单角色关系已更新')
|
||||
setDrawerOpen(false)
|
||||
} finally {
|
||||
setSavingRoles(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<MenuNode> = [
|
||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
||||
{
|
||||
title: '展示名称',
|
||||
width: 180,
|
||||
render: (_, record) => record.meta.title,
|
||||
},
|
||||
{ title: '路由 Name', dataIndex: 'name', width: 140 },
|
||||
{ title: '路由 Path', dataIndex: 'path', width: 180 },
|
||||
{ title: '组件路径', dataIndex: 'component', width: 280 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Tag color={record.hidden ? 'default' : 'green'}>{record.hidden ? '隐藏' : '显示'}</Tag>
|
||||
{record.meta.keepAlive ? <Tag color="blue">KeepAlive</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 260,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Button type="link" onClick={() => openCreate(record.ID)}>
|
||||
添加子菜单
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openRoleDrawer(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteMenu(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
菜单管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页直接维护后端基础菜单树,保证新 React 后台与原权限模型保持一致。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => openCreate()}>
|
||||
新建根菜单
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1300 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingMenu ? '编辑菜单' : '新建菜单'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveMenu}
|
||||
confirmLoading={saving}
|
||||
width={760}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="parentId" label="父菜单">
|
||||
<Select options={[{ label: '根菜单', value: 0 }, ...menuOptions]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="展示名称" rules={[{ required: true, message: '请输入展示名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="路由 Name"
|
||||
rules={[{ required: true, message: '请输入路由 Name' }]}
|
||||
style={{ width: '50%' }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="path"
|
||||
label="路由 Path"
|
||||
rules={[{ required: true, message: '请输入路由 Path' }]}
|
||||
style={{ width: '50%' }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item
|
||||
name="component"
|
||||
label="组件路径"
|
||||
rules={[{ required: true, message: '请输入组件路径' }]}
|
||||
>
|
||||
<Input placeholder="例如:view/superAdmin/user/user.vue" />
|
||||
</Form.Item>
|
||||
<Space.Compact block>
|
||||
<Form.Item name="icon" label="图标名" style={{ width: '60%' }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" style={{ width: '40%' }}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
<Space size="large">
|
||||
<Form.Item name="hidden" label="隐藏" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="keepAlive" label="KeepAlive" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTab" label="自动关闭 Tab" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title={activeMenu ? `菜单角色分配 · ${activeMenu.meta.title}` : '菜单角色分配'}
|
||||
width={520}
|
||||
extra={
|
||||
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{defaultRouterRoles.length ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`有 ${defaultRouterRoles.length} 个角色将当前菜单设为默认首页,调整前请确认首页策略。`}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedRoles}
|
||||
options={roleOptions}
|
||||
onChange={setSelectedRoles}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
web-admin/src/features/params/ParamsManagementPage.tsx
Normal file
201
web-admin/src/features/params/ParamsManagementPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { sysParamsApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { SysParam } from '@/types/system'
|
||||
|
||||
export function ParamsManagementPage() {
|
||||
const [searchForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [params, setParams] = useState<SysParam[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingParam, setEditingParam] = useState<SysParam | null>(null)
|
||||
|
||||
const reloadParams = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await sysParamsApi.getParamsList({
|
||||
page,
|
||||
pageSize,
|
||||
...searchForm.getFieldsValue(),
|
||||
})
|
||||
setParams(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
reloadParams()
|
||||
}, [reloadParams])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingParam(null)
|
||||
editForm.resetFields()
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: SysParam) => {
|
||||
setEditingParam(record)
|
||||
editForm.setFieldsValue(record)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const saveParam = async () => {
|
||||
const values = await editForm.validateFields()
|
||||
if (editingParam) {
|
||||
await sysParamsApi.updateParam({ ID: editingParam.ID, ...values })
|
||||
message.success('参数已更新')
|
||||
} else {
|
||||
await sysParamsApi.createParam(values)
|
||||
message.success('参数已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
reloadParams()
|
||||
}
|
||||
|
||||
const deleteParam = (record: SysParam) => {
|
||||
Modal.confirm({
|
||||
title: `删除参数 ${record.key}`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await sysParamsApi.deleteParam(record.ID)
|
||||
message.success('参数已删除')
|
||||
reloadParams()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<SysParam> = [
|
||||
{
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
render: (_, record) => formatDate(record.CreatedAt),
|
||||
},
|
||||
{ title: '参数名称', dataIndex: 'name', width: 160 },
|
||||
{ title: '参数键', dataIndex: 'key', width: 180 },
|
||||
{ title: '参数值', dataIndex: 'value' },
|
||||
{ title: '说明', dataIndex: 'desc', width: 200 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteParam(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
参数管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页维护运行期键值参数,保持与原系统的参数读取方式一致。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建参数
|
||||
</Button>
|
||||
</div>
|
||||
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadParams() }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="name" label="参数名称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="key" label="参数键">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
reloadParams()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={params}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingParam ? '编辑参数' : '新建参数'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveParam}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="参数名称" rules={[{ required: true, message: '请输入参数名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="key" label="参数键" rules={[{ required: true, message: '请输入参数键' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="参数值" rules={[{ required: true, message: '请输入参数值' }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="desc" label="说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
web-admin/src/features/person/ProfilePage.tsx
Normal file
123
web-admin/src/features/person/ProfilePage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Avatar, Button, Card, Col, Form, Input, Row, Typography, message } from 'antd'
|
||||
import { authApi } from '@/lib/api'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export function ProfilePage() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [profileForm] = Form.useForm()
|
||||
const [passwordForm] = Form.useForm()
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
const [savingPassword, setSavingPassword] = useState(false)
|
||||
|
||||
const submitProfile = async () => {
|
||||
const values = await profileForm.validateFields()
|
||||
setSavingProfile(true)
|
||||
try {
|
||||
await authApi.setSelfInfo({
|
||||
ID: user?.ID,
|
||||
nickName: values.nickName,
|
||||
phone: values.phone,
|
||||
email: values.email,
|
||||
headerImg: values.headerImg,
|
||||
})
|
||||
setUser({
|
||||
...(user || {}),
|
||||
nickName: values.nickName,
|
||||
phone: values.phone,
|
||||
email: values.email,
|
||||
headerImg: values.headerImg,
|
||||
} as typeof user)
|
||||
message.success('个人资料已更新')
|
||||
} finally {
|
||||
setSavingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitPassword = async () => {
|
||||
const values = await passwordForm.validateFields()
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
await authApi.changePassword({
|
||||
password: values.password,
|
||||
newPassword: values.newPassword,
|
||||
})
|
||||
passwordForm.resetFields()
|
||||
message.success('密码已修改')
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
个人中心
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页面使用后端自身资料接口,不依赖额外菜单配置。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Avatar src={user?.headerImg} size={72}>
|
||||
{user?.nickName?.[0] || user?.userName?.[0]}
|
||||
</Avatar>
|
||||
</div>
|
||||
</Card>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} xl={14}>
|
||||
<Card className="glass-panel page-panel" title="资料维护">
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={profileForm}
|
||||
initialValues={{
|
||||
userName: user?.userName,
|
||||
nickName: user?.nickName,
|
||||
phone: user?.phone,
|
||||
email: user?.email,
|
||||
headerImg: user?.headerImg,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="用户名" name="userName">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="昵称" name="nickName" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱" name="email">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="头像 URL" name="headerImg">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={savingProfile} onClick={submitProfile}>
|
||||
保存资料
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={10}>
|
||||
<Card className="glass-panel page-panel" title="修改密码">
|
||||
<Form layout="vertical" form={passwordForm}>
|
||||
<Form.Item label="原密码" name="password" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={savingPassword} onClick={submitPassword}>
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
397
web-admin/src/features/roles/RoleManagementPage.tsx
Normal file
397
web-admin/src/features/roles/RoleManagementPage.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { DataNode } from 'antd/es/tree'
|
||||
import { apiRegistryApi, authorityApi, casbinApi, menuApi, userApi } from '@/lib/api'
|
||||
import { collectCheckedLeafMenus, flattenAuthorities } from '@/lib/tree'
|
||||
import type { ApiRecord, Authority, MenuNode, UserInfo } from '@/types/system'
|
||||
|
||||
type PermissionTab = 'menus' | 'apis' | 'users'
|
||||
|
||||
function mapMenusToTree(menus: MenuNode[]): DataNode[] {
|
||||
return menus.map((menu) => ({
|
||||
key: menu.ID,
|
||||
title: menu.meta.title,
|
||||
children: mapMenusToTree(menu.children || []),
|
||||
}))
|
||||
}
|
||||
|
||||
function mapApisToTree(apis: ApiRecord[]): DataNode[] {
|
||||
const grouped = apis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
|
||||
if (!accumulator[api.apiGroup]) {
|
||||
accumulator[api.apiGroup] = []
|
||||
}
|
||||
accumulator[api.apiGroup].push(api)
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
return Object.entries(grouped).map(([group, items]) => ({
|
||||
key: `group:${group}`,
|
||||
title: `${group} 组`,
|
||||
children: items.map((item) => ({
|
||||
key: `${item.path}::${item.method}`,
|
||||
title: `${item.method} ${item.path} · ${item.description}`,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export function RoleManagementPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [roles, setRoles] = useState<Authority[]>([])
|
||||
const [menuTree, setMenuTree] = useState<MenuNode[]>([])
|
||||
const [apis, setApis] = useState<ApiRecord[]>([])
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [roleModalOpen, setRoleModalOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [savingRole, setSavingRole] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<PermissionTab>('menus')
|
||||
const [editingRole, setEditingRole] = useState<Authority | null>(null)
|
||||
const [copySource, setCopySource] = useState<Authority | null>(null)
|
||||
const [activeRole, setActiveRole] = useState<Authority | null>(null)
|
||||
const [menuChecked, setMenuChecked] = useState<number[]>([])
|
||||
const [defaultRouter, setDefaultRouter] = useState<string>()
|
||||
const [apiChecked, setApiChecked] = useState<string[]>([])
|
||||
const [userIds, setUserIds] = useState<number[]>([])
|
||||
const [savingPermission, setSavingPermission] = useState(false)
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
flattenAuthorities(roles).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
})),
|
||||
[roles],
|
||||
)
|
||||
|
||||
const reloadRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await authorityApi.getAuthorityList()
|
||||
setRoles(response.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadRoles()
|
||||
}, [])
|
||||
|
||||
const openCreate = (parentId?: number) => {
|
||||
setEditingRole(null)
|
||||
setCopySource(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ parentId: parentId ?? 0 })
|
||||
setRoleModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: Authority) => {
|
||||
setEditingRole(record)
|
||||
setCopySource(null)
|
||||
form.setFieldsValue({
|
||||
authorityId: record.authorityId,
|
||||
authorityName: record.authorityName,
|
||||
parentId: record.parentId ?? 0,
|
||||
})
|
||||
setRoleModalOpen(true)
|
||||
}
|
||||
|
||||
const openCopy = (record: Authority) => {
|
||||
setEditingRole(null)
|
||||
setCopySource(record)
|
||||
form.setFieldsValue({
|
||||
authorityId: undefined,
|
||||
authorityName: `${record.authorityName}-副本`,
|
||||
parentId: record.parentId ?? 0,
|
||||
})
|
||||
setRoleModalOpen(true)
|
||||
}
|
||||
|
||||
const saveRole = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSavingRole(true)
|
||||
try {
|
||||
if (copySource) {
|
||||
await authorityApi.copyAuthority({
|
||||
oldAuthorityId: copySource.authorityId,
|
||||
authority: {
|
||||
authorityId: values.authorityId,
|
||||
authorityName: values.authorityName,
|
||||
parentId: values.parentId,
|
||||
},
|
||||
})
|
||||
message.success('角色已复制')
|
||||
} else if (editingRole) {
|
||||
await authorityApi.updateAuthority({
|
||||
authorityId: editingRole.authorityId,
|
||||
authorityName: values.authorityName,
|
||||
parentId: values.parentId,
|
||||
defaultRouter: editingRole.defaultRouter,
|
||||
})
|
||||
message.success('角色已更新')
|
||||
} else {
|
||||
await authorityApi.createAuthority({
|
||||
authorityId: values.authorityId,
|
||||
authorityName: values.authorityName,
|
||||
parentId: values.parentId,
|
||||
})
|
||||
message.success('角色已创建')
|
||||
}
|
||||
|
||||
setRoleModalOpen(false)
|
||||
reloadRoles()
|
||||
} finally {
|
||||
setSavingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRole = (record: Authority) => {
|
||||
Modal.confirm({
|
||||
title: `删除角色 ${record.authorityName}`,
|
||||
content: '删除前请确认没有用户正在依赖该角色。',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await authorityApi.deleteAuthority(record.authorityId)
|
||||
message.success('角色已删除')
|
||||
reloadRoles()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openPermissionDrawer = async (record: Authority) => {
|
||||
setActiveRole(record)
|
||||
setDrawerOpen(true)
|
||||
const [menuRes, checkedMenusRes, apiRes, policyRes, userRes, roleUserRes] = await Promise.all([
|
||||
menuApi.getBaseMenuTree(),
|
||||
menuApi.getMenuAuthority(record.authorityId),
|
||||
apiRegistryApi.getAllApis(),
|
||||
casbinApi.getPolicyPathByAuthorityId(record.authorityId),
|
||||
userApi.getUserList({ page: 1, pageSize: 999 }),
|
||||
authorityApi.getUsersByAuthorityId(record.authorityId),
|
||||
])
|
||||
|
||||
setMenuTree(menuRes.data.menus)
|
||||
setMenuChecked(
|
||||
checkedMenusRes.data.menus.map((item) => Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID)),
|
||||
)
|
||||
setDefaultRouter(record.defaultRouter)
|
||||
setApis(apiRes.data.apis)
|
||||
setApiChecked(policyRes.data.paths.map((item) => `${item.path}::${item.method}`))
|
||||
setUsers(userRes.data.list)
|
||||
setUserIds(roleUserRes.data)
|
||||
}
|
||||
|
||||
const saveCurrentPermission = async () => {
|
||||
if (!activeRole) {
|
||||
return
|
||||
}
|
||||
setSavingPermission(true)
|
||||
try {
|
||||
if (activeTab === 'menus') {
|
||||
const checkedMenus = collectCheckedLeafMenus(menuTree, new Set(menuChecked))
|
||||
await menuApi.addMenuAuthority({
|
||||
authorityId: activeRole.authorityId,
|
||||
menus: checkedMenus,
|
||||
})
|
||||
await authorityApi.updateAuthority({
|
||||
authorityId: activeRole.authorityId,
|
||||
authorityName: activeRole.authorityName,
|
||||
parentId: activeRole.parentId,
|
||||
defaultRouter,
|
||||
})
|
||||
message.success('菜单权限已保存')
|
||||
} else if (activeTab === 'apis') {
|
||||
const selectedApis = apis
|
||||
.filter((item) => apiChecked.includes(`${item.path}::${item.method}`))
|
||||
.map((item) => ({ path: item.path, method: item.method }))
|
||||
await casbinApi.updateCasbin({
|
||||
authorityId: activeRole.authorityId,
|
||||
casbinInfos: selectedApis,
|
||||
})
|
||||
message.success('API 权限已保存')
|
||||
} else {
|
||||
await authorityApi.setRoleUsers({
|
||||
authorityId: activeRole.authorityId,
|
||||
userIds,
|
||||
})
|
||||
message.success('角色用户关系已保存')
|
||||
}
|
||||
reloadRoles()
|
||||
} finally {
|
||||
setSavingPermission(false)
|
||||
}
|
||||
}
|
||||
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
collectCheckedLeafMenus(menuTree, new Set(menuChecked)).map((menu) => ({
|
||||
label: menu.meta.title,
|
||||
value: menu.name,
|
||||
})),
|
||||
[menuChecked, menuTree],
|
||||
)
|
||||
|
||||
const columns: ColumnsType<Authority> = [
|
||||
{
|
||||
title: '角色 ID',
|
||||
dataIndex: 'authorityId',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'authorityName',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '默认首页',
|
||||
dataIndex: 'defaultRouter',
|
||||
width: 140,
|
||||
render: (value: string | undefined) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Button type="link" onClick={() => openPermissionDrawer(record)}>
|
||||
设置权限
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openCreate(record.authorityId)}>
|
||||
新增子角色
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openCopy(record)}>
|
||||
复制
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteRole(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
角色管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页接通角色树、菜单授权、API 授权和角色用户绑定。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => openCreate()}>
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="authorityId"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={roleModalOpen}
|
||||
title={copySource ? `复制角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新建角色'}
|
||||
onCancel={() => setRoleModalOpen(false)}
|
||||
onOk={saveRole}
|
||||
confirmLoading={savingRole}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="parentId" label="父角色">
|
||||
<Select options={[{ label: '根角色', value: 0 }, ...roleOptions]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="authorityId" label="角色 ID" rules={[{ required: true, message: '请输入角色 ID' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="authorityName" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={900}
|
||||
title={activeRole ? `角色权限 · ${activeRole.authorityName}` : '角色权限'}
|
||||
extra={
|
||||
<Button type="primary" loading={savingPermission} onClick={saveCurrentPermission}>
|
||||
保存当前标签
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as PermissionTab)}>
|
||||
<Tabs.TabPane tab="菜单权限" key="menus">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Select
|
||||
value={defaultRouter}
|
||||
options={menuOptions}
|
||||
allowClear
|
||||
placeholder="请选择默认首页"
|
||||
onChange={(value) => setDefaultRouter(value)}
|
||||
/>
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandAll
|
||||
checkedKeys={menuChecked}
|
||||
treeData={mapMenusToTree(menuTree)}
|
||||
onCheck={(checkedKeys) => setMenuChecked((checkedKeys as number[]).map((item) => Number(item)))}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="API 权限" key="apis">
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandAll
|
||||
checkedKeys={apiChecked}
|
||||
treeData={mapApisToTree(apis)}
|
||||
onCheck={(checkedKeys) => setApiChecked((checkedKeys as string[]).filter((item) => !item.startsWith('group:')))}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="角色用户" key="users">
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={userIds}
|
||||
options={users.map((user) => ({
|
||||
label: `${user.nickName} (${user.userName})`,
|
||||
value: user.ID,
|
||||
}))}
|
||||
onChange={(value) => setUserIds(value)}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Tag>{userIds.length} 个用户已绑定到当前角色</Tag>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
web-admin/src/features/server/ServerStatePage.tsx
Normal file
71
web-admin/src/features/server/ServerStatePage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Descriptions, Skeleton, Typography } from 'antd'
|
||||
import { inventoryApi } from '@/lib/api'
|
||||
|
||||
function renderValue(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function ServerStatePage() {
|
||||
const [server, setServer] = useState<Record<string, unknown> | null>(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
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
服务器状态
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
该页面直接读取 `/system/getServerInfo` 的结构化结果,不预设字段,优先保证信息完整。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
{loading ? (
|
||||
<Skeleton active />
|
||||
) : (
|
||||
<Descriptions bordered column={1} size="middle">
|
||||
{Object.entries(server || {}).map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
web-admin/src/features/system/SystemConfigPage.tsx
Normal file
130
web-admin/src/features/system/SystemConfigPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, Row, Space, Statistic, Tabs, Typography, message } from 'antd'
|
||||
import { systemApi } from '@/lib/api'
|
||||
|
||||
export function SystemConfigPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [text, setText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const system = (config.system || {}) as Record<string, unknown>
|
||||
return {
|
||||
addr: system.addr || '-',
|
||||
dbType: system['db-type'] || '-',
|
||||
routerPrefix: system['router-prefix'] || '/',
|
||||
strictAuth: String(system['use-strict-auth'] ?? false),
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const reloadConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await systemApi.getSystemConfig()
|
||||
setConfig(response.data.config)
|
||||
setText(JSON.stringify(response.data.config, null, 2))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadConfig()
|
||||
}, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
await systemApi.setSystemConfig(parsed)
|
||||
message.success('配置已保存')
|
||||
setConfig(parsed)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
系统配置
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页以原始 JSON 为主视图,优先保证配置项覆盖完整,不丢字段。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={reloadConfig} loading={loading}>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await systemApi.reloadSystem()
|
||||
message.success('系统重载命令已发送')
|
||||
}}
|
||||
>
|
||||
重载系统
|
||||
</Button>
|
||||
<Button type="primary" onClick={saveConfig}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="服务端口" value={String(summary.addr)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="数据库类型" value={String(summary.dbType)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="路由前缀" value={String(summary.routerPrefix)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="严格权限模式" value={String(summary.strictAuth)} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel" loading={loading}>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON 编辑器',
|
||||
children: (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 540,
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(16, 37, 66, 0.12)',
|
||||
padding: 16,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '结构预览',
|
||||
children: (
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
web-admin/src/features/tokens/ApiTokenPage.tsx
Normal file
237
web-admin/src/features/tokens/ApiTokenPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { apiTokenApi, userApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import type { ApiTokenRecord, UserInfo } from '@/types/system'
|
||||
|
||||
export function ApiTokenPage() {
|
||||
const [searchForm] = Form.useForm()
|
||||
const [issueForm] = Form.useForm()
|
||||
const [rows, setRows] = useState<ApiTokenRecord[]>([])
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [issuedToken, setIssuedToken] = useState<string>('')
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => users.find((item) => item.ID === issueForm.getFieldValue('userId')),
|
||||
[issueForm, users],
|
||||
)
|
||||
|
||||
const reloadRows = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiTokenApi.getTokenList({
|
||||
page,
|
||||
pageSize,
|
||||
...searchForm.getFieldsValue(),
|
||||
})
|
||||
setRows(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
const response = await userApi.getUserList({ page: 1, pageSize: 999 })
|
||||
setUsers(response.data.list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
reloadRows()
|
||||
loadUsers()
|
||||
}, [loadUsers, reloadRows])
|
||||
|
||||
const issueToken = async () => {
|
||||
const values = await issueForm.validateFields()
|
||||
const response = await apiTokenApi.createToken(values)
|
||||
setIssuedToken(response.data.token)
|
||||
setModalOpen(false)
|
||||
issueForm.resetFields()
|
||||
message.success('Token 已签发')
|
||||
reloadRows()
|
||||
}
|
||||
|
||||
const columns: ColumnsType<ApiTokenRecord> = [
|
||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
||||
{
|
||||
title: '用户',
|
||||
width: 220,
|
||||
render: (_, record) => `${record.user?.nickName || '-'} (${record.user?.userName || '-'})`,
|
||||
},
|
||||
{ title: '角色 ID', dataIndex: 'authorityId', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_, record) => <Tag color={record.status ? 'green' : 'red'}>{record.status ? '有效' : '已作废'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
width: 180,
|
||||
render: (_, record) => formatDate(record.expiresAt),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
disabled={!record.status}
|
||||
onClick={async () => {
|
||||
await apiTokenApi.deleteToken(record.ID)
|
||||
message.success('Token 已作废')
|
||||
reloadRows()
|
||||
}}
|
||||
>
|
||||
作废
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
API Token
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
为指定用户和角色签发临时访问 JWT。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => setModalOpen(true)}>
|
||||
签发 Token
|
||||
</Button>
|
||||
</div>
|
||||
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadRows() }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="userId" label="用户 ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={[{ label: '有效', value: true }, { label: '无效', value: false }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
reloadRows()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal open={modalOpen} title="签发 API Token" onCancel={() => setModalOpen(false)} onOk={issueToken}>
|
||||
<Form form={issueForm} layout="vertical" initialValues={{ days: 30 }}>
|
||||
<Form.Item name="userId" label="用户" rules={[{ required: true, message: '请选择用户' }]}>
|
||||
<Select
|
||||
options={users.map((user) => ({
|
||||
label: `${user.nickName} (${user.userName})`,
|
||||
value: user.ID,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="authorityId" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select
|
||||
options={(selectedUser?.authorities || []).map((authority) => ({
|
||||
label: `${authority.authorityName} (${authority.authorityId})`,
|
||||
value: authority.authorityId,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="days" label="有效期">
|
||||
<Select
|
||||
options={[
|
||||
{ label: '1 天', value: 1 },
|
||||
{ label: '7 天', value: 7 },
|
||||
{ label: '30 天', value: 30 },
|
||||
{ label: '90 天', value: 90 },
|
||||
{ label: '永久', value: -1 },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={Boolean(issuedToken)} title="签发结果" footer={null} onCancel={() => setIssuedToken('')}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="请立即复制保存。关闭后列表中不会再显示完整 Token。"
|
||||
/>
|
||||
<Input.TextArea value={issuedToken} rows={6} readOnly />
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(issuedToken)
|
||||
message.success('Token 已复制')
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setIssuedToken('')}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
400
web-admin/src/features/users/UserManagementPage.tsx
Normal file
400
web-admin/src/features/users/UserManagementPage.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { authorityApi, userApi } from '@/lib/api'
|
||||
import { flattenAuthorities } from '@/lib/tree'
|
||||
import type { Authority, UserInfo } from '@/types/system'
|
||||
|
||||
type UserSearch = {
|
||||
username?: string
|
||||
nickName?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
const [searchForm] = Form.useForm<UserSearch>()
|
||||
const [editForm] = Form.useForm()
|
||||
const [passwordForm] = Form.useForm()
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [authorities, setAuthorities] = useState<Authority[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<UserInfo | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
|
||||
const authorityOptions = useMemo(
|
||||
() =>
|
||||
flattenAuthorities(authorities).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
})),
|
||||
[authorities],
|
||||
)
|
||||
|
||||
const loadAuthorities = useCallback(async () => {
|
||||
const response = await authorityApi.getAuthorityList()
|
||||
setAuthorities(response.data)
|
||||
}, [])
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const values = searchForm.getFieldsValue()
|
||||
const response = await userApi.getUserList({
|
||||
page,
|
||||
pageSize,
|
||||
...values,
|
||||
})
|
||||
setUsers(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthorities()
|
||||
}, [loadAuthorities])
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({
|
||||
enable: true,
|
||||
authorityIds: [],
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: UserInfo) => {
|
||||
setEditingUser(record)
|
||||
editForm.setFieldsValue({
|
||||
userName: record.userName,
|
||||
nickName: record.nickName,
|
||||
phone: record.phone,
|
||||
email: record.email,
|
||||
headerImg: record.headerImg,
|
||||
authorityIds:
|
||||
record.authorities?.map((item) => item.authorityId) ||
|
||||
(record.authorityId ? [record.authorityId] : []),
|
||||
enable: (record.enable || 1) === 1,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const saveUser = async () => {
|
||||
const values = await editForm.validateFields()
|
||||
const authorityIds: number[] = values.authorityIds || []
|
||||
if (!authorityIds.length) {
|
||||
message.warning('至少选择一个角色')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
ID: editingUser?.ID,
|
||||
userName: values.userName,
|
||||
password: values.password,
|
||||
nickName: values.nickName,
|
||||
phone: values.phone,
|
||||
email: values.email,
|
||||
headerImg: values.headerImg,
|
||||
authorityId: authorityIds[0],
|
||||
authorityIds,
|
||||
enable: values.enable ? 1 : 2,
|
||||
}
|
||||
|
||||
if (editingUser) {
|
||||
await userApi.updateUser(payload)
|
||||
message.success('用户信息已更新')
|
||||
} else {
|
||||
await userApi.register(payload)
|
||||
message.success('用户已创建')
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
loadUsers()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = (record: UserInfo) => {
|
||||
Modal.confirm({
|
||||
title: `删除用户 ${record.userName}`,
|
||||
content: '删除后无法恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await userApi.deleteUser(record.ID)
|
||||
message.success('用户已删除')
|
||||
loadUsers()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openResetPassword = (record: UserInfo) => {
|
||||
setEditingUser(record)
|
||||
passwordForm.resetFields()
|
||||
setPasswordOpen(true)
|
||||
}
|
||||
|
||||
const savePassword = async () => {
|
||||
const values = await passwordForm.validateFields()
|
||||
if (!editingUser) {
|
||||
return
|
||||
}
|
||||
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
await userApi.resetPassword({
|
||||
ID: editingUser.ID,
|
||||
password: values.password,
|
||||
})
|
||||
message.success('密码已重置')
|
||||
setPasswordOpen(false)
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<UserInfo> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'userName',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickName',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
{(record.authorities?.length ? record.authorities : record.authority ? [record.authority] : []).map((item) => (
|
||||
<Tag key={item.authorityId}>{item.authorityName}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Tag color={(record.enable || 1) === 1 ? 'green' : 'red'}>
|
||||
{(record.enable || 1) === 1 ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openResetPassword(record)}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteUser(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
用户管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
接口来源:`/user/*` 与 `/authority/getAuthorityList`。首版重点覆盖用户列表、角色集合和密码重置。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建用户
|
||||
</Button>
|
||||
</div>
|
||||
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); loadUsers() }}>
|
||||
<Row gutter={[16, 0]}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="nickName" label="昵称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
loadUsers()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingUser ? '编辑用户' : '新建用户'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveUser}
|
||||
confirmLoading={saving}
|
||||
width={720}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="userName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input disabled={Boolean(editingUser)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{!editingUser ? (
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label="登录密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
) : null}
|
||||
<Col span={12}>
|
||||
<Form.Item name="nickName" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="headerImg" label="头像 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Form.Item name="authorityIds" label="角色集合" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select mode="multiple" options={authorityOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="enable" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={passwordOpen}
|
||||
title={`重置密码${editingUser ? ` · ${editingUser.userName}` : ''}`}
|
||||
onCancel={() => setPasswordOpen(false)}
|
||||
onOk={savePassword}
|
||||
confirmLoading={passwordSaving}
|
||||
>
|
||||
<Form form={passwordForm} layout="vertical">
|
||||
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
250
web-admin/src/index.css
Normal file
250
web-admin/src/index.css
Normal file
@@ -0,0 +1,250 @@
|
||||
:root {
|
||||
color: #102542;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(209, 111, 63, 0.18), transparent 30%),
|
||||
radial-gradient(circle at top right, rgba(17, 138, 178, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f8f4ef 0%, #f2ede7 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--surface: rgba(255, 255, 255, 0.78);
|
||||
--surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(16, 37, 66, 0.08);
|
||||
--text-main: #102542;
|
||||
--text-secondary: #536277;
|
||||
--text-light: rgba(255, 255, 255, 0.86);
|
||||
--accent: #d16f3f;
|
||||
--accent-soft: rgba(209, 111, 63, 0.12);
|
||||
--success-soft: rgba(31, 157, 120, 0.14);
|
||||
--shadow-lg: 0 24px 64px rgba(16, 37, 66, 0.12);
|
||||
--shadow-md: 0 12px 32px rgba(16, 37, 66, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Avenir Next",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fullscreen-status {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 312px 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
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;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(209, 111, 63, 0.22), rgba(255, 255, 255, 0.06)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.48);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: 22px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.9fr;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
padding: 56px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(209, 111, 63, 0.28), transparent 28%),
|
||||
radial-gradient(circle at 80% 15%, rgba(60, 135, 180, 0.22), transparent 30%),
|
||||
linear-gradient(160deg, #0f2746 0%, #16365d 48%, #214a73 100%);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-form-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(460px, 100%);
|
||||
border-radius: 32px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.capsule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.catalog-card {
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.catalog-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.ready {
|
||||
background: #1f9d78;
|
||||
}
|
||||
|
||||
.status-dot.partial {
|
||||
background: #d29b2f;
|
||||
}
|
||||
|
||||
.status-dot.planned {
|
||||
background: #8896a8;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell,
|
||||
.login-shell,
|
||||
.hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.catalog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
275
web-admin/src/lib/api.ts
Normal file
275
web-admin/src/lib/api.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { http } from './http'
|
||||
import type {
|
||||
ApiRecord,
|
||||
ApiTokenRecord,
|
||||
Authority,
|
||||
CaptchaInfo,
|
||||
Dictionary,
|
||||
DictionaryDetail,
|
||||
LoginLog,
|
||||
LoginResult,
|
||||
MenuNode,
|
||||
OperationRecord,
|
||||
PagePayload,
|
||||
SysErrorRecord,
|
||||
SysParam,
|
||||
UserInfo,
|
||||
} from '@/types/system'
|
||||
|
||||
export const authApi = {
|
||||
getCaptcha() {
|
||||
return http.post<CaptchaInfo>('/base/captcha')
|
||||
},
|
||||
login(payload: { username: string; password: string; captcha?: string; captchaId?: string }) {
|
||||
return http.post<LoginResult>('/base/login', payload)
|
||||
},
|
||||
getUserInfo() {
|
||||
return http.get<{ userInfo: UserInfo }>('/user/getUserInfo')
|
||||
},
|
||||
logout() {
|
||||
return http.post<Record<string, never>>('/jwt/jsonInBlacklist')
|
||||
},
|
||||
changeAuthority(authorityId: number) {
|
||||
return http.post<Record<string, never>>('/user/setUserAuthority', { authorityId })
|
||||
},
|
||||
setSelfInfo(payload: Partial<UserInfo>) {
|
||||
return http.put<Record<string, never>>('/user/setSelfInfo', payload)
|
||||
},
|
||||
changePassword(payload: { password: string; newPassword: string }) {
|
||||
return http.post<Record<string, never>>('/user/changePassword', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenu() {
|
||||
return http.post<{ menus: MenuNode[] }>('/menu/getMenu')
|
||||
},
|
||||
getBaseMenuTree() {
|
||||
return http.post<{ menus: MenuNode[] }>('/menu/getBaseMenuTree')
|
||||
},
|
||||
getMenuList() {
|
||||
return http.post<MenuNode[]>('/menu/getMenuList')
|
||||
},
|
||||
addBaseMenu(payload: Partial<MenuNode>) {
|
||||
return http.post<Record<string, never>>('/menu/addBaseMenu', payload)
|
||||
},
|
||||
updateBaseMenu(payload: Partial<MenuNode>) {
|
||||
return http.post<Record<string, never>>('/menu/updateBaseMenu', payload)
|
||||
},
|
||||
deleteBaseMenu(id: number) {
|
||||
return http.post<Record<string, never>>('/menu/deleteBaseMenu', { ID: id })
|
||||
},
|
||||
getBaseMenuById(id: number) {
|
||||
return http.post<{ menu: MenuNode }>('/menu/getBaseMenuById', { ID: id })
|
||||
},
|
||||
getMenuAuthority(authorityId: number) {
|
||||
return http.post<{ menus: MenuNode[] }>('/menu/getMenuAuthority', { authorityId })
|
||||
},
|
||||
addMenuAuthority(payload: { authorityId: number; menus: MenuNode[] }) {
|
||||
return http.post<Record<string, never>>('/menu/addMenuAuthority', payload)
|
||||
},
|
||||
getMenuRoles(menuId: number) {
|
||||
return http.get<{ authorityIds: number[]; defaultRouterAuthorityIds: number[] }>('/menu/getMenuRoles', {
|
||||
params: { menuId },
|
||||
})
|
||||
},
|
||||
setMenuRoles(payload: { menuId: number; authorityIds: number[] }) {
|
||||
return http.post<Record<string, never>>('/menu/setMenuRoles', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const authorityApi = {
|
||||
getAuthorityList() {
|
||||
return http.post<Authority[]>('/authority/getAuthorityList', {})
|
||||
},
|
||||
createAuthority(payload: Partial<Authority>) {
|
||||
return http.post<Record<string, never>>('/authority/createAuthority', payload)
|
||||
},
|
||||
updateAuthority(payload: Partial<Authority>) {
|
||||
return http.put<{ authority: Authority }>('/authority/updateAuthority', payload)
|
||||
},
|
||||
deleteAuthority(authorityId: number) {
|
||||
return http.post<Record<string, never>>('/authority/deleteAuthority', { authorityId })
|
||||
},
|
||||
copyAuthority(payload: { oldAuthorityId: number; authority: Partial<Authority> }) {
|
||||
return http.post<Record<string, never>>('/authority/copyAuthority', payload)
|
||||
},
|
||||
getUsersByAuthorityId(authorityId: number) {
|
||||
return http.get<number[]>('/authority/getUsersByAuthority', { params: { authorityId } })
|
||||
},
|
||||
setRoleUsers(payload: { authorityId: number; userIds: number[] }) {
|
||||
return http.post<Record<string, never>>('/authority/setRoleUsers', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
getUserList(payload: Record<string, unknown>) {
|
||||
return http.post<PagePayload<UserInfo>>('/user/getUserList', payload)
|
||||
},
|
||||
register(payload: Record<string, unknown>) {
|
||||
return http.post<Record<string, never>>('/user/admin_register', payload)
|
||||
},
|
||||
updateUser(payload: Record<string, unknown>) {
|
||||
return http.put<Record<string, never>>('/user/setUserInfo', payload)
|
||||
},
|
||||
deleteUser(id: number) {
|
||||
return http.delete<Record<string, never>>('/user/deleteUser', { data: { ID: id } })
|
||||
},
|
||||
resetPassword(payload: { ID: number; password: string }) {
|
||||
return http.post<Record<string, never>>('/user/resetPassword', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const casbinApi = {
|
||||
getPolicyPathByAuthorityId(authorityId: number) {
|
||||
return http.post<{ paths: Array<{ path: string; method: string }> }>('/casbin/getPolicyPathByAuthorityId', {
|
||||
authorityId,
|
||||
})
|
||||
},
|
||||
updateCasbin(payload: { authorityId: number; casbinInfos: Array<{ path: string; method: string }> }) {
|
||||
return http.post<Record<string, never>>('/casbin/updateCasbin', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
getSystemConfig() {
|
||||
return http.post<{ config: Record<string, unknown> }>('/system/getSystemConfig')
|
||||
},
|
||||
setSystemConfig(payload: Record<string, unknown>) {
|
||||
return http.post<Record<string, never>>('/system/setSystemConfig', payload)
|
||||
},
|
||||
reloadSystem() {
|
||||
return http.post<Record<string, never>>('/system/reloadSystem')
|
||||
},
|
||||
}
|
||||
|
||||
export const apiRegistryApi = {
|
||||
getApiList(payload: Record<string, unknown>) {
|
||||
return http.post<PagePayload<ApiRecord>>('/api/getApiList', payload)
|
||||
},
|
||||
getAllApis() {
|
||||
return http.post<{ apis: ApiRecord[] }>('/api/getAllApis', {})
|
||||
},
|
||||
createApi(payload: Partial<ApiRecord>) {
|
||||
return http.post<Record<string, never>>('/api/createApi', payload)
|
||||
},
|
||||
updateApi(payload: Partial<ApiRecord>) {
|
||||
return http.post<Record<string, never>>('/api/updateApi', payload)
|
||||
},
|
||||
deleteApi(payload: { ID: number }) {
|
||||
return http.post<Record<string, never>>('/api/deleteApi', payload)
|
||||
},
|
||||
getApiById(id: number) {
|
||||
return http.post<{ api: ApiRecord }>('/api/getApiById', { ID: id })
|
||||
},
|
||||
freshCasbin() {
|
||||
return http.get<Record<string, never>>('/api/freshCasbin')
|
||||
},
|
||||
getApiRoles(path: string, method: string) {
|
||||
return http.get<number[]>('/api/getApiRoles', { params: { path, method } })
|
||||
},
|
||||
setApiRoles(payload: { path: string; method: string; authorityIds: number[] }) {
|
||||
return http.post<Record<string, never>>('/api/setApiRoles', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const dictionaryApi = {
|
||||
getDictionaryList(params?: Record<string, unknown>) {
|
||||
return http.get<Dictionary[]>('/sysDictionary/getSysDictionaryList', { params })
|
||||
},
|
||||
createDictionary(payload: Partial<Dictionary>) {
|
||||
return http.post<Record<string, never>>('/sysDictionary/createSysDictionary', payload)
|
||||
},
|
||||
updateDictionary(payload: Partial<Dictionary>) {
|
||||
return http.put<Record<string, never>>('/sysDictionary/updateSysDictionary', payload)
|
||||
},
|
||||
deleteDictionary(payload: { ID: number }) {
|
||||
return http.delete<Record<string, never>>('/sysDictionary/deleteSysDictionary', { data: payload })
|
||||
},
|
||||
getDictionaryTree(sysDictionaryID: number) {
|
||||
return http.get<{ list: DictionaryDetail[] }>('/sysDictionaryDetail/getDictionaryTreeList', {
|
||||
params: { sysDictionaryID },
|
||||
})
|
||||
},
|
||||
createDictionaryDetail(payload: Partial<DictionaryDetail>) {
|
||||
return http.post<Record<string, never>>('/sysDictionaryDetail/createSysDictionaryDetail', payload)
|
||||
},
|
||||
updateDictionaryDetail(payload: Partial<DictionaryDetail>) {
|
||||
return http.put<Record<string, never>>('/sysDictionaryDetail/updateSysDictionaryDetail', payload)
|
||||
},
|
||||
deleteDictionaryDetail(payload: { ID: number }) {
|
||||
return http.delete<Record<string, never>>('/sysDictionaryDetail/deleteSysDictionaryDetail', { data: payload })
|
||||
},
|
||||
}
|
||||
|
||||
export const sysParamsApi = {
|
||||
getParamsList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<SysParam>>('/sysParams/getSysParamsList', { params })
|
||||
},
|
||||
createParam(payload: Partial<SysParam>) {
|
||||
return http.post<Record<string, never>>('/sysParams/createSysParams', payload)
|
||||
},
|
||||
updateParam(payload: Partial<SysParam>) {
|
||||
return http.put<Record<string, never>>('/sysParams/updateSysParams', payload)
|
||||
},
|
||||
deleteParam(id: number) {
|
||||
return http.delete<Record<string, never>>('/sysParams/deleteSysParams', { params: { ID: id } })
|
||||
},
|
||||
}
|
||||
|
||||
export const loginLogApi = {
|
||||
getLoginLogList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<LoginLog>>('/sysLoginLog/getLoginLogList', { params })
|
||||
},
|
||||
deleteLoginLog(id: number) {
|
||||
return http.delete<Record<string, never>>('/sysLoginLog/deleteLoginLog', { data: { ID: id } })
|
||||
},
|
||||
deleteLoginLogByIds(ids: number[]) {
|
||||
return http.delete<Record<string, never>>('/sysLoginLog/deleteLoginLogByIds', { data: { ids } })
|
||||
},
|
||||
}
|
||||
|
||||
export const operationApi = {
|
||||
getOperationList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<OperationRecord>>('/sysOperationRecord/getSysOperationRecordList', { params })
|
||||
},
|
||||
deleteOperation(id: number) {
|
||||
return http.delete<Record<string, never>>('/sysOperationRecord/deleteSysOperationRecord', { data: { ID: id } })
|
||||
},
|
||||
deleteOperationByIds(ids: number[]) {
|
||||
return http.delete<Record<string, never>>('/sysOperationRecord/deleteSysOperationRecordByIds', { data: { ids } })
|
||||
},
|
||||
}
|
||||
|
||||
export const apiTokenApi = {
|
||||
getTokenList(payload: Record<string, unknown>) {
|
||||
return http.post<PagePayload<ApiTokenRecord>>('/sysApiToken/getApiTokenList', payload)
|
||||
},
|
||||
createToken(payload: { userId: number; authorityId: number; days: number; remark?: string }) {
|
||||
return http.post<{ token: string }>('/sysApiToken/createApiToken', payload)
|
||||
},
|
||||
deleteToken(id: number) {
|
||||
return http.post<Record<string, never>>('/sysApiToken/deleteApiToken', { ID: id })
|
||||
},
|
||||
}
|
||||
|
||||
export const sysErrorApi = {
|
||||
getErrorList(params?: Record<string, unknown>) {
|
||||
return http.get<PagePayload<SysErrorRecord>>('/sysError/getSysErrorList', { params })
|
||||
},
|
||||
updateError(payload: Partial<SysErrorRecord>) {
|
||||
return http.put<Record<string, never>>('/sysError/updateSysError', payload)
|
||||
},
|
||||
deleteError(id: number) {
|
||||
return http.delete<Record<string, never>>('/sysError/deleteSysError', { params: { ID: id } })
|
||||
},
|
||||
handleError(id: number) {
|
||||
return http.get<Record<string, never>>('/sysError/getSysErrorSolution', { params: { id } })
|
||||
},
|
||||
}
|
||||
|
||||
export const inventoryApi = {
|
||||
getServerInfo() {
|
||||
return http.post<{ server: Record<string, unknown> }>('/system/getServerInfo')
|
||||
},
|
||||
}
|
||||
9
web-admin/src/lib/date.ts
Normal file
9
web-admin/src/lib/date.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
63
web-admin/src/lib/http.ts
Normal file
63
web-admin/src/lib/http.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import { message } from 'antd'
|
||||
import type { ApiResponse } from '@/types/system'
|
||||
import { storage } from './storage'
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_API || '/api',
|
||||
timeout: 60000,
|
||||
})
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = storage.getToken()
|
||||
if (token) {
|
||||
config.headers['x-token'] = token
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||
const status = error.response?.status
|
||||
const errorText = error.response?.data?.msg || error.message || '请求失败'
|
||||
|
||||
if (status === 401) {
|
||||
storage.clearToken()
|
||||
window.location.hash = '#/login'
|
||||
window.location.reload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
message.error(errorText)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
async function request<T>(config: AxiosRequestConfig) {
|
||||
const response = await instance.request<ApiResponse<T>>(config)
|
||||
const payload = response.data
|
||||
|
||||
if (typeof payload?.code === 'number' && payload.code !== 0) {
|
||||
message.error(payload.msg || '请求失败')
|
||||
throw new Error(payload.msg || '请求失败')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'get' })
|
||||
},
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, data, method: 'post' })
|
||||
},
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, data, method: 'put' })
|
||||
},
|
||||
delete<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'delete' })
|
||||
},
|
||||
}
|
||||
57
web-admin/src/lib/menu.ts
Normal file
57
web-admin/src/lib/menu.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AppMenu, MenuNode } from '@/types/system'
|
||||
|
||||
export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
|
||||
return (
|
||||
menu.path.startsWith('http://') ||
|
||||
menu.path.startsWith('https://') ||
|
||||
menu.component.startsWith('http://') ||
|
||||
menu.component === '/'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePathSegment(path: string) {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function buildFullMenus(
|
||||
menus: MenuNode[],
|
||||
parentPath = '',
|
||||
parentName?: string,
|
||||
): AppMenu[] {
|
||||
return [...menus]
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
.map((menu) => {
|
||||
const normalized = normalizePathSegment(menu.path)
|
||||
const fullPath = isExternalMenu(menu)
|
||||
? menu.path
|
||||
: `${parentPath}/${normalized}`.replace(/\/+/g, '/')
|
||||
|
||||
const children = buildFullMenus(menu.children || [], fullPath, menu.name)
|
||||
|
||||
return {
|
||||
...menu,
|
||||
parentName,
|
||||
fullPath,
|
||||
fullPathKey: fullPath.replace(/^\//, ''),
|
||||
children,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function flattenMenus(menus: AppMenu[]): AppMenu[] {
|
||||
return menus.flatMap((menu) => [menu, ...flattenMenus(menu.children || [])])
|
||||
}
|
||||
|
||||
export function findDefaultRoute(menus: AppMenu[], routeName?: string) {
|
||||
const flatMenus = flattenMenus(menus).filter((menu) => !isExternalMenu(menu))
|
||||
const byDefaultName = flatMenus.find((menu) => menu.name === routeName)
|
||||
if (byDefaultName) {
|
||||
return byDefaultName.fullPath
|
||||
}
|
||||
|
||||
return flatMenus[0]?.fullPath
|
||||
}
|
||||
13
web-admin/src/lib/storage.ts
Normal file
13
web-admin/src/lib/storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const TOKEN_KEY = 'gva-react-admin-token'
|
||||
|
||||
export const storage = {
|
||||
getToken() {
|
||||
return window.localStorage.getItem(TOKEN_KEY) || ''
|
||||
},
|
||||
setToken(token: string) {
|
||||
window.localStorage.setItem(TOKEN_KEY, token)
|
||||
},
|
||||
clearToken() {
|
||||
window.localStorage.removeItem(TOKEN_KEY)
|
||||
},
|
||||
}
|
||||
26
web-admin/src/lib/tree.ts
Normal file
26
web-admin/src/lib/tree.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Authority, MenuNode } from '@/types/system'
|
||||
|
||||
export function flattenAuthorities(authorities: Authority[], depth = 0): Array<Authority & { depth: number }> {
|
||||
return authorities.flatMap((authority) => [
|
||||
{ ...authority, depth },
|
||||
...flattenAuthorities(authority.children || [], depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
export function flattenMenusForOptions(menus: MenuNode[], depth = 0): Array<MenuNode & { depth: number }> {
|
||||
return menus.flatMap((menu) => [
|
||||
{ ...menu, depth },
|
||||
...flattenMenusForOptions(menu.children || [], depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
export function collectCheckedLeafMenus(menus: MenuNode[], checkedKeys: Set<number>): MenuNode[] {
|
||||
return menus.flatMap((menu) => {
|
||||
const children = menu.children || []
|
||||
if (!children.length) {
|
||||
return checkedKeys.has(menu.ID) ? [menu] : []
|
||||
}
|
||||
|
||||
return collectCheckedLeafMenus(children, checkedKeys)
|
||||
})
|
||||
}
|
||||
46
web-admin/src/main.tsx
Normal file
46
web-admin/src/main.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App as AntdApp, ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#d16f3f',
|
||||
colorSuccess: '#1f9d78',
|
||||
colorWarning: '#d29b2f',
|
||||
colorError: '#c34747',
|
||||
borderRadius: 16,
|
||||
wireframe: false,
|
||||
fontFamily:
|
||||
'"Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
46
web-admin/src/store/auth.ts
Normal file
46
web-admin/src/store/auth.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { storage } from '@/lib/storage'
|
||||
import type { AppMenu, UserInfo } from '@/types/system'
|
||||
|
||||
type AuthState = {
|
||||
token: string
|
||||
user: UserInfo | null
|
||||
menus: AppMenu[]
|
||||
setToken: (token: string) => void
|
||||
setUser: (user: UserInfo | null) => void
|
||||
setMenus: (menus: AppMenu[]) => void
|
||||
applySession: (payload: { token: string; user: UserInfo }) => void
|
||||
clearSession: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: storage.getToken(),
|
||||
user: null,
|
||||
menus: [],
|
||||
setToken: (token) => {
|
||||
storage.setToken(token)
|
||||
set({ token })
|
||||
},
|
||||
setUser: (user) => set({ user }),
|
||||
setMenus: (menus) => set({ menus }),
|
||||
applySession: ({ token, user }) => {
|
||||
storage.setToken(token)
|
||||
set({ token, user })
|
||||
},
|
||||
clearSession: () => {
|
||||
storage.clearToken()
|
||||
set({ token: '', user: null, menus: [] })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'gva-react-admin-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
171
web-admin/src/types/system.ts
Normal file
171
web-admin/src/types/system.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
export type ApiResponse<T> = {
|
||||
code: number
|
||||
data: T
|
||||
msg: string
|
||||
}
|
||||
|
||||
export type BaseEntity = {
|
||||
ID: number
|
||||
CreatedAt?: string
|
||||
UpdatedAt?: string
|
||||
}
|
||||
|
||||
export type Authority = {
|
||||
authorityId: number
|
||||
authorityName: string
|
||||
parentId?: number | null
|
||||
defaultRouter?: string
|
||||
children?: Authority[]
|
||||
}
|
||||
|
||||
export type PagePayload<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type UserInfo = BaseEntity & {
|
||||
uuid?: string
|
||||
userName: string
|
||||
nickName: string
|
||||
headerImg?: string
|
||||
authorityId?: number
|
||||
authority?: Authority
|
||||
authorities?: Authority[]
|
||||
phone?: string
|
||||
email?: string
|
||||
enable?: number
|
||||
originSetting?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type MenuMeta = {
|
||||
title: string
|
||||
icon?: string
|
||||
keepAlive?: boolean
|
||||
closeTab?: boolean
|
||||
defaultMenu?: boolean
|
||||
activeName?: string
|
||||
transitionType?: string
|
||||
}
|
||||
|
||||
export type MenuNode = BaseEntity & {
|
||||
parentId: number
|
||||
path: string
|
||||
name: string
|
||||
hidden?: boolean
|
||||
component: string
|
||||
sort: number
|
||||
meta: MenuMeta
|
||||
menuBtn?: Array<{ ID: number; name: string; desc?: string }>
|
||||
btns?: Record<string, number>
|
||||
children?: MenuNode[]
|
||||
}
|
||||
|
||||
export type AppMenu = MenuNode & {
|
||||
fullPath: string
|
||||
fullPathKey: string
|
||||
parentName?: string
|
||||
children?: AppMenu[]
|
||||
}
|
||||
|
||||
export type LoginResult = {
|
||||
user: UserInfo
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type CaptchaInfo = {
|
||||
captchaId: string
|
||||
picPath: string
|
||||
captchaLength: number
|
||||
openCaptcha: boolean
|
||||
}
|
||||
|
||||
export type ApiRecord = BaseEntity & {
|
||||
path: string
|
||||
description: string
|
||||
apiGroup: string
|
||||
method: string
|
||||
}
|
||||
|
||||
export type Dictionary = BaseEntity & {
|
||||
name: string
|
||||
type: string
|
||||
status?: boolean | null
|
||||
desc?: string
|
||||
parentID?: number | null
|
||||
children?: Dictionary[]
|
||||
}
|
||||
|
||||
export type DictionaryDetail = BaseEntity & {
|
||||
label: string
|
||||
value: string
|
||||
extend?: string
|
||||
status?: boolean | null
|
||||
sort?: number
|
||||
sysDictionaryID: number
|
||||
parentID?: number | null
|
||||
level?: number
|
||||
path?: string
|
||||
children?: DictionaryDetail[]
|
||||
}
|
||||
|
||||
export type SysParam = BaseEntity & {
|
||||
name: string
|
||||
key: string
|
||||
value: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
export type LoginLog = BaseEntity & {
|
||||
username: string
|
||||
ip: string
|
||||
status: boolean
|
||||
errorMessage?: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export type OperationRecord = BaseEntity & {
|
||||
ip: string
|
||||
method: string
|
||||
path: string
|
||||
status: number
|
||||
latency?: string
|
||||
agent?: string
|
||||
error_message?: string
|
||||
body?: string
|
||||
resp?: string
|
||||
user_id?: number
|
||||
user?: UserInfo
|
||||
}
|
||||
|
||||
export type ApiTokenRecord = BaseEntity & {
|
||||
userId: number
|
||||
authorityId: number
|
||||
token?: string
|
||||
status: boolean
|
||||
expiresAt: string
|
||||
remark?: string
|
||||
user?: UserInfo
|
||||
}
|
||||
|
||||
export type SysErrorRecord = BaseEntity & {
|
||||
form?: string | null
|
||||
info?: string | null
|
||||
level?: string
|
||||
solution?: string | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type ModuleStatus = 'ready' | 'partial' | 'planned'
|
||||
|
||||
export type ModuleDescriptor = {
|
||||
name: string
|
||||
title: string
|
||||
summary: string
|
||||
group: string
|
||||
status: ModuleStatus
|
||||
features: string[]
|
||||
endpoints: string[]
|
||||
}
|
||||
30
web-admin/tsconfig.app.json
Normal file
30
web-admin/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web-admin/tsconfig.json
Normal file
7
web-admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
web-admin/tsconfig.node.json
Normal file
24
web-admin/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
27
web-admin/vite.config.ts
Normal file
27
web-admin/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 8081,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8888',
|
||||
changeOrigin: true,
|
||||
rewrite: (currentPath) => currentPath.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user