🎨 精简完善系统

This commit is contained in:
2026-04-10 17:57:48 +08:00
parent ee6565371e
commit 82c5020e71
55 changed files with 5785 additions and 9712 deletions

215
AGENTS.md Normal file
View File

@@ -0,0 +1,215 @@
# AGENTS.md
## 1. 文件作用
本文件定义本仓库的协作约束,供人类开发者和代码代理统一遵循。
目标只有三个:
- 先分清模块边界,再动代码。
- 新功能默认落在 `server``web-admin`
- `web` 只作为旧版参考实现,不参与当前交付。
## 2. 项目总览
本仓库是一个前后端分离项目,当前有效开发面如下:
| 目录 | 角色 | 技术栈 | 是否允许修改 |
| --- | --- | --- | --- |
| `server` | 后端服务 | Go 1.24、Gin、Gorm、Viper、Zap | 允许 |
| `web-admin` | 新管理后台前端 | React 19、TypeScript、Vite、Ant Design、Zustand、React Query | 允许 |
| `web` | 原版 Vue 管理后台 | Vue | 不允许 |
当前默认管理后台目录已经在根目录 `Makefile` 中指定为 `WEB_DIR ?= web-admin`,后续构建与打包应以 `web-admin` 为准。
## 3. 工作边界
### 3.1 允许修改的目录
- `server/**`
- `web-admin/**`
- 根目录与部署目录中和当前任务直接相关的文档、脚本、配置文件
### 3.2 禁止修改的目录
- `web/**`
`web` 是旧版 Vue 后台,仅用于界面、接口命名、交互流程和功能覆盖范围参考。不得在该目录内新增、删除或修改任何文件。
### 3.3 修改原则
- 如果 `web-admin` 缺少某个页面或交互,可以参考 `web` 的实现思路,但要用 React 方式重写。
- 如果 `server` 的接口能力不足,应优先补齐后端,再让 `web-admin` 接入。
- 不为了兼容旧实现而把新代码继续写回 `web`
## 4. 目录说明
### 4.1 `server`
后端采用典型分层结构,开发时按职责落位:
| 目录 | 职责 |
| --- | --- |
| `server/api` | HTTP 接口入口。负责参数接收、调用 service、返回响应。 |
| `server/service` | 业务逻辑层。负责业务编排、事务边界、数据写入策略。 |
| `server/router` | 路由注册。负责把接口暴露给 Gin。 |
| `server/model` | 数据模型、请求结构、响应结构。 |
| `server/middleware` | 中间件。负责鉴权、日志、限流、错误处理等横切逻辑。 |
| `server/initialize` | 初始化流程。负责数据库、Redis、路由、校验器、定时器等装配。 |
| `server/config` | 配置结构定义。与 `config.yaml` / `config.docker.yaml` 对应。 |
| `server/utils` | 通用工具,不承载具体业务语义。 |
| `server/mcp` | MCP 相关能力。新增此类能力时优先放在这里。 |
| `server/docs` | Swagger 产物目录。只有接口变更需要更新文档时才修改。 |
后端变更约束:
- 不要把业务逻辑堆进 `api` 层。
- 不要把页面专属拼装逻辑散落到 `utils`
- 新接口需要同时检查路由注册、权限、请求结构和返回结构是否完整。
- 涉及配置项变更时,必须同步检查 `server/config.yaml``server/config.docker.yaml` 和对应结构体。
### 4.2 `web-admin`
新后台是当前主开发前端,目录组织以功能模块为中心:
| 目录 | 职责 |
| --- | --- |
| `web-admin/src/features` | 业务页面与模块实现。页面优先落在这里。 |
| `web-admin/src/router` | 路由定义与页面装配。 |
| `web-admin/src/lib` | 请求封装、日期、树结构、存储等通用前端基础设施。 |
| `web-admin/src/store` | 全局状态。当前主要用于鉴权与会话信息。 |
| `web-admin/src/types` | 共享类型定义。 |
| `web-admin/public` | 静态资源。 |
前端变更约束:
- 新页面优先沿用现有 `features/*Page.tsx` 的组织方式。
- 接口请求优先收敛到 `web-admin/src/lib` 或对应 feature 内部的请求层,不要在组件里散写 HTTP 细节。
- 类型优先显式声明,不要用大量 `any`
- 页面以管理后台场景为前提,优先保证信息密度、操作链路和可维护性。
### 4.3 `web`
该目录只读。允许查看,不允许编辑。
适用场景:
- 查旧版菜单结构
- 查既有接口命名
- 查页面字段和交互流程
- 查某个能力在旧后台中的展示方式
不适用场景:
- 直接在这里修 bug
- 在这里补新功能
- 为了图省事复制构建配置并改回旧目录
## 5. 本地开发
### 5.1 环境基线
| 项目 | 要求 |
| --- | --- |
| Go | 1.24.x |
| Node.js | >= 18.16,建议 20+ |
| 包管理器 | `web-admin` 使用 `npm` |
### 5.2 常用命令
#### 后端
```bash
cd /Users/lee/GolandProjects/Go-Web-Template/server
go run .
```
#### 新后台前端
```bash
cd /Users/lee/GolandProjects/Go-Web-Template/web-admin
npm install
npm run dev
```
默认约定:
- `web-admin` 开发地址:`http://localhost:8081`
- 后端默认代理目标:`http://127.0.0.1:8888`
#### Swagger 文档
```bash
cd /Users/lee/GolandProjects/Go-Web-Template/server
swag init
```
#### 根目录构建
```bash
cd /Users/lee/GolandProjects/Go-Web-Template
make build
```
## 6. 变更落地规则
### 6.1 后端任务
遇到后端需求时,优先按下面顺序检查:
1. 是否已有对应 `model``service``api``router`
2. 是否需要补权限、菜单、字典、参数或日志记录。
3. 是否影响 Swagger 文档、配置文件或初始化逻辑。
4. 是否需要补测试,至少覆盖纯工具函数和稳定业务逻辑。
### 6.2 前端任务
遇到管理后台需求时,优先按下面顺序处理:
1. 先确认目标页面属于 `web-admin` 哪个 feature。
2. 需要参考旧实现时,只读取 `web`,不在 `web` 动手。
3. 先整理接口契约,再组织页面状态和交互。
4. 提交前至少完成构建或 lint 校验。
### 6.3 跨端联动任务
如果需求同时涉及前后端,推荐顺序如下:
1. 先定义或确认接口契约。
2. 先补 `server`,保证接口可运行。
3. 再接 `web-admin` 页面和交互。
4. 最后联调并确认代理、鉴权、菜单和权限表现一致。
## 7. 验证要求
只要改动了对应模块,至少执行一项最低验证:
| 改动范围 | 最低验证 |
| --- | --- |
| `server` Go 代码 | `go test ./...` 或最小相关包测试 |
| `web-admin` 前端代码 | `npm run build``npm run lint` |
| 接口定义变更 | 补充或更新 Swagger 文档,并做一次接口联调 |
| 构建脚本或部署配置 | 执行对应构建命令,确认没有明显路径错误 |
如果因为外部依赖无法完成验证,需要明确说明阻塞点,不得省略。
## 8. 代码风格补充
- 文档、注释、提交说明优先使用清晰中文。
- 注释只说明职责、边界、约束和必要语义,不复述代码表面行为。
- 新增后端代码遵循现有分层,不主动引入跨层调用。
- 新增前端代码遵循现有 React + TypeScript + Ant Design 方案,不回退到旧 Vue 写法。
- 非必要不新增大型依赖。新增依赖前先确认当前栈无法直接解决问题。
## 9. 决策优先级
出现冲突时,按下面顺序决策:
1. 不修改 `web`
2.`web-admin` 作为当前管理后台主实现
3.`server` 的真实接口和数据结构为准
4. 参考旧版 `web` 的业务行为,而不是复制其实现细节
## 10. 一句话约束
这个仓库的当前主线是:维护 `server`,建设 `web-admin`,只参考 `web`,绝不修改 `web`

View File

@@ -6,10 +6,10 @@
<div align=center>
<img src="https://img.shields.io/badge/golang-1.20-blue"/>
<img src="https://img.shields.io/badge/gin-1.9.1-lightBlue"/>
<img src="https://img.shields.io/badge/vue-3.3.4-brightgreen"/>
<img src="https://img.shields.io/badge/element--plus-2.3.8-green"/>
<img src="https://img.shields.io/badge/react-19.2.4-brightgreen"/>
<img src="https://img.shields.io/badge/antd-6.3.5-green"/>
<img src="https://img.shields.io/badge/gorm-1.25.2-red"/>
<img src="https://gitcode.com/flipped-aurora/gin-vue-admin/star/badge.svg"/>
<img src="https://gitcode.com/flipped-aurora/gin-react-admin/star/badge.svg"/>
</div>
<div align=center>
@@ -20,7 +20,7 @@
## 支持claw生态
[🦞GvaClaw](https://plugin.gin-vue-admin.com/details/159)
[🦞GvaClaw](https://plugin.gin-react-admin.com/details/159)
## ✨一分钟生成前后端基础代码
@@ -43,29 +43,29 @@
# 项目文档
[在线文档](https://www.gin-vue-admin.com) : https://www.gin-vue-admin.com
[在线文档](https://www.gin-react-admin.com) : https://www.gin-react-admin.com
[初始化](https://www.gin-vue-admin.com/guide/start-quickly/initialization.html)
[初始化](https://www.gin-react-admin.com/guide/start-quickly/initialization.html)
[从环境到部署教学视频](https://www.bilibili.com/video/BV1Rg411u7xH)
[开发教学](https://www.gin-vue-admin.com/guide/start-quickly/env.html) (贡献者: <a href="https://github.com/LLemonGreen">LLemonGreen</a> And <a href="https://github.com/fkk0509">Fann</a>)
[开发教学](https://www.gin-react-admin.com/guide/start-quickly/env.html) (贡献者: <a href="https://github.com/LLemonGreen">LLemonGreen</a> And <a href="https://github.com/fkk0509">Fann</a>)
[交流社区](https://support.qq.com/products/371961)
[插件市场](https://plugin.gin-vue-admin.com/)
[插件市场](https://plugin.gin-react-admin.com/)
[软件著作权证书](https://www.gin-vue-admin.com/copyright.pdf)
[软件著作权证书](https://www.gin-react-admin.com/copyright.pdf)
# 重要提示
1.本项目从起步到开发到部署均有文档和详细视频教程
2.本项目需要您有一定的golang和vue基础
2.本项目需要您有一定的golang和react基础
3.您完全可以通过我们的教程和文档完成一切操作,因此我们不再提供免费的技术服务,如需服务请进行[付费支持](https://www.gin-vue-admin.com/coffee/payment.html)
3.您完全可以通过我们的教程和文档完成一切操作,因此我们不再提供免费的技术服务,如需服务请进行[付费支持](https://www.gin-react-admin.com/coffee/payment.html)
4.如果您将此项目用于商业用途请遵守Apache2.0协议并保留作者技术支持声明。您需保留如下版权声明信息,以及日志和代码中所包含的版权声明信息。所需保留信息均为文案性质,不会影响任何业务内容,如决定商用【产生收益的商业行为均在商用行列】或者必须剔除请[购买授权](https://plugin.gin-vue-admin.com/licenseindex.html)
4.如果您将此项目用于商业用途请遵守Apache2.0协议并保留作者技术支持声明。您需保留如下版权声明信息,以及日志和代码中所包含的版权声明信息。所需保留信息均为文案性质,不会影响任何业务内容,如决定商用【产生收益的商业行为均在商用行列】或者必须剔除请[购买授权](https://plugin.gin-react-admin.com/licenseindex.html)
\
<img src="https://qmplusimg.henrongyi.top/openSource/login.jpg" width="1000">
@@ -75,20 +75,20 @@
### 1.1 项目介绍
> Gin-vue-admin是一个基于 [vue](https://vuejs.org) 和 [gin](https://gin-gonic.com) 开发的全栈前后端分离开发基础平台,集成jwt鉴权动态路由动态菜单casbin鉴权,表单生成器,代码生成器等功能,提供多种示例文件,让您把更多时间专注在业务开发上。
> Gin-React-Admin 是一个基于 [react](https://react.dev) 和 [gin](https://gin-gonic.com) 开发的全栈前后端分离开发基础平台,集成 JWT 鉴权动态路由动态菜单、Casbin 鉴权与多种后台治理能力,让您把更多时间专注在业务开发上。
[在线预览](http://demo.gin-vue-admin.com): http://demo.gin-vue-admin.com
[在线预览](http://demo.gin-react-admin.com): http://demo.gin-react-admin.com
测试用户名admin
测试密码123456
### 1.2 贡献指南
Hi! 首先感谢你使用 gin-vue-admin。
Hi! 首先感谢你使用 Gin-React-Admin。
Gin-vue-admin 是一套为快速研发准备的一整套前后端分离架构式的开源框架,旨在快速搭建中小型项目。
Gin-React-Admin 是一套为快速研发准备的一整套前后端分离架构式的开源框架,旨在快速搭建中小型项目。
Gin-vue-admin 的成长离不开大家的支持,如果你愿意为 gin-vue-admin 贡献代码或提供建议,请阅读以下内容。
Gin-React-Admin 的成长离不开大家的支持,如果你愿意为 Gin-React-Admin 贡献代码或提供建议,请阅读以下内容。
#### 1.2.1 Issue 规范
- issue 仅用于提交 Bug 或 Feature 以及设计相关的内容,其它内容可能会被直接关闭。
@@ -114,12 +114,12 @@ Gin-vue-admin 的成长离不开大家的支持,如果你愿意为 gin-vue-adm
### 2.1 server项目
使用 `Goland` 等编辑工具打开server目录不可以打开 gin-vue-admin 根目录
使用 `Goland` 等编辑工具打开server目录不可以打开 gin-react-admin 根目录
```bash
# 克隆项目
git clone https://github.com/flipped-aurora/gin-vue-admin.git
git clone https://github.com/flipped-aurora/gin-react-admin.git
# 进入server文件夹
cd server
@@ -165,7 +165,7 @@ swag init
#### 2.4.1 开发
使用`VSCode`打开根目录下的工作区文件`gin-vue-admin.code-workspace`,在边栏可以看到三个虚拟目录:`backend`、`frontend`、`root`。
使用`VSCode`打开根目录下的工作区文件`gin-react-admin.code-workspace`,在边栏可以看到三个虚拟目录:`backend`、`frontend`、`root`。
#### 2.4.2 运行/调试
@@ -194,7 +194,7 @@ swag init
### 4.1 系统架构图
![系统架构图](http://qmplusimg.henrongyi.top/gva/gin-vue-admin.png)
![系统架构图](http://qmplusimg.henrongyi.top/gva/gin-react-admin.png)
### 4.2 前端详细设计图 (提供者:<a href="https://github.com/baobeisuper">baobeisuper</a>
@@ -219,7 +219,7 @@ swag init
├── packfile (静态文件打包)
├── resource (静态资源文件夹)
│ ├── excel (excel导入导出默认路径)
│ ├── page (表单生成器)
│ ├── page (页面静态资源目录)
│ └── template (模板)
├── router (路由层)
├── service (service层)
@@ -242,7 +242,7 @@ swag init
│ ├── components -- 全局组件
│ ├── core -- gva 组件包
│ │ ├── config.js -- gva网站配置文件
│ │ ├── gin-vue-admin.js -- 注册欢迎文件
│ │ ├── gin-react-admin.js -- 注册欢迎文件
│ │ └── global.js -- 统一导入文件
│ ├── directive -- v-auth 注册文件
│ ├── main.js -- 主文件
@@ -313,11 +313,10 @@ swag init
- 配置管理:配置文件可前台修改(在线体验站点不开放此功能)。
- 条件搜索:增加条件搜索示例。
- restful示例可以参考用户管理模块中的示例API。
- 前端文件参考: [web/src/view/superAdmin/api/api.vue](https://github.com/flipped-aurora/gin-vue-admin/blob/master/web/src/view/superAdmin/api/api.vue)
- 后台文件参考: [server/router/sys_api.go](https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/router/sys_api.go)
- 前端文件参考: [web/src/view/superAdmin/api/api.vue](https://github.com/flipped-aurora/gin-react-admin/blob/master/web/src/view/superAdmin/api/api.vue)
- 后台文件参考: [server/router/sys_api.go](https://github.com/flipped-aurora/gin-react-admin/blob/master/server/router/sys_api.go)
- 多点登录限制:需要在`config.yaml`中把`system`中的`use-multipoint`修改为true(需要自行配置Redis和Config中的Redis参数测试阶段有bug请及时反馈)。
- 分片上传:提供文件分片上传和大文件分片上传功能示例。
- 表单生成器:表单生成器借助 [@Variant Form](https://github.com/vform666/variant-form) 。
- 代码生成器后台基础逻辑以及简单curd的代码生成器。
## 6. 知识库
@@ -346,7 +345,7 @@ swag init
> bilibilihttps://space.bilibili.com/322210472/channel/detail?cid=126418&ctype=0
5gin-vue-admin 版本更新介绍视频
5gin-react-admin 版本更新介绍视频
> bilibilihttps://www.bilibili.com/video/BV1kv4y1g7nT
@@ -369,21 +368,21 @@ decodeBytes, err := base64.StdEncoding.DecodeString(str)
fmt.Println(decodeBytes, err)
```
### [关于我们](https://www.gin-vue-admin.com/about/join.html)
### [关于我们](https://www.gin-react-admin.com/about/join.html)
## 8. 贡献者
感谢您对gin-vue-admin的贡献!
感谢您对gin-react-admin的贡献!
<a href="https://openomy.app/github/flipped-aurora/gin-vue-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=flipped-aurora/gin-vue-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
<a href="https://openomy.app/github/flipped-aurora/gin-react-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=flipped-aurora/gin-react-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
## 9. 捐赠
如果你觉得这个项目对你有帮助,你可以请作者喝饮料 :tropical_drink: [点我](https://www.gin-vue-admin.com/coffee/index.html)
如果你觉得这个项目对你有帮助,你可以请作者喝饮料 :tropical_drink: [点我](https://www.gin-react-admin.com/coffee/index.html)
## 10. 注意事项
请严格遵守Apache 2.0协议并保留作品声明,去除版权信息请务必[获取授权](https://plugin.gin-vue-admin.com/license)
请严格遵守Apache 2.0协议并保留作品声明,去除版权信息请务必[获取授权](https://plugin.gin-react-admin.com/license)
未授权去除版权信息将依法追究法律责任

View File

@@ -3,12 +3,12 @@ kind: ConfigMap
metadata:
name: config.yaml
annotations:
flipped-aurora/gin-vue-admin: backend
github: "https://github.com/flipped-aurora/gin-vue-admin.git"
flipped-aurora/gin-react-admin: backend
github: "https://github.com/flipped-aurora/gin-react-admin.git"
app.kubernetes.io/version: 0.0.1
labels:
app: gva-server
version: gva-vue3
version: gva-react
data:
config.yaml: |
# git.echol.cn/loser/Go-Web-Template/server Global Configuration
@@ -125,7 +125,7 @@ data:
region: 'ap-shanghai'
secret-id: 'xxxxxxxx'
secret-key: 'xxxxxxxx'
base-url: 'https://gin.vue.admin'
base-url: 'https://gin-react-admin.com'
path-prefix: 'git.echol.cn/loser/Go-Web-Template/server'
# excel configuration

View File

@@ -3,26 +3,26 @@ kind: Deployment
metadata:
name: gva-server
annotations:
flipped-aurora/gin-vue-admin: backend
github: "https://github.com/flipped-aurora/gin-vue-admin.git"
flipped-aurora/gin-react-admin: backend
github: "https://github.com/flipped-aurora/gin-react-admin.git"
app.kubernetes.io/version: 0.0.1
labels:
app: gva-server
version: gva-vue3
version: gva-react
spec:
replicas: 1
selector:
matchLabels:
app: gva-server
version: gva-vue3
version: gva-react
template:
metadata:
labels:
app: gva-server
version: gva-vue3
version: gva-react
spec:
containers:
- name: gin-vue-admin-container
- name: gin-react-admin-container
image: registry.cn-hangzhou.aliyuncs.com/gva/server:latest
imagePullPolicy: Always
ports:

View File

@@ -3,16 +3,16 @@ kind: Service
metadata:
name: gva-server
annotations:
flipped-aurora/gin-vue-admin: backend
github: "https://github.com/flipped-aurora/gin-vue-admin.git"
flipped-aurora/gin-react-admin: backend
github: "https://github.com/flipped-aurora/gin-react-admin.git"
app.kubernetes.io/version: 0.0.1
labels:
app: gva-server
version: gva-vue3
version: gva-react
spec:
selector:
app: gva-server
version: gva-vue3
version: gva-react
ports:
- port: 8888
name: http

View File

@@ -43,7 +43,7 @@
| `packfile` | 静态文件打包 | 静态文件打包 |
| `resource` | 静态资源文件夹 | 负责存放静态文件 |
| `--excel` | excel导入导出默认路径 | excel导入导出默认路径 |
| `--page` | 表单生成器 | 表单生成器 打包后的dist |
| `--page` | 页面静态资源目录 | 历史页面资源输出目录 |
| `--template` | 模板 | 模板文件夹,存放的是代码生成器的模板 |
| `router` | 路由层 | 路由层 |
| `service` | service层 | 存放业务逻辑问题 |
@@ -51,4 +51,3 @@
| `utils` | 工具包 | 工具函数封装 |
| `--timer` | timer | 定时器接口封装 |
| `--upload` | oss | oss接口封装 |

View File

@@ -15,10 +15,8 @@ type ApiGroup struct {
OperationRecordApi
DictionaryDetailApi
AuthorityBtnApi
SysExportTemplateApi
McpApi
SysParamsApi
SysVersionApi
SysErrorApi
LoginLogApi
ApiTokenApi
@@ -39,7 +37,6 @@ var (
sysParamsService = service.ServiceGroupApp.SystemServiceGroup.SysParamsService
operationRecordService = service.ServiceGroupApp.SystemServiceGroup.OperationRecordService
dictionaryDetailService = service.ServiceGroupApp.SystemServiceGroup.DictionaryDetailService
sysVersionService = service.ServiceGroupApp.SystemServiceGroup.SysVersionService
mcpService = service.ServiceGroupApp.SystemServiceGroup.McpService
sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService
loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService

View File

@@ -1,456 +0,0 @@
package system
import (
"fmt"
"net/http"
"net/url"
"sync"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/common/request"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
"git.echol.cn/loser/Go-Web-Template/server/service"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// 用于token一次性存储
var (
exportTokenCache = make(map[string]interface{})
exportTokenExpiration = make(map[string]time.Time)
tokenMutex sync.RWMutex
)
// 五分钟检测窗口过期
func cleanupExpiredTokens() {
for {
time.Sleep(5 * time.Minute)
tokenMutex.Lock()
now := time.Now()
for token, expiry := range exportTokenExpiration {
if now.After(expiry) {
delete(exportTokenCache, token)
delete(exportTokenExpiration, token)
}
}
tokenMutex.Unlock()
}
}
func init() {
go cleanupExpiredTokens()
}
type SysExportTemplateApi struct {
}
var sysExportTemplateService = service.ServiceGroupApp.SystemServiceGroup.SysExportTemplateService
// PreviewSQL 预览最终生成的SQL
// @Tags SysExportTemplate
// @Summary 预览最终生成的SQL不执行查询仅返回SQL字符串
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param templateID query string true "导出模板ID"
// @Param params query string false "查询参数编码字符串,参考 ExportExcel 组件"
// @Success 200 {object} response.Response{data=map[string]string} "获取成功"
// @Router /sysExportTemplate/previewSQL [get]
func (sysExportTemplateApi *SysExportTemplateApi) PreviewSQL(c *gin.Context) {
templateID := c.Query("templateID")
if templateID == "" {
response.FailWithMessage("模板ID不能为空", c)
return
}
// 直接复用导出接口的参数组织方式:使用 URL Query其中 params 为内部编码的查询字符串
queryParams := c.Request.URL.Query()
if sqlPreview, err := sysExportTemplateService.PreviewSQL(templateID, queryParams); err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
} else {
response.OkWithData(gin.H{"sql": sqlPreview}, c)
}
}
// CreateSysExportTemplate 创建导出模板
// @Tags SysExportTemplate
// @Summary 创建导出模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body system.SysExportTemplate true "创建导出模板"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}"
// @Router /sysExportTemplate/createSysExportTemplate [post]
func (sysExportTemplateApi *SysExportTemplateApi) CreateSysExportTemplate(c *gin.Context) {
var sysExportTemplate system.SysExportTemplate
err := c.ShouldBindJSON(&sysExportTemplate)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
verify := utils.Rules{
"Name": {utils.NotEmpty()},
}
if err := utils.Verify(sysExportTemplate, verify); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := sysExportTemplateService.CreateSysExportTemplate(&sysExportTemplate); err != nil {
global.GVA_LOG.Error("创建失败!", zap.Error(err))
response.FailWithMessage("创建失败", c)
} else {
response.OkWithMessage("创建成功", c)
}
}
// DeleteSysExportTemplate 删除导出模板
// @Tags SysExportTemplate
// @Summary 删除导出模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body system.SysExportTemplate true "删除导出模板"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
// @Router /sysExportTemplate/deleteSysExportTemplate [delete]
func (sysExportTemplateApi *SysExportTemplateApi) DeleteSysExportTemplate(c *gin.Context) {
var sysExportTemplate system.SysExportTemplate
err := c.ShouldBindJSON(&sysExportTemplate)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := sysExportTemplateService.DeleteSysExportTemplate(sysExportTemplate); err != nil {
global.GVA_LOG.Error("删除失败!", zap.Error(err))
response.FailWithMessage("删除失败", c)
} else {
response.OkWithMessage("删除成功", c)
}
}
// DeleteSysExportTemplateByIds 批量删除导出模板
// @Tags SysExportTemplate
// @Summary 批量删除导出模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.IdsReq true "批量删除导出模板"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"批量删除成功"}"
// @Router /sysExportTemplate/deleteSysExportTemplateByIds [delete]
func (sysExportTemplateApi *SysExportTemplateApi) DeleteSysExportTemplateByIds(c *gin.Context) {
var IDS request.IdsReq
err := c.ShouldBindJSON(&IDS)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := sysExportTemplateService.DeleteSysExportTemplateByIds(IDS); err != nil {
global.GVA_LOG.Error("批量删除失败!", zap.Error(err))
response.FailWithMessage("批量删除失败", c)
} else {
response.OkWithMessage("批量删除成功", c)
}
}
// UpdateSysExportTemplate 更新导出模板
// @Tags SysExportTemplate
// @Summary 更新导出模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body system.SysExportTemplate true "更新导出模板"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"更新成功"}"
// @Router /sysExportTemplate/updateSysExportTemplate [put]
func (sysExportTemplateApi *SysExportTemplateApi) UpdateSysExportTemplate(c *gin.Context) {
var sysExportTemplate system.SysExportTemplate
err := c.ShouldBindJSON(&sysExportTemplate)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
verify := utils.Rules{
"Name": {utils.NotEmpty()},
}
if err := utils.Verify(sysExportTemplate, verify); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := sysExportTemplateService.UpdateSysExportTemplate(sysExportTemplate); err != nil {
global.GVA_LOG.Error("更新失败!", zap.Error(err))
response.FailWithMessage("更新失败", c)
} else {
response.OkWithMessage("更新成功", c)
}
}
// FindSysExportTemplate 用id查询导出模板
// @Tags SysExportTemplate
// @Summary 用id查询导出模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data query system.SysExportTemplate true "用id查询导出模板"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}"
// @Router /sysExportTemplate/findSysExportTemplate [get]
func (sysExportTemplateApi *SysExportTemplateApi) FindSysExportTemplate(c *gin.Context) {
var sysExportTemplate system.SysExportTemplate
err := c.ShouldBindQuery(&sysExportTemplate)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if resysExportTemplate, err := sysExportTemplateService.GetSysExportTemplate(sysExportTemplate.ID); err != nil {
global.GVA_LOG.Error("查询失败!", zap.Error(err))
response.FailWithMessage("查询失败", c)
} else {
response.OkWithData(gin.H{"resysExportTemplate": resysExportTemplate}, c)
}
}
// GetSysExportTemplateList 分页获取导出模板列表
// @Tags SysExportTemplate
// @Summary 分页获取导出模板列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data query systemReq.SysExportTemplateSearch true "分页获取导出模板列表"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
// @Router /sysExportTemplate/getSysExportTemplateList [get]
func (sysExportTemplateApi *SysExportTemplateApi) GetSysExportTemplateList(c *gin.Context) {
var pageInfo systemReq.SysExportTemplateSearch
err := c.ShouldBindQuery(&pageInfo)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if list, total, err := sysExportTemplateService.GetSysExportTemplateInfoList(pageInfo); err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
} else {
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: pageInfo.Page,
PageSize: pageInfo.PageSize,
}, "获取成功", c)
}
}
// ExportExcel 导出表格token
// @Tags SysExportTemplate
// @Summary 导出表格
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/exportExcel [get]
func (sysExportTemplateApi *SysExportTemplateApi) ExportExcel(c *gin.Context) {
templateID := c.Query("templateID")
if templateID == "" {
response.FailWithMessage("模板ID不能为空", c)
return
}
queryParams := c.Request.URL.Query()
//创造一次性token
token := utils.RandomString(32) // 随机32位
// 记录本次请求参数
exportParams := map[string]interface{}{
"templateID": templateID,
"queryParams": queryParams,
}
// 参数保留记录完成鉴权
tokenMutex.Lock()
exportTokenCache[token] = exportParams
exportTokenExpiration[token] = time.Now().Add(30 * time.Minute)
tokenMutex.Unlock()
// 生成一次性链接
exportUrl := fmt.Sprintf("/sysExportTemplate/exportExcelByToken?token=%s", token)
response.OkWithData(exportUrl, c)
}
// ExportExcelByToken 导出表格
// @Tags ExportExcelByToken
// @Summary 导出表格
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/exportExcelByToken [get]
func (sysExportTemplateApi *SysExportTemplateApi) ExportExcelByToken(c *gin.Context) {
token := c.Query("token")
if token == "" {
response.FailWithMessage("导出token不能为空", c)
return
}
// 获取token并且从缓存中剔除
tokenMutex.RLock()
exportParamsRaw, exists := exportTokenCache[token]
expiry, _ := exportTokenExpiration[token]
tokenMutex.RUnlock()
if !exists || time.Now().After(expiry) {
global.GVA_LOG.Error("导出token无效或已过期!")
response.FailWithMessage("导出token无效或已过期", c)
return
}
// 从token获取参数
exportParams, ok := exportParamsRaw.(map[string]interface{})
if !ok {
global.GVA_LOG.Error("解析导出参数失败!")
response.FailWithMessage("解析导出参数失败", c)
return
}
// 获取导出参数
templateID := exportParams["templateID"].(string)
queryParams := exportParams["queryParams"].(url.Values)
// 清理一次性token
tokenMutex.Lock()
delete(exportTokenCache, token)
delete(exportTokenExpiration, token)
tokenMutex.Unlock()
// 导出
if file, name, err := sysExportTemplateService.ExportExcel(templateID, queryParams); err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
} else {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name+utils.RandomString(6)+".xlsx"))
c.Header("success", "true")
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", file.Bytes())
}
}
// ExportTemplate 导出表格模板
// @Tags SysExportTemplate
// @Summary 导出表格模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/exportTemplate [get]
func (sysExportTemplateApi *SysExportTemplateApi) ExportTemplate(c *gin.Context) {
templateID := c.Query("templateID")
if templateID == "" {
response.FailWithMessage("模板ID不能为空", c)
return
}
// 创造一次性token
token := utils.RandomString(32) // 随机32位
// 记录本次请求参数
exportParams := map[string]interface{}{
"templateID": templateID,
"isTemplate": true,
}
// 参数保留记录完成鉴权
tokenMutex.Lock()
exportTokenCache[token] = exportParams
exportTokenExpiration[token] = time.Now().Add(30 * time.Minute)
tokenMutex.Unlock()
// 生成一次性链接
exportUrl := fmt.Sprintf("/sysExportTemplate/exportTemplateByToken?token=%s", token)
response.OkWithData(exportUrl, c)
}
// ExportTemplateByToken 通过token导出表格模板
// @Tags ExportTemplateByToken
// @Summary 通过token导出表格模板
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/exportTemplateByToken [get]
func (sysExportTemplateApi *SysExportTemplateApi) ExportTemplateByToken(c *gin.Context) {
token := c.Query("token")
if token == "" {
response.FailWithMessage("导出token不能为空", c)
return
}
// 获取token并且从缓存中剔除
tokenMutex.RLock()
exportParamsRaw, exists := exportTokenCache[token]
expiry, _ := exportTokenExpiration[token]
tokenMutex.RUnlock()
if !exists || time.Now().After(expiry) {
global.GVA_LOG.Error("导出token无效或已过期!")
response.FailWithMessage("导出token无效或已过期", c)
return
}
// 从token获取参数
exportParams, ok := exportParamsRaw.(map[string]interface{})
if !ok {
global.GVA_LOG.Error("解析导出参数失败!")
response.FailWithMessage("解析导出参数失败", c)
return
}
// 检查是否为模板导出
isTemplate, _ := exportParams["isTemplate"].(bool)
if !isTemplate {
global.GVA_LOG.Error("token类型错误!")
response.FailWithMessage("token类型错误", c)
return
}
// 获取导出参数
templateID := exportParams["templateID"].(string)
// 清理一次性token
tokenMutex.Lock()
delete(exportTokenCache, token)
delete(exportTokenExpiration, token)
tokenMutex.Unlock()
// 导出模板
if file, name, err := sysExportTemplateService.ExportTemplate(templateID); err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
} else {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name+"模板.xlsx"))
c.Header("success", "true")
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", file.Bytes())
}
}
// ImportExcel 导入表格
// @Tags SysImportTemplate
// @Summary 导入表格
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/importExcel [post]
func (sysExportTemplateApi *SysExportTemplateApi) ImportExcel(c *gin.Context) {
templateID := c.Query("templateID")
if templateID == "" {
response.FailWithMessage("模板ID不能为空", c)
return
}
file, err := c.FormFile("file")
if err != nil {
global.GVA_LOG.Error("文件获取失败!", zap.Error(err))
response.FailWithMessage("文件获取失败", c)
return
}
if err := sysExportTemplateService.ImportExcel(templateID, file); err != nil {
global.GVA_LOG.Error(err.Error(), zap.Error(err))
response.FailWithMessage(err.Error(), c)
} else {
response.OkWithMessage("导入成功", c)
}
}

View File

@@ -1,486 +0,0 @@
package system
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/common/response"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
systemRes "git.echol.cn/loser/Go-Web-Template/server/model/system/response"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type SysVersionApi struct{}
// buildMenuTree 构建菜单树结构
func buildMenuTree(menus []system.SysBaseMenu) []system.SysBaseMenu {
// 创建菜单映射
menuMap := make(map[uint]*system.SysBaseMenu)
for i := range menus {
menuMap[menus[i].ID] = &menus[i]
}
// 构建树结构
var rootMenus []system.SysBaseMenu
for _, menu := range menus {
if menu.ParentId == 0 {
// 根菜单
menuData := convertMenuToStruct(menu, menuMap)
rootMenus = append(rootMenus, menuData)
}
}
// 按sort排序根菜单
sort.Slice(rootMenus, func(i, j int) bool {
return rootMenus[i].Sort < rootMenus[j].Sort
})
return rootMenus
}
// convertMenuToStruct 将菜单转换为结构体并递归处理子菜单
func convertMenuToStruct(menu system.SysBaseMenu, menuMap map[uint]*system.SysBaseMenu) system.SysBaseMenu {
result := system.SysBaseMenu{
Path: menu.Path,
Name: menu.Name,
Hidden: menu.Hidden,
Component: menu.Component,
Sort: menu.Sort,
Meta: menu.Meta,
}
// 清理并复制参数数据
if len(menu.Parameters) > 0 {
cleanParameters := make([]system.SysBaseMenuParameter, 0, len(menu.Parameters))
for _, param := range menu.Parameters {
cleanParam := system.SysBaseMenuParameter{
Type: param.Type,
Key: param.Key,
Value: param.Value,
// 不复制 ID, CreatedAt, UpdatedAt, SysBaseMenuID
}
cleanParameters = append(cleanParameters, cleanParam)
}
result.Parameters = cleanParameters
}
// 清理并复制菜单按钮数据
if len(menu.MenuBtn) > 0 {
cleanMenuBtns := make([]system.SysBaseMenuBtn, 0, len(menu.MenuBtn))
for _, btn := range menu.MenuBtn {
cleanBtn := system.SysBaseMenuBtn{
Name: btn.Name,
Desc: btn.Desc,
// 不复制 ID, CreatedAt, UpdatedAt, SysBaseMenuID
}
cleanMenuBtns = append(cleanMenuBtns, cleanBtn)
}
result.MenuBtn = cleanMenuBtns
}
// 查找并处理子菜单
var children []system.SysBaseMenu
for _, childMenu := range menuMap {
if childMenu.ParentId == menu.ID {
childData := convertMenuToStruct(*childMenu, menuMap)
children = append(children, childData)
}
}
// 按sort排序子菜单
if len(children) > 0 {
sort.Slice(children, func(i, j int) bool {
return children[i].Sort < children[j].Sort
})
result.Children = children
}
return result
}
// DeleteSysVersion 删除版本管理
// @Tags SysVersion
// @Summary 删除版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body system.SysVersion true "删除版本管理"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /sysVersion/deleteSysVersion [delete]
func (sysVersionApi *SysVersionApi) DeleteSysVersion(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
ID := c.Query("ID")
err := sysVersionService.DeleteSysVersion(ctx, ID)
if err != nil {
global.GVA_LOG.Error("删除失败!", zap.Error(err))
response.FailWithMessage("删除失败:"+err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
// DeleteSysVersionByIds 批量删除版本管理
// @Tags SysVersion
// @Summary 批量删除版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{msg=string} "批量删除成功"
// @Router /sysVersion/deleteSysVersionByIds [delete]
func (sysVersionApi *SysVersionApi) DeleteSysVersionByIds(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
IDs := c.QueryArray("IDs[]")
err := sysVersionService.DeleteSysVersionByIds(ctx, IDs)
if err != nil {
global.GVA_LOG.Error("批量删除失败!", zap.Error(err))
response.FailWithMessage("批量删除失败:"+err.Error(), c)
return
}
response.OkWithMessage("批量删除成功", c)
}
// FindSysVersion 用id查询版本管理
// @Tags SysVersion
// @Summary 用id查询版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param ID query uint true "用id查询版本管理"
// @Success 200 {object} response.Response{data=system.SysVersion,msg=string} "查询成功"
// @Router /sysVersion/findSysVersion [get]
func (sysVersionApi *SysVersionApi) FindSysVersion(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
ID := c.Query("ID")
resysVersion, err := sysVersionService.GetSysVersion(ctx, ID)
if err != nil {
global.GVA_LOG.Error("查询失败!", zap.Error(err))
response.FailWithMessage("查询失败:"+err.Error(), c)
return
}
response.OkWithData(resysVersion, c)
}
// GetSysVersionList 分页获取版本管理列表
// @Tags SysVersion
// @Summary 分页获取版本管理列表
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data query systemReq.SysVersionSearch true "分页获取版本管理列表"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /sysVersion/getSysVersionList [get]
func (sysVersionApi *SysVersionApi) GetSysVersionList(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
var pageInfo systemReq.SysVersionSearch
err := c.ShouldBindQuery(&pageInfo)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
list, total, err := sysVersionService.GetSysVersionInfoList(ctx, pageInfo)
if err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: pageInfo.Page,
PageSize: pageInfo.PageSize,
}, "获取成功", c)
}
// GetSysVersionPublic 不需要鉴权的版本管理接口
// @Tags SysVersion
// @Summary 不需要鉴权的版本管理接口
// @Accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{data=object,msg=string} "获取成功"
// @Router /sysVersion/getSysVersionPublic [get]
func (sysVersionApi *SysVersionApi) GetSysVersionPublic(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
// 此接口不需要鉴权
// 示例为返回了一个固定的消息接口一般本接口用于C端服务需要自己实现业务逻辑
sysVersionService.GetSysVersionPublic(ctx)
response.OkWithDetailed(gin.H{
"info": "不需要鉴权的版本管理接口信息",
}, "获取成功", c)
}
// ExportVersion 创建发版数据
// @Tags SysVersion
// @Summary 创建发版数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body systemReq.ExportVersionRequest true "创建发版数据"
// @Success 200 {object} response.Response{msg=string} "创建成功"
// @Router /sysVersion/exportVersion [post]
func (sysVersionApi *SysVersionApi) ExportVersion(c *gin.Context) {
ctx := c.Request.Context()
var req systemReq.ExportVersionRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取选中的菜单数据
var menuData []system.SysBaseMenu
if len(req.MenuIds) > 0 {
menuData, err = sysVersionService.GetMenusByIds(ctx, req.MenuIds)
if err != nil {
global.GVA_LOG.Error("获取菜单数据失败!", zap.Error(err))
response.FailWithMessage("获取菜单数据失败:"+err.Error(), c)
return
}
}
// 获取选中的API数据
var apiData []system.SysApi
if len(req.ApiIds) > 0 {
apiData, err = sysVersionService.GetApisByIds(ctx, req.ApiIds)
if err != nil {
global.GVA_LOG.Error("获取API数据失败!", zap.Error(err))
response.FailWithMessage("获取API数据失败:"+err.Error(), c)
return
}
}
// 获取选中的字典数据
var dictData []system.SysDictionary
if len(req.DictIds) > 0 {
dictData, err = sysVersionService.GetDictionariesByIds(ctx, req.DictIds)
if err != nil {
global.GVA_LOG.Error("获取字典数据失败!", zap.Error(err))
response.FailWithMessage("获取字典数据失败:"+err.Error(), c)
return
}
}
// 处理菜单数据构建递归的children结构
processedMenus := buildMenuTree(menuData)
// 处理API数据清除ID和时间戳字段
processedApis := make([]system.SysApi, 0, len(apiData))
for _, api := range apiData {
cleanApi := system.SysApi{
Path: api.Path,
Description: api.Description,
ApiGroup: api.ApiGroup,
Method: api.Method,
}
processedApis = append(processedApis, cleanApi)
}
// 处理字典数据清除ID和时间戳字段包含字典详情
processedDicts := make([]system.SysDictionary, 0, len(dictData))
for _, dict := range dictData {
cleanDict := system.SysDictionary{
Name: dict.Name,
Type: dict.Type,
Status: dict.Status,
Desc: dict.Desc,
}
// 处理字典详情数据清除ID和时间戳字段
cleanDetails := make([]system.SysDictionaryDetail, 0, len(dict.SysDictionaryDetails))
for _, detail := range dict.SysDictionaryDetails {
cleanDetail := system.SysDictionaryDetail{
Label: detail.Label,
Value: detail.Value,
Extend: detail.Extend,
Status: detail.Status,
Sort: detail.Sort,
// 不复制 ID, CreatedAt, UpdatedAt, SysDictionaryID
}
cleanDetails = append(cleanDetails, cleanDetail)
}
cleanDict.SysDictionaryDetails = cleanDetails
processedDicts = append(processedDicts, cleanDict)
}
// 构建导出数据
exportData := systemRes.ExportVersionResponse{
Version: systemReq.VersionInfo{
Name: req.VersionName,
Code: req.VersionCode,
Description: req.Description,
ExportTime: time.Now().Format("2006-01-02 15:04:05"),
},
Menus: processedMenus,
Apis: processedApis,
Dictionaries: processedDicts,
}
// 转换为JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
global.GVA_LOG.Error("JSON序列化失败!", zap.Error(err))
response.FailWithMessage("JSON序列化失败:"+err.Error(), c)
return
}
// 保存版本记录
version := system.SysVersion{
VersionName: utils.Pointer(req.VersionName),
VersionCode: utils.Pointer(req.VersionCode),
Description: utils.Pointer(req.Description),
VersionData: utils.Pointer(string(jsonData)),
}
err = sysVersionService.CreateSysVersion(ctx, &version)
if err != nil {
global.GVA_LOG.Error("保存版本记录失败!", zap.Error(err))
response.FailWithMessage("保存版本记录失败:"+err.Error(), c)
return
}
response.OkWithMessage("创建发版成功", c)
}
// DownloadVersionJson 下载版本JSON数据
// @Tags SysVersion
// @Summary 下载版本JSON数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param ID query string true "版本ID"
// @Success 200 {object} response.Response{data=object,msg=string} "下载成功"
// @Router /sysVersion/downloadVersionJson [get]
func (sysVersionApi *SysVersionApi) DownloadVersionJson(c *gin.Context) {
ctx := c.Request.Context()
ID := c.Query("ID")
if ID == "" {
response.FailWithMessage("版本ID不能为空", c)
return
}
// 获取版本记录
version, err := sysVersionService.GetSysVersion(ctx, ID)
if err != nil {
global.GVA_LOG.Error("获取版本记录失败!", zap.Error(err))
response.FailWithMessage("获取版本记录失败:"+err.Error(), c)
return
}
// 构建JSON数据
var jsonData []byte
if version.VersionData != nil && *version.VersionData != "" {
jsonData = []byte(*version.VersionData)
} else {
// 如果没有存储的JSON数据构建一个基本的结构
basicData := systemRes.ExportVersionResponse{
Version: systemReq.VersionInfo{
Name: *version.VersionName,
Code: *version.VersionCode,
Description: *version.Description,
ExportTime: version.CreatedAt.Format("2006-01-02 15:04:05"),
},
Menus: []system.SysBaseMenu{},
Apis: []system.SysApi{},
}
jsonData, _ = json.MarshalIndent(basicData, "", " ")
}
// 设置下载响应头
filename := fmt.Sprintf("version_%s_%s.json", *version.VersionCode, time.Now().Format("20060102150405"))
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Length", strconv.Itoa(len(jsonData)))
c.Data(http.StatusOK, "application/json", jsonData)
}
// ImportVersion 导入版本数据
// @Tags SysVersion
// @Summary 导入版本数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body systemReq.ImportVersionRequest true "版本JSON数据"
// @Success 200 {object} response.Response{msg=string} "导入成功"
// @Router /sysVersion/importVersion [post]
func (sysVersionApi *SysVersionApi) ImportVersion(c *gin.Context) {
ctx := c.Request.Context()
// 获取JSON数据
var importData systemReq.ImportVersionRequest
err := c.ShouldBindJSON(&importData)
if err != nil {
response.FailWithMessage("解析JSON数据失败:"+err.Error(), c)
return
}
// 验证数据格式
if importData.VersionInfo.Name == "" || importData.VersionInfo.Code == "" {
response.FailWithMessage("版本信息格式错误", c)
return
}
// 导入菜单数据
if len(importData.ExportMenu) > 0 {
if err := sysVersionService.ImportMenus(ctx, importData.ExportMenu); err != nil {
global.GVA_LOG.Error("导入菜单失败!", zap.Error(err))
response.FailWithMessage("导入菜单失败: "+err.Error(), c)
return
}
}
// 导入API数据
if len(importData.ExportApi) > 0 {
if err := sysVersionService.ImportApis(importData.ExportApi); err != nil {
global.GVA_LOG.Error("导入API失败!", zap.Error(err))
response.FailWithMessage("导入API失败: "+err.Error(), c)
return
}
}
// 导入字典数据
if len(importData.ExportDictionary) > 0 {
if err := sysVersionService.ImportDictionaries(importData.ExportDictionary); err != nil {
global.GVA_LOG.Error("导入字典失败!", zap.Error(err))
response.FailWithMessage("导入字典失败: "+err.Error(), c)
return
}
}
// 创建导入记录
jsonData, _ := json.Marshal(importData)
version := system.SysVersion{
VersionName: utils.Pointer(importData.VersionInfo.Name),
VersionCode: utils.Pointer(fmt.Sprintf("%s_imported_%s", importData.VersionInfo.Code, time.Now().Format("20060102150405"))),
Description: utils.Pointer(fmt.Sprintf("导入版本: %s", importData.VersionInfo.Description)),
VersionData: utils.Pointer(string(jsonData)),
}
err = sysVersionService.CreateSysVersion(ctx, &version)
if err != nil {
global.GVA_LOG.Error("保存导入记录失败!", zap.Error(err))
// 这里不返回错误,因为数据已经导入成功
}
response.OkWithMessage("导入成功", c)
}

View File

@@ -97,7 +97,7 @@ captcha:
open-captcha-timeout: 3600 # open-captcha大于0时才生效
# mysql connect configuration
# 未初始化之前请勿手动修改数据库信息如果一定要手动初始化请看https://gin-vue-admin.com/docs/first_master
# 未初始化之前请勿手动修改数据库信息如果一定要手动初始化请看https://gin-react-admin.com/docs/first_master
mysql:
path: ""
port: ""
@@ -111,7 +111,7 @@ mysql:
log-zap: false
# pgsql connect configuration
# 未初始化之前请勿手动修改数据库信息如果一定要手动初始化请看https://gin-vue-admin.com/docs/first_master
# 未初始化之前请勿手动修改数据库信息如果一定要手动初始化请看https://gin-react-admin.com/docs/first_master
pgsql:
path: ""
port: ""
@@ -211,7 +211,7 @@ tencent-cos:
region: ap-shanghai
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin.vue.admin
base-url: https://gin-react-admin.com
path-prefix: git.echol.cn/loser/Go-Web-Template/server
# aws s3 configuration (minio compatible)
@@ -223,13 +223,13 @@ aws-s3:
disable-ssl: false
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin.vue.admin
base-url: https://gin-react-admin.com
path-prefix: git.echol.cn/loser/Go-Web-Template/server
# cloudflare r2 configuration
cloudflare-r2:
bucket: xxxx0bucket
base-url: https://gin.vue.admin.com
base-url: https://gin-react-admin.com
path: uploads
account-id: xxx_account_id
access-key-id: xxx_key_id

View File

@@ -34,7 +34,7 @@ func RunServer() {
mcpBaseURL := mcpTool.ResolveMCPServiceURL()
fmt.Printf(`
欢迎使用 gin-vue-admin
欢迎使用 Gin-React-Admin
当前版本:%s
默认自动化文档地址:http://127.0.0.1%s/swagger/index.html
MCP 独立服务请手动启动: go run ./cmd/mcp -config ./cmd/mcp/config.yaml

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ const (
// Version 当前版本号
Version = "v2.9.1"
// AppName 应用名称
AppName = "Gin-Vue-Admin"
AppName = "Gin-React-Admin"
// Description 应用描述
Description = "使用gin+vue进行极速开发的全栈开发基础平台"
Description = "使用gin+react进行极速开发的全栈开发基础平台"
)

View File

@@ -2,6 +2,7 @@ package initialize
import (
"context"
commonModel "git.echol.cn/loser/Go-Web-Template/server/model/common"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
@@ -46,11 +47,7 @@ func (e *ensureTables) MigrateTable(ctx context.Context) (context.Context, error
sysModel.SysBaseMenuParameter{},
sysModel.SysBaseMenuBtn{},
sysModel.SysAuthorityBtn{},
sysModel.SysExportTemplate{},
sysModel.Condition{},
sysModel.JoinTemplate{},
sysModel.SysParams{},
sysModel.SysVersion{},
sysModel.SysError{},
sysModel.SysLoginLog{},
sysModel.SysApiToken{},
@@ -86,9 +83,6 @@ func (e *ensureTables) TableCreated(ctx context.Context) bool {
sysModel.SysBaseMenuParameter{},
sysModel.SysBaseMenuBtn{},
sysModel.SysAuthorityBtn{},
sysModel.SysExportTemplate{},
sysModel.Condition{},
sysModel.JoinTemplate{},
adapter.CasbinRule{},

View File

@@ -55,11 +55,7 @@ func RegisterTables() {
system.SysBaseMenuParameter{},
system.SysBaseMenuBtn{},
system.SysAuthorityBtn{},
system.SysExportTemplate{},
system.Condition{},
system.JoinTemplate{},
system.SysParams{},
system.SysVersion{},
system.SysError{},
system.SysApiToken{},
system.SysLoginLog{},

View File

@@ -84,14 +84,12 @@ func Routers() *gin.Engine {
systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由
systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由
systemRouter.InitSystemRouter(PrivateGroup) // system相关路由
systemRouter.InitSysVersionRouter(PrivateGroup) // 发版相关路由
systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由
systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由
systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理
systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录
systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理
systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理
systemRouter.InitSysExportTemplateRouter(PrivateGroup, PublicGroup) // 导出模板
systemRouter.InitMcpRouter(PrivateGroup) // MCP 管理
systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理
systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志

View File

@@ -20,9 +20,9 @@ import (
// @Tag.Name SysUser
// @Tag.Description 用户
// @title Gin-Vue-Admin Swagger API接口文档
// @title Gin-React-Admin Swagger API接口文档
// @version v2.9.1
// @description 使用gin+vue进行极速开发的全栈开发基础平台
// @description 使用gin+react进行极速开发的全栈开发基础平台
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name x-token

View File

@@ -18,7 +18,7 @@ func requireMCPIntegration(t *testing.T) {
// 测试 MCP 客户端连接
func TestMcpClientConnection(t *testing.T) {
requireMCPIntegration(t)
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-vue-admin MCP服务")
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-react-admin MCP服务")
defer c.Close()
if err != nil {
t.Fatalf("create client failed: %v", err)
@@ -28,7 +28,7 @@ func TestMcpClientConnection(t *testing.T) {
func TestTools(t *testing.T) {
requireMCPIntegration(t)
t.Run("currentTime", func(t *testing.T) {
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-vue-admin MCP服务")
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-react-admin MCP服务")
defer c.Close()
if err != nil {
t.Fatalf("Failed to create client: %v", err)
@@ -58,7 +58,7 @@ func TestTools(t *testing.T) {
t.Run("getNickname", func(t *testing.T) {
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-vue-admin MCP服务")
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-react-admin MCP服务")
defer c.Close()
if err != nil {
t.Fatalf("Failed to create client: %v", err)
@@ -102,7 +102,7 @@ func TestTools(t *testing.T) {
func TestGetTools(t *testing.T) {
requireMCPIntegration(t)
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-vue-admin MCP服务")
c, err := NewClient("http://localhost:8888/sse", "test-client", "1.0.0", "gin-react-admin MCP服务")
defer c.Close()
if err != nil {
t.Fatalf("Failed to create client: %v", err)

View File

@@ -69,14 +69,14 @@ func (m *MenuCreator) New() mcp.Tool {
),
mcp.WithString("name",
mcp.Required(),
mcp.Description("路由name用于Vue RouteruserList"),
mcp.Description("路由name用于前端路由标识userList"),
),
mcp.WithBoolean("hidden",
mcp.Description("是否在菜单列表中隐藏"),
),
mcp.WithString("component",
mcp.Required(),
mcp.Description("对应的前端Vue组件路径,如:view/user/list.vue"),
mcp.Description("对应的前端React组件路径,如:features/users/UserManagementPage"),
),
mcp.WithNumber("sort",
mcp.Description("菜单排序号,数字越小越靠前"),

View File

@@ -1,14 +0,0 @@
package request
import (
"git.echol.cn/loser/Go-Web-Template/server/model/common/request"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
"time"
)
type SysExportTemplateSearch struct {
system.SysExportTemplate
StartCreatedAt *time.Time `json:"startCreatedAt" form:"startCreatedAt"`
EndCreatedAt *time.Time `json:"endCreatedAt" form:"endCreatedAt"`
request.PageInfo
}

View File

@@ -23,7 +23,7 @@ func DefaultMenu() []system.SysBaseMenu {
ParentId: 0,
Path: "dashboard",
Name: "dashboard",
Component: "view/dashboard/index.vue",
Component: "features/dashboard/DashboardPage",
Sort: 1,
Meta: system.Meta{
Title: "仪表盘",

View File

@@ -1,40 +0,0 @@
package request
import (
"git.echol.cn/loser/Go-Web-Template/server/model/common/request"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
"time"
)
type SysVersionSearch struct {
CreatedAtRange []time.Time `json:"createdAtRange" form:"createdAtRange[]"`
VersionName *string `json:"versionName" form:"versionName"`
VersionCode *string `json:"versionCode" form:"versionCode"`
request.PageInfo
}
// ExportVersionRequest 导出版本请求结构体
type ExportVersionRequest struct {
VersionName string `json:"versionName" binding:"required"` // 版本名称
VersionCode string `json:"versionCode" binding:"required"` // 版本号
Description string `json:"description"` // 版本描述
MenuIds []uint `json:"menuIds"` // 选中的菜单ID列表
ApiIds []uint `json:"apiIds"` // 选中的API ID列表
DictIds []uint `json:"dictIds"` // 选中的字典ID列表
}
// ImportVersionRequest 导入版本请求结构体
type ImportVersionRequest struct {
VersionInfo VersionInfo `json:"version" binding:"required"` // 版本信息
ExportMenu []system.SysBaseMenu `json:"menus"` // 菜单数据直接复用SysBaseMenu
ExportApi []system.SysApi `json:"apis"` // API数据直接复用SysApi
ExportDictionary []system.SysDictionary `json:"dictionaries"` // 字典数据直接复用SysDictionary
}
// VersionInfo 版本信息结构体
type VersionInfo struct {
Name string `json:"name" binding:"required"` // 版本名称
Code string `json:"code" binding:"required"` // 版本号
Description string `json:"description"` // 版本描述
ExportTime string `json:"exportTime"` // 导出时间
}

View File

@@ -1,14 +0,0 @@
package response
import (
"git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/model/system/request"
)
// ExportVersionResponse 导出版本响应结构体
type ExportVersionResponse struct {
Version request.VersionInfo `json:"version"` // 版本信息
Menus []system.SysBaseMenu `json:"menus"` // 菜单数据直接复用SysBaseMenu
Apis []system.SysApi `json:"apis"` // API数据直接复用SysApi
Dictionaries []system.SysDictionary `json:"dictionaries"` // 字典数据直接复用SysDictionary
}

View File

@@ -1,46 +0,0 @@
// 自动生成模板SysExportTemplate
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/global"
)
// 导出模板 结构体 SysExportTemplate
type SysExportTemplate struct {
global.GVA_MODEL
DBName string `json:"dbName" form:"dbName" gorm:"column:db_name;comment:数据库名称;"` //数据库名称
Name string `json:"name" form:"name" gorm:"column:name;comment:模板名称;"` //模板名称
TableName string `json:"tableName" form:"tableName" gorm:"column:table_name;comment:表名称;"` //表名称
TemplateID string `json:"templateID" form:"templateID" gorm:"column:template_id;comment:模板标识;"` //模板标识
TemplateInfo string `json:"templateInfo" form:"templateInfo" gorm:"column:template_info;type:text;"` //模板信息
SQL string `json:"sql" form:"sql" gorm:"column:sql;type:text;comment:自定义导出SQL;"` //自定义导出SQL
ImportSQL string `json:"importSql" form:"importSql" gorm:"column:import_sql;type:text;comment:自定义导入SQL;"` //自定义导入SQL
Limit *int `json:"limit" form:"limit" gorm:"column:limit;comment:导出限制"`
Order string `json:"order" form:"order" gorm:"column:order;comment:排序"`
Conditions []Condition `json:"conditions" form:"conditions" gorm:"foreignKey:TemplateID;references:TemplateID;comment:条件"`
JoinTemplate []JoinTemplate `json:"joinTemplate" form:"joinTemplate" gorm:"foreignKey:TemplateID;references:TemplateID;comment:关联"`
}
type JoinTemplate struct {
global.GVA_MODEL
TemplateID string `json:"templateID" form:"templateID" gorm:"column:template_id;comment:模板标识"`
JOINS string `json:"joins" form:"joins" gorm:"column:joins;comment:关联"`
Table string `json:"table" form:"table" gorm:"column:table;comment:关联表"`
ON string `json:"on" form:"on" gorm:"column:on;comment:关联条件"`
}
func (JoinTemplate) TableName() string {
return "sys_export_template_join"
}
type Condition struct {
global.GVA_MODEL
TemplateID string `json:"templateID" form:"templateID" gorm:"column:template_id;comment:模板标识"`
From string `json:"from" form:"from" gorm:"column:from;comment:条件取的key"`
Column string `json:"column" form:"column" gorm:"column:column;comment:作为查询条件的字段"`
Operator string `json:"operator" form:"operator" gorm:"column:operator;comment:操作符"`
}
func (Condition) TableName() string {
return "sys_export_template_condition"
}

View File

@@ -1,20 +0,0 @@
// 自动生成模板SysVersion
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/global"
)
// 版本管理 结构体 SysVersion
type SysVersion struct {
global.GVA_MODEL
VersionName *string `json:"versionName" form:"versionName" gorm:"comment:版本名称;column:version_name;size:255;" binding:"required"` //版本名称
VersionCode *string `json:"versionCode" form:"versionCode" gorm:"comment:版本号;column:version_code;size:100;" binding:"required"` //版本号
Description *string `json:"description" form:"description" gorm:"comment:版本描述;column:description;size:500;"` //版本描述
VersionData *string `json:"versionData" form:"versionData" gorm:"comment:版本数据JSON;column:version_data;type:text;"` //版本数据
}
// TableName 版本管理 SysVersion自定义表名 sys_versions
func (SysVersion) TableName() string {
return "sys_versions"
}

View File

@@ -16,10 +16,8 @@ type RouterGroup struct {
OperationRecordRouter
DictionaryDetailRouter
AuthorityBtnRouter
SysExportTemplateRouter
McpRouter
SysParamsRouter
SysVersionRouter
SysErrorRouter
LoginLogRouter
ApiTokenRouter
@@ -39,8 +37,6 @@ var (
authorityMenuApi = api.ApiGroupApp.SystemApiGroup.AuthorityMenuApi
operationRecordApi = api.ApiGroupApp.SystemApiGroup.OperationRecordApi
dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi
exportTemplateApi = api.ApiGroupApp.SystemApiGroup.SysExportTemplateApi
mcpApi = api.ApiGroupApp.SystemApiGroup.McpApi
sysVersionApi = api.ApiGroupApp.SystemApiGroup.SysVersionApi
sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi
)

View File

@@ -1,35 +0,0 @@
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/middleware"
"github.com/gin-gonic/gin"
)
type SysExportTemplateRouter struct {
}
// InitSysExportTemplateRouter 初始化 导出模板 路由信息
func (s *SysExportTemplateRouter) InitSysExportTemplateRouter(Router *gin.RouterGroup, pubRouter *gin.RouterGroup) {
sysExportTemplateRouter := Router.Group("sysExportTemplate").Use(middleware.OperationRecord())
sysExportTemplateRouterWithoutRecord := Router.Group("sysExportTemplate")
sysExportTemplateRouterWithoutAuth := pubRouter.Group("sysExportTemplate")
{
sysExportTemplateRouter.POST("createSysExportTemplate", exportTemplateApi.CreateSysExportTemplate) // 新建导出模板
sysExportTemplateRouter.DELETE("deleteSysExportTemplate", exportTemplateApi.DeleteSysExportTemplate) // 删除导出模板
sysExportTemplateRouter.DELETE("deleteSysExportTemplateByIds", exportTemplateApi.DeleteSysExportTemplateByIds) // 批量删除导出模板
sysExportTemplateRouter.PUT("updateSysExportTemplate", exportTemplateApi.UpdateSysExportTemplate) // 更新导出模板
sysExportTemplateRouter.POST("importExcel", exportTemplateApi.ImportExcel) // 导入excel模板数据
}
{
sysExportTemplateRouterWithoutRecord.GET("findSysExportTemplate", exportTemplateApi.FindSysExportTemplate) // 根据ID获取导出模板
sysExportTemplateRouterWithoutRecord.GET("getSysExportTemplateList", exportTemplateApi.GetSysExportTemplateList) // 获取导出模板列表
sysExportTemplateRouterWithoutRecord.GET("exportExcel", exportTemplateApi.ExportExcel) // 获取导出token
sysExportTemplateRouterWithoutRecord.GET("exportTemplate", exportTemplateApi.ExportTemplate) // 导出表格模板
sysExportTemplateRouterWithoutRecord.GET("previewSQL", exportTemplateApi.PreviewSQL) // 预览SQL
}
{
sysExportTemplateRouterWithoutAuth.GET("exportExcelByToken", exportTemplateApi.ExportExcelByToken) // 通过token导出表格
sysExportTemplateRouterWithoutAuth.GET("exportTemplateByToken", exportTemplateApi.ExportTemplateByToken) // 通过token导出模板
}
}

View File

@@ -1,25 +0,0 @@
package system
import (
"git.echol.cn/loser/Go-Web-Template/server/middleware"
"github.com/gin-gonic/gin"
)
type SysVersionRouter struct{}
// InitSysVersionRouter 初始化 版本管理 路由信息
func (s *SysVersionRouter) InitSysVersionRouter(Router *gin.RouterGroup) {
sysVersionRouter := Router.Group("sysVersion").Use(middleware.OperationRecord())
sysVersionRouterWithoutRecord := Router.Group("sysVersion")
{
sysVersionRouter.DELETE("deleteSysVersion", sysVersionApi.DeleteSysVersion) // 删除版本管理
sysVersionRouter.DELETE("deleteSysVersionByIds", sysVersionApi.DeleteSysVersionByIds) // 批量删除版本管理
sysVersionRouter.POST("exportVersion", sysVersionApi.ExportVersion) // 导出版本数据
sysVersionRouter.POST("importVersion", sysVersionApi.ImportVersion) // 导入版本数据
}
{
sysVersionRouterWithoutRecord.GET("findSysVersion", sysVersionApi.FindSysVersion) // 根据ID获取版本管理
sysVersionRouterWithoutRecord.GET("getSysVersionList", sysVersionApi.GetSysVersionList) // 获取版本管理列表
sysVersionRouterWithoutRecord.GET("downloadVersionJson", sysVersionApi.DownloadVersionJson) // 下载版本JSON数据
}
}

View File

@@ -14,9 +14,7 @@ type ServiceGroup struct {
OperationRecordService
DictionaryDetailService
AuthorityBtnService
SysExportTemplateService
SysParamsService
SysVersionService
McpService
SysErrorService
LoginLogService

View File

@@ -1,724 +0,0 @@
package system
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/url"
"strconv"
"strings"
"time"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/common/request"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
"git.echol.cn/loser/Go-Web-Template/server/utils"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
)
type SysExportTemplateService struct {
}
var SysExportTemplateServiceApp = new(SysExportTemplateService)
// CreateSysExportTemplate 创建导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) CreateSysExportTemplate(sysExportTemplate *system.SysExportTemplate) (err error) {
err = global.GVA_DB.Create(sysExportTemplate).Error
return err
}
// DeleteSysExportTemplate 删除导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) DeleteSysExportTemplate(sysExportTemplate system.SysExportTemplate) (err error) {
err = global.GVA_DB.Delete(&sysExportTemplate).Error
return err
}
// DeleteSysExportTemplateByIds 批量删除导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) DeleteSysExportTemplateByIds(ids request.IdsReq) (err error) {
err = global.GVA_DB.Delete(&[]system.SysExportTemplate{}, "id in ?", ids.Ids).Error
return err
}
// UpdateSysExportTemplate 更新导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) UpdateSysExportTemplate(sysExportTemplate system.SysExportTemplate) (err error) {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
conditions := sysExportTemplate.Conditions
e := tx.Delete(&[]system.Condition{}, "template_id = ?", sysExportTemplate.TemplateID).Error
if e != nil {
return e
}
sysExportTemplate.Conditions = nil
joins := sysExportTemplate.JoinTemplate
e = tx.Delete(&[]system.JoinTemplate{}, "template_id = ?", sysExportTemplate.TemplateID).Error
if e != nil {
return e
}
sysExportTemplate.JoinTemplate = nil
e = tx.Updates(&sysExportTemplate).Error
if e != nil {
return e
}
if len(conditions) > 0 {
for i := range conditions {
conditions[i].ID = 0
}
e = tx.Create(&conditions).Error
}
if len(joins) > 0 {
for i := range joins {
joins[i].ID = 0
}
e = tx.Create(&joins).Error
}
return e
})
}
// GetSysExportTemplate 根据id获取导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) GetSysExportTemplate(id uint) (sysExportTemplate system.SysExportTemplate, err error) {
err = global.GVA_DB.Where("id = ?", id).Preload("JoinTemplate").Preload("Conditions").First(&sysExportTemplate).Error
return
}
// GetSysExportTemplateInfoList 分页获取导出模板记录
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) GetSysExportTemplateInfoList(info systemReq.SysExportTemplateSearch) (list []system.SysExportTemplate, total int64, err error) {
limit := info.PageSize
offset := info.PageSize * (info.Page - 1)
// 创建db
db := global.GVA_DB.Model(&system.SysExportTemplate{})
var sysExportTemplates []system.SysExportTemplate
// 如果有条件搜索 下方会自动创建搜索语句
if info.StartCreatedAt != nil && info.EndCreatedAt != nil {
db = db.Where("created_at BETWEEN ? AND ?", info.StartCreatedAt, info.EndCreatedAt)
}
if info.Name != "" {
db = db.Where("name LIKE ?", "%"+info.Name+"%")
}
if info.TableName != "" {
db = db.Where("table_name = ?", info.TableName)
}
if info.TemplateID != "" {
db = db.Where("template_id = ?", info.TemplateID)
}
err = db.Count(&total).Error
if err != nil {
return
}
if limit != 0 {
db = db.Limit(limit).Offset(offset)
}
err = db.Find(&sysExportTemplates).Error
return sysExportTemplates, total, err
}
// ExportExcel 导出Excel
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) ExportExcel(templateID string, values url.Values) (file *bytes.Buffer, name string, err error) {
var params = values.Get("params")
paramsValues, err := url.ParseQuery(params)
if err != nil {
return nil, "", fmt.Errorf("解析 params 参数失败: %v", err)
}
var template system.SysExportTemplate
err = global.GVA_DB.Preload("Conditions").Preload("JoinTemplate").First(&template, "template_id = ?", templateID).Error
if err != nil {
return nil, "", err
}
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
// Create a new sheet.
index, err := f.NewSheet("Sheet1")
if err != nil {
fmt.Println(err)
return
}
var templateInfoMap = make(map[string]string)
columns, err := utils.GetJSONKeys(template.TemplateInfo)
if err != nil {
return nil, "", err
}
err = json.Unmarshal([]byte(template.TemplateInfo), &templateInfoMap)
if err != nil {
return nil, "", err
}
var tableTitle []string
var selectKeyFmt []string
for _, key := range columns {
selectKeyFmt = append(selectKeyFmt, key)
tableTitle = append(tableTitle, templateInfoMap[key])
}
selects := strings.Join(selectKeyFmt, ", ")
var tableMap []map[string]interface{}
db := global.GVA_DB
if template.DBName != "" {
db = global.MustGetGlobalDBByDBName(template.DBName)
}
// 如果有自定义SQL则优先使用自定义SQL
if template.SQL != "" {
// 将 url.Values 转换为 map[string]interface{} 以支持 GORM 的命名参数
sqlParams := make(map[string]interface{})
for k, v := range paramsValues {
if len(v) > 0 {
sqlParams[k] = v[0]
}
}
// 执行原生 SQL支持 @key 命名参数
err = db.Raw(template.SQL, sqlParams).Scan(&tableMap).Error
if err != nil {
return nil, "", err
}
} else {
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
db = db.Joins(join.JOINS + " " + join.Table + " ON " + join.ON)
}
}
db = db.Select(selects).Table(template.TableName)
filterDeleted := false
filterParam := paramsValues.Get("filterDeleted")
if filterParam == "true" {
filterDeleted = true
}
if filterDeleted {
// 自动过滤主表的软删除
db = db.Where(fmt.Sprintf("%s.deleted_at IS NULL", template.TableName))
// 过滤关联表的软删除(如果有)
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
// 检查关联表是否有deleted_at字段
hasDeletedAt := sysExportTemplateService.hasDeletedAtColumn(join.Table)
if hasDeletedAt {
db = db.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
}
}
}
}
if len(template.Conditions) > 0 {
for _, condition := range template.Conditions {
sql := fmt.Sprintf("%s %s ?", condition.Column, condition.Operator)
value := paramsValues.Get(condition.From)
if condition.Operator == "IN" || condition.Operator == "NOT IN" {
sql = fmt.Sprintf("%s %s (?)", condition.Column, condition.Operator)
}
if condition.Operator == "BETWEEN" {
sql = fmt.Sprintf("%s BETWEEN ? AND ?", condition.Column)
startValue := paramsValues.Get("start" + condition.From)
endValue := paramsValues.Get("end" + condition.From)
if startValue != "" && endValue != "" {
db = db.Where(sql, startValue, endValue)
}
continue
}
if value != "" {
if condition.Operator == "LIKE" {
value = "%" + value + "%"
}
db = db.Where(sql, value)
}
}
}
// 通过参数传入limit
limit := paramsValues.Get("limit")
if limit != "" {
l, e := strconv.Atoi(limit)
if e == nil {
db = db.Limit(l)
}
}
// 模板的默认limit
if limit == "" && template.Limit != nil && *template.Limit != 0 {
db = db.Limit(*template.Limit)
}
// 通过参数传入offset
offset := paramsValues.Get("offset")
if offset != "" {
o, e := strconv.Atoi(offset)
if e == nil {
db = db.Offset(o)
}
}
// 获取当前表的所有字段
table := template.TableName
orderColumns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return nil, "", err
}
// 创建一个 map 来存储字段名
fields := make(map[string]bool)
for _, column := range orderColumns {
fields[column.Name()] = true
}
// 通过参数传入order
order := paramsValues.Get("order")
if order == "" && template.Order != "" {
// 如果没有order入参这里会使用模板的默认排序
order = template.Order
}
if order != "" {
checkOrderArr := strings.Split(order, " ")
orderStr := ""
// 检查请求的排序字段是否在字段列表中
if _, ok := fields[checkOrderArr[0]]; !ok {
return nil, "", fmt.Errorf("order by %s is not in the fields", order)
}
orderStr = checkOrderArr[0]
if len(checkOrderArr) > 1 {
if checkOrderArr[1] != "asc" && checkOrderArr[1] != "desc" {
return nil, "", fmt.Errorf("order by %s is not secure", order)
}
orderStr = orderStr + " " + checkOrderArr[1]
}
db = db.Order(orderStr)
}
err = db.Debug().Find(&tableMap).Error
if err != nil {
return nil, "", err
}
}
var rows [][]string
rows = append(rows, tableTitle)
for _, exTable := range tableMap {
var row []string
for _, column := range columns {
column = strings.ReplaceAll(column, "\"", "")
column = strings.ReplaceAll(column, "`", "")
if len(template.JoinTemplate) > 0 {
columnAs := strings.Split(column, " as ")
if len(columnAs) > 1 {
column = strings.TrimSpace(strings.Split(column, " as ")[1])
} else {
columnArr := strings.Split(column, ".")
if len(columnArr) > 1 {
column = strings.Split(column, ".")[1]
}
}
}
// 需要对时间类型特殊处理
if t, ok := exTable[column].(time.Time); ok {
row = append(row, t.Format("2006-01-02 15:04:05"))
} else {
row = append(row, fmt.Sprintf("%v", exTable[column]))
}
}
rows = append(rows, row)
}
for i, row := range rows {
for j, colCell := range row {
cell := fmt.Sprintf("%s%d", getColumnName(j+1), i+1)
var sErr error
if v, err := strconv.ParseFloat(colCell, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else if v, err := strconv.ParseInt(colCell, 10, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else {
sErr = f.SetCellValue("Sheet1", cell, colCell)
}
if sErr != nil {
return nil, "", sErr
}
}
}
f.SetActiveSheet(index)
file, err = f.WriteToBuffer()
if err != nil {
return nil, "", err
}
return file, template.Name, nil
}
// PreviewSQL 预览最终生成的 SQL不执行查询仅返回 SQL 字符串)
// Author [piexlmax](https://github.com/piexlmax) & [trae-ai]
func (sysExportTemplateService *SysExportTemplateService) PreviewSQL(templateID string, values url.Values) (sqlPreview string, err error) {
// 解析 params与导出逻辑保持一致
var params = values.Get("params")
paramsValues, _ := url.ParseQuery(params)
// 加载模板
var template system.SysExportTemplate
err = global.GVA_DB.Preload("Conditions").Preload("JoinTemplate").First(&template, "template_id = ?", templateID).Error
if err != nil {
return "", err
}
// 解析模板列
var templateInfoMap = make(map[string]string)
columns, err := utils.GetJSONKeys(template.TemplateInfo)
if err != nil {
return "", err
}
err = json.Unmarshal([]byte(template.TemplateInfo), &templateInfoMap)
if err != nil {
return "", err
}
var selectKeyFmt []string
for _, key := range columns {
selectKeyFmt = append(selectKeyFmt, key)
}
selects := strings.Join(selectKeyFmt, ", ")
// 生成 FROM 与 JOIN 片段
var sb strings.Builder
sb.WriteString("SELECT ")
sb.WriteString(selects)
sb.WriteString(" FROM ")
sb.WriteString(template.TableName)
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
sb.WriteString(" ")
sb.WriteString(join.JOINS)
sb.WriteString(" ")
sb.WriteString(join.Table)
sb.WriteString(" ON ")
sb.WriteString(join.ON)
}
}
// WHERE 条件
var wheres []string
// 软删除过滤
filterDeleted := false
if paramsValues != nil {
filterParam := paramsValues.Get("filterDeleted")
if filterParam == "true" {
filterDeleted = true
}
}
if filterDeleted {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", template.TableName))
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
if sysExportTemplateService.hasDeletedAtColumn(join.Table) {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
}
}
}
}
// 模板条件(保留与 ExportExcel 同步的解析规则)
if len(template.Conditions) > 0 {
for _, condition := range template.Conditions {
op := strings.ToUpper(strings.TrimSpace(condition.Operator))
col := strings.TrimSpace(condition.Column)
// 预览优先展示传入值,没有则展示占位符
val := ""
if paramsValues != nil {
val = paramsValues.Get(condition.From)
}
switch op {
case "BETWEEN":
startValue := ""
endValue := ""
if paramsValues != nil {
startValue = paramsValues.Get("start" + condition.From)
endValue = paramsValues.Get("end" + condition.From)
}
if startValue != "" && endValue != "" {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", col, startValue, endValue))
} else {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", col, condition.From, condition.From))
}
case "IN", "NOT IN":
if val != "" {
// 逗号分隔值做简单展示
parts := strings.Split(val, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", col, op, strings.Join(parts, "','")))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", col, op, condition.From))
}
case "LIKE":
if val != "" {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", col, val))
} else {
wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", col, condition.From))
}
default:
if val != "" {
wheres = append(wheres, fmt.Sprintf("%s %s '%s'", col, op, val))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s {%s}", col, op, condition.From))
}
}
}
}
if len(wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(wheres, " AND "))
}
// 排序
order := ""
if paramsValues != nil {
order = paramsValues.Get("order")
}
if order == "" && template.Order != "" {
order = template.Order
}
if order != "" {
sb.WriteString(" ORDER BY ")
sb.WriteString(order)
}
// limit/offset如果传入或默认值为0则不生成
limitStr := ""
offsetStr := ""
if paramsValues != nil {
limitStr = paramsValues.Get("limit")
offsetStr = paramsValues.Get("offset")
}
// 处理模板默认limit仅当非0时
if limitStr == "" && template.Limit != nil && *template.Limit != 0 {
limitStr = strconv.Itoa(*template.Limit)
}
// 解析为数值,用于判断是否生成
limitInt := 0
offsetInt := 0
if limitStr != "" {
if v, e := strconv.Atoi(limitStr); e == nil {
limitInt = v
}
}
if offsetStr != "" {
if v, e := strconv.Atoi(offsetStr); e == nil {
offsetInt = v
}
}
if limitInt > 0 {
sb.WriteString(" LIMIT ")
sb.WriteString(strconv.Itoa(limitInt))
if offsetInt > 0 {
sb.WriteString(" OFFSET ")
sb.WriteString(strconv.Itoa(offsetInt))
}
} else {
// 当limit未设置或为0时仅当offset>0才生成OFFSET
if offsetInt > 0 {
sb.WriteString(" OFFSET ")
sb.WriteString(strconv.Itoa(offsetInt))
}
}
return sb.String(), nil
}
// ExportTemplate 导出Excel模板
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) ExportTemplate(templateID string) (file *bytes.Buffer, name string, err error) {
var template system.SysExportTemplate
err = global.GVA_DB.First(&template, "template_id = ?", templateID).Error
if err != nil {
return nil, "", err
}
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
// Create a new sheet.
index, err := f.NewSheet("Sheet1")
if err != nil {
fmt.Println(err)
return
}
var templateInfoMap = make(map[string]string)
columns, err := utils.GetJSONKeys(template.TemplateInfo)
err = json.Unmarshal([]byte(template.TemplateInfo), &templateInfoMap)
if err != nil {
return nil, "", err
}
var tableTitle []string
for _, key := range columns {
tableTitle = append(tableTitle, templateInfoMap[key])
}
for i := range tableTitle {
fErr := f.SetCellValue("Sheet1", fmt.Sprintf("%s%d", getColumnName(i+1), 1), tableTitle[i])
if fErr != nil {
return nil, "", fErr
}
}
f.SetActiveSheet(index)
file, err = f.WriteToBuffer()
if err != nil {
return nil, "", err
}
return file, template.Name, nil
}
// 辅助函数检查表是否有deleted_at列
func (s *SysExportTemplateService) hasDeletedAtColumn(tableName string) bool {
var count int64
global.GVA_DB.Raw("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = 'deleted_at'", tableName).Count(&count)
return count > 0
}
// ImportExcel 导入Excel
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) ImportExcel(templateID string, file *multipart.FileHeader) (err error) {
var template system.SysExportTemplate
err = global.GVA_DB.First(&template, "template_id = ?", templateID).Error
if err != nil {
return err
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
f, err := excelize.OpenReader(src)
if err != nil {
return err
}
rows, err := f.GetRows("Sheet1")
if err != nil {
return err
}
if len(rows) < 2 {
return errors.New("Excel data is not enough.\nIt should contain title row and data")
}
var templateInfoMap = make(map[string]string)
err = json.Unmarshal([]byte(template.TemplateInfo), &templateInfoMap)
if err != nil {
return err
}
db := global.GVA_DB
if template.DBName != "" {
db = global.MustGetGlobalDBByDBName(template.DBName)
}
items, err := sysExportTemplateService.parseExcelToMap(rows, templateInfoMap)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
if template.ImportSQL != "" {
return sysExportTemplateService.importBySQL(tx, template.ImportSQL, items)
}
return sysExportTemplateService.importByGORM(tx, template.TableName, items)
})
}
func (sysExportTemplateService *SysExportTemplateService) parseExcelToMap(rows [][]string, templateInfoMap map[string]string) ([]map[string]interface{}, error) {
var titleKeyMap = make(map[string]string)
for key, title := range templateInfoMap {
titleKeyMap[title] = key
}
excelTitle := rows[0]
for i, str := range excelTitle {
excelTitle[i] = strings.TrimSpace(str)
}
values := rows[1:]
items := make([]map[string]interface{}, 0, len(values))
for _, row := range values {
var item = make(map[string]interface{})
for ii, value := range row {
if ii >= len(excelTitle) {
continue
}
if _, ok := titleKeyMap[excelTitle[ii]]; !ok {
continue // excel中多余的标题在模板信息中没有对应的字段因此key为空必须跳过
}
key := titleKeyMap[excelTitle[ii]]
item[key] = value
}
items = append(items, item)
}
return items, nil
}
func (sysExportTemplateService *SysExportTemplateService) importBySQL(tx *gorm.DB, sql string, items []map[string]interface{}) error {
for _, item := range items {
if err := tx.Exec(sql, item).Error; err != nil {
return err
}
}
return nil
}
func (sysExportTemplateService *SysExportTemplateService) importByGORM(tx *gorm.DB, tableName string, items []map[string]interface{}) error {
needCreated := tx.Migrator().HasColumn(tableName, "created_at")
needUpdated := tx.Migrator().HasColumn(tableName, "updated_at")
for _, item := range items {
if item["created_at"] == nil && needCreated {
item["created_at"] = time.Now()
}
if item["updated_at"] == nil && needUpdated {
item["updated_at"] = time.Now()
}
}
return tx.Table(tableName).CreateInBatches(&items, 1000).Error
}
func getColumnName(n int) string {
columnName := ""
for n > 0 {
n--
columnName = string(rune('A'+n%26)) + columnName
n /= 26
}
return columnName
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"git.echol.cn/loser/Go-Web-Template/server/config"
"github.com/gookit/color"
@@ -19,6 +20,10 @@ import (
type PgsqlInitHandler struct{}
func quotePgIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func NewPgsqlInitHandler() *PgsqlInitHandler {
return &PgsqlInitHandler{}
}
@@ -55,9 +60,13 @@ func (h PgsqlInitHandler) EnsureDB(ctx context.Context, conf *request.InitDB) (n
dsn := conf.PgsqlEmptyDsn()
var createSql string
if conf.Template != "" {
createSql = fmt.Sprintf("CREATE DATABASE %s WITH TEMPLATE %s;", c.Dbname, conf.Template)
createSql = fmt.Sprintf(
"CREATE DATABASE %s WITH TEMPLATE %s;",
quotePgIdentifier(c.Dbname),
quotePgIdentifier(conf.Template),
)
} else {
createSql = fmt.Sprintf("CREATE DATABASE %s;", c.Dbname)
createSql = fmt.Sprintf("CREATE DATABASE %s;", quotePgIdentifier(c.Dbname))
}
if err = createDatabase(dsn, "pgx", createSql); err != nil {
return nil, err

View File

@@ -1,230 +0,0 @@
package system
import (
"context"
"git.echol.cn/loser/Go-Web-Template/server/global"
"git.echol.cn/loser/Go-Web-Template/server/model/system"
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
"gorm.io/gorm"
)
type SysVersionService struct{}
// CreateSysVersion 创建版本管理记录
// Author [yourname](https://github.com/yourname)
func (sysVersionService *SysVersionService) CreateSysVersion(ctx context.Context, sysVersion *system.SysVersion) (err error) {
err = global.GVA_DB.Create(sysVersion).Error
return err
}
// DeleteSysVersion 删除版本管理记录
// Author [yourname](https://github.com/yourname)
func (sysVersionService *SysVersionService) DeleteSysVersion(ctx context.Context, ID string) (err error) {
err = global.GVA_DB.Delete(&system.SysVersion{}, "id = ?", ID).Error
return err
}
// DeleteSysVersionByIds 批量删除版本管理记录
// Author [yourname](https://github.com/yourname)
func (sysVersionService *SysVersionService) DeleteSysVersionByIds(ctx context.Context, IDs []string) (err error) {
err = global.GVA_DB.Where("id in ?", IDs).Delete(&system.SysVersion{}).Error
return err
}
// GetSysVersion 根据ID获取版本管理记录
// Author [yourname](https://github.com/yourname)
func (sysVersionService *SysVersionService) GetSysVersion(ctx context.Context, ID string) (sysVersion system.SysVersion, err error) {
err = global.GVA_DB.Where("id = ?", ID).First(&sysVersion).Error
return
}
// GetSysVersionInfoList 分页获取版本管理记录
// Author [yourname](https://github.com/yourname)
func (sysVersionService *SysVersionService) GetSysVersionInfoList(ctx context.Context, info systemReq.SysVersionSearch) (list []system.SysVersion, total int64, err error) {
limit := info.PageSize
offset := info.PageSize * (info.Page - 1)
// 创建db
db := global.GVA_DB.Model(&system.SysVersion{})
var sysVersions []system.SysVersion
// 如果有条件搜索 下方会自动创建搜索语句
if len(info.CreatedAtRange) == 2 {
db = db.Where("created_at BETWEEN ? AND ?", info.CreatedAtRange[0], info.CreatedAtRange[1])
}
if info.VersionName != nil && *info.VersionName != "" {
db = db.Where("version_name LIKE ?", "%"+*info.VersionName+"%")
}
if info.VersionCode != nil && *info.VersionCode != "" {
db = db.Where("version_code = ?", *info.VersionCode)
}
err = db.Count(&total).Error
if err != nil {
return
}
if limit != 0 {
db = db.Limit(limit).Offset(offset)
}
err = db.Find(&sysVersions).Error
return sysVersions, total, err
}
func (sysVersionService *SysVersionService) GetSysVersionPublic(ctx context.Context) {
// 此方法为获取数据源定义的数据
// 请自行实现
}
// GetMenusByIds 根据ID列表获取菜单数据
func (sysVersionService *SysVersionService) GetMenusByIds(ctx context.Context, ids []uint) (menus []system.SysBaseMenu, err error) {
err = global.GVA_DB.Where("id in ?", ids).Preload("Parameters").Preload("MenuBtn").Find(&menus).Error
return
}
// GetApisByIds 根据ID列表获取API数据
func (sysVersionService *SysVersionService) GetApisByIds(ctx context.Context, ids []uint) (apis []system.SysApi, err error) {
err = global.GVA_DB.Where("id in ?", ids).Find(&apis).Error
return
}
// GetDictionariesByIds 根据ID列表获取字典数据
func (sysVersionService *SysVersionService) GetDictionariesByIds(ctx context.Context, ids []uint) (dictionaries []system.SysDictionary, err error) {
err = global.GVA_DB.Where("id in ?", ids).Preload("SysDictionaryDetails").Find(&dictionaries).Error
return
}
// ImportMenus 导入菜单数据
func (sysVersionService *SysVersionService) ImportMenus(ctx context.Context, menus []system.SysBaseMenu) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 递归创建菜单
return sysVersionService.createMenusRecursively(tx, menus, 0)
})
}
// createMenusRecursively 递归创建菜单
func (sysVersionService *SysVersionService) createMenusRecursively(tx *gorm.DB, menus []system.SysBaseMenu, parentId uint) error {
for _, menu := range menus {
// 检查菜单是否已存在
var existingMenu system.SysBaseMenu
if err := tx.Where("name = ? AND path = ?", menu.Name, menu.Path).First(&existingMenu).Error; err == nil {
// 菜单已存在使用现有菜单ID继续处理子菜单
if len(menu.Children) > 0 {
if err := sysVersionService.createMenusRecursively(tx, menu.Children, existingMenu.ID); err != nil {
return err
}
}
continue
}
// 保存参数和按钮数据,稍后处理
parameters := menu.Parameters
menuBtns := menu.MenuBtn
children := menu.Children
// 创建新菜单(不包含关联数据)
newMenu := system.SysBaseMenu{
ParentId: parentId,
Path: menu.Path,
Name: menu.Name,
Hidden: menu.Hidden,
Component: menu.Component,
Sort: menu.Sort,
Meta: menu.Meta,
}
if err := tx.Create(&newMenu).Error; err != nil {
return err
}
// 创建参数
if len(parameters) > 0 {
for _, param := range parameters {
newParam := system.SysBaseMenuParameter{
SysBaseMenuID: newMenu.ID,
Type: param.Type,
Key: param.Key,
Value: param.Value,
}
if err := tx.Create(&newParam).Error; err != nil {
return err
}
}
}
// 创建菜单按钮
if len(menuBtns) > 0 {
for _, btn := range menuBtns {
newBtn := system.SysBaseMenuBtn{
SysBaseMenuID: newMenu.ID,
Name: btn.Name,
Desc: btn.Desc,
}
if err := tx.Create(&newBtn).Error; err != nil {
return err
}
}
}
// 递归处理子菜单
if len(children) > 0 {
if err := sysVersionService.createMenusRecursively(tx, children, newMenu.ID); err != nil {
return err
}
}
}
return nil
}
// ImportApis 导入API数据
func (sysVersionService *SysVersionService) ImportApis(apis []system.SysApi) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
for _, api := range apis {
// 检查API是否已存在
var existingApi system.SysApi
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).First(&existingApi).Error; err == nil {
// API已存在跳过
continue
}
// 创建新API
newApi := system.SysApi{
Path: api.Path,
Description: api.Description,
ApiGroup: api.ApiGroup,
Method: api.Method,
}
if err := tx.Create(&newApi).Error; err != nil {
return err
}
}
return nil
})
}
// ImportDictionaries 导入字典数据
func (sysVersionService *SysVersionService) ImportDictionaries(dictionaries []system.SysDictionary) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
for _, dict := range dictionaries {
// 检查字典是否已存在
var existingDict system.SysDictionary
if err := tx.Where("type = ?", dict.Type).First(&existingDict).Error; err == nil {
// 字典已存在,跳过
continue
}
// 创建新字典
newDict := system.SysDictionary{
Name: dict.Name,
Type: dict.Type,
Status: dict.Status,
Desc: dict.Desc,
SysDictionaryDetails: dict.SysDictionaryDetails,
}
if err := tx.Create(&newDict).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -152,24 +152,13 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "断点续传(插件版)", Method: "GET", Path: "/simpleUploader/checkFileMd5", Description: "文件完整度验证"},
{ApiGroup: "断点续传(插件版)", Method: "GET", Path: "/simpleUploader/mergeFileMd5", Description: "上传完成合并文件"},
{ApiGroup: "email", Method: "POST", Path: "/email/emailTest", Description: "发送测试邮件"},
{ApiGroup: "email", Method: "POST", Path: "/email/sendEmail", Description: "发送邮件"},
//{ApiGroup: "email", Method: "POST", Path: "/email/emailTest", Description: "发送测试邮件"},
//{ApiGroup: "email", Method: "POST", Path: "/email/sendEmail", Description: "发送邮件"},
{ApiGroup: "按钮权限", Method: "POST", Path: "/authorityBtn/setAuthorityBtn", Description: "设置按钮权限"},
{ApiGroup: "按钮权限", Method: "POST", Path: "/authorityBtn/getAuthorityBtn", Description: "获取已有按钮权限"},
{ApiGroup: "按钮权限", Method: "POST", Path: "/authorityBtn/canRemoveAuthorityBtn", Description: "删除按钮"},
{ApiGroup: "导出模板", Method: "POST", Path: "/sysExportTemplate/createSysExportTemplate", Description: "新增导出模板"},
{ApiGroup: "导出模板", Method: "DELETE", Path: "/sysExportTemplate/deleteSysExportTemplate", Description: "删除导出模板"},
{ApiGroup: "导出模板", Method: "DELETE", Path: "/sysExportTemplate/deleteSysExportTemplateByIds", Description: "批量删除导出模板"},
{ApiGroup: "导出模板", Method: "PUT", Path: "/sysExportTemplate/updateSysExportTemplate", Description: "更新导出模板"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/findSysExportTemplate", Description: "根据ID获取导出模板"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/getSysExportTemplateList", Description: "获取导出模板列表"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/exportExcel", Description: "导出Excel"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/exportTemplate", Description: "下载模板"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/previewSQL", Description: "预览SQL"},
{ApiGroup: "导出模板", Method: "POST", Path: "/sysExportTemplate/importExcel", Description: "导入Excel"},
{ApiGroup: "错误日志", Method: "POST", Path: "/sysError/createSysError", Description: "新建错误日志"},
{ApiGroup: "错误日志", Method: "DELETE", Path: "/sysError/deleteSysError", Description: "删除错误日志"},
{ApiGroup: "错误日志", Method: "DELETE", Path: "/sysError/deleteSysErrorByIds", Description: "批量删除错误日志"},
@@ -188,14 +177,6 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "媒体库分类", Method: "GET", Path: "/attachmentCategory/getCategoryList", Description: "分类列表"},
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/addCategory", Description: "添加/编辑分类"},
{ApiGroup: "媒体库分类", Method: "POST", Path: "/attachmentCategory/deleteCategory", Description: "删除分类"},
{ApiGroup: "版本控制", Method: "GET", Path: "/sysVersion/findSysVersion", Description: "获取单一版本"},
{ApiGroup: "版本控制", Method: "GET", Path: "/sysVersion/getSysVersionList", Description: "获取版本列表"},
{ApiGroup: "版本控制", Method: "GET", Path: "/sysVersion/downloadVersionJson", Description: "下载版本json"},
{ApiGroup: "版本控制", Method: "POST", Path: "/sysVersion/exportVersion", Description: "创建版本"},
{ApiGroup: "版本控制", Method: "POST", Path: "/sysVersion/importVersion", Description: "同步版本"},
{ApiGroup: "版本控制", Method: "DELETE", Path: "/sysVersion/deleteSysVersion", Description: "删除版本"},
{ApiGroup: "版本控制", Method: "DELETE", Path: "/sysVersion/deleteSysVersionByIds", Description: "批量删除版本"},
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")

View File

@@ -161,17 +161,6 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/authorityBtn/getAuthorityBtn", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/authorityBtn/canRemoveAuthorityBtn", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/createSysExportTemplate", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/deleteSysExportTemplate", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/deleteSysExportTemplateByIds", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/updateSysExportTemplate", V2: "PUT"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/findSysExportTemplate", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/getSysExportTemplateList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/exportExcel", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/exportTemplate", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/previewSQL", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/importExcel", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysError/createSysError", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysError/deleteSysError", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/sysError/deleteSysErrorByIds", V2: "DELETE"},
@@ -191,14 +180,6 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/attachmentCategory/addCategory", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/attachmentCategory/deleteCategory", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysVersion/findSysVersion", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysVersion/getSysVersionList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysVersion/downloadVersionJson", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysVersion/exportVersion", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysVersion/importVersion", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/sysVersion/deleteSysVersion", V2: "DELETE"},
{Ptype: "p", V0: "888", V1: "/sysVersion/deleteSysVersionByIds", V2: "DELETE"},
{Ptype: "p", V0: "8881", V1: "/user/admin_register", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/api/createApi", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/api/getApiList", V2: "POST"},

View File

@@ -1,75 +0,0 @@
package system
import (
"context"
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
"git.echol.cn/loser/Go-Web-Template/server/service/system"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type initExcelTemplate struct{}
const initOrderExcelTemplate = initOrderDictDetail + 1
// auto run
func init() {
system.RegisterInit(initOrderExcelTemplate, &initExcelTemplate{})
}
func (i *initExcelTemplate) InitializerName() string {
return "sys_export_templates"
}
func (i *initExcelTemplate) MigrateTable(ctx context.Context) (context.Context, error) {
db, ok := ctx.Value("db").(*gorm.DB)
if !ok {
return ctx, system.ErrMissingDBContext
}
return ctx, db.AutoMigrate(&sysModel.SysExportTemplate{})
}
func (i *initExcelTemplate) TableCreated(ctx context.Context) bool {
db, ok := ctx.Value("db").(*gorm.DB)
if !ok {
return false
}
return db.Migrator().HasTable(&sysModel.SysExportTemplate{})
}
func (i *initExcelTemplate) InitializeData(ctx context.Context) (context.Context, error) {
db, ok := ctx.Value("db").(*gorm.DB)
if !ok {
return ctx, system.ErrMissingDBContext
}
entities := []sysModel.SysExportTemplate{
{
Name: "api",
TableName: "sys_apis",
TemplateID: "api",
TemplateInfo: `{
"path":"路径",
"method":"方法(大写)",
"description":"方法介绍",
"api_group":"方法分组"
}`,
},
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, "sys_export_templates"+"表数据初始化失败!")
}
next := context.WithValue(ctx, i.InitializerName(), entities)
return next, nil
}
func (i *initExcelTemplate) DataInserted(ctx context.Context) bool {
db, ok := ctx.Value("db").(*gorm.DB)
if !ok {
return false
}
if errors.Is(db.First(&sysModel.SysExportTemplate{}).Error, gorm.ErrRecordNotFound) {
return false
}
return true
}

View File

@@ -53,14 +53,14 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er
// 定义所有菜单
allMenus := []SysBaseMenu{
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Sort: 1, Meta: Meta{Title: "仪表盘", Icon: "odometer"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "about", Name: "about", Component: "view/about/index.vue", Sort: 9, Meta: Meta{Title: "关于我们", Icon: "info-filled"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "admin", Name: "superAdmin", Component: "view/superAdmin/index.vue", Sort: 3, Meta: Meta{Title: "超级管理员", Icon: "user"}},
{MenuLevel: 0, Hidden: true, ParentId: 0, Path: "person", Name: "person", Component: "view/person/person.vue", Sort: 4, Meta: Meta{Title: "个人信息", Icon: "message"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "common", Name: "common", Component: "view/routerHolder.vue", Sort: 6, Meta: Meta{Title: "公共能力", Icon: "folder-opened"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "systemTools", Name: "systemTools", Component: "view/systemTools/index.vue", Sort: 5, Meta: Meta{Title: "编程辅助", Icon: "tools"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "https://www.gin-vue-admin.com", Name: "https://www.gin-vue-admin.com", Component: "/", Sort: 0, Meta: Meta{Title: "官方网站", Icon: "customer-gva"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "state", Name: "state", Component: "view/system/state.vue", Sort: 8, Meta: Meta{Title: "服务器状态", Icon: "cloudy"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "dashboard", Name: "dashboard", Component: "features/dashboard/DashboardPage", Sort: 1, Meta: Meta{Title: "仪表盘", Icon: "DashboardOutlined"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "about", Name: "about", Component: "features/discovery/ModuleLandingPage:about", Sort: 9, Meta: Meta{Title: "关于系统", Icon: "InfoCircleOutlined"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "admin", Name: "superAdmin", Component: "features/discovery/ModuleLandingPage:superAdmin", Sort: 3, Meta: Meta{Title: "超级管理员", Icon: "LockOutlined"}},
{MenuLevel: 0, Hidden: true, ParentId: 0, Path: "person", Name: "person", Component: "features/person/ProfilePage", Sort: 4, Meta: Meta{Title: "个人信息", Icon: "UserOutlined"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "common", Name: "common", Component: "features/discovery/ModuleLandingPage:common", Sort: 6, Meta: Meta{Title: "公共能力", Icon: "AppstoreOutlined"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "systemTools", Name: "systemTools", Component: "features/discovery/ModuleLandingPage:systemTools", Sort: 5, Meta: Meta{Title: "编程辅助", Icon: "ToolOutlined"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "https://echol.cn", Name: "https://echol.cn", Component: "/", Sort: 0, Meta: Meta{Title: "官方网站", Icon: "customer-gva"}},
{MenuLevel: 0, Hidden: false, ParentId: 0, Path: "state", Name: "state", Component: "features/server/ServerStatePage", Sort: 8, Meta: Meta{Title: "服务器状态", Icon: "CloudServerOutlined"}},
}
// 先创建父级菜单ParentId = 0 的菜单)
@@ -77,28 +77,25 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er
// 定义子菜单并设置正确的ParentId
childMenus := []SysBaseMenu{
// superAdmin子菜单
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "authority", Name: "authority", Component: "view/superAdmin/authority/authority.vue", Sort: 1, Meta: Meta{Title: "角色管理", Icon: "avatar"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "menu", Name: "menu", Component: "view/superAdmin/menu/menu.vue", Sort: 2, Meta: Meta{Title: "菜单管理", Icon: "tickets", KeepAlive: true}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "api", Name: "api", Component: "view/superAdmin/api/api.vue", Sort: 3, Meta: Meta{Title: "api管理", Icon: "platform", KeepAlive: true}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "user", Name: "user", Component: "view/superAdmin/user/user.vue", Sort: 4, Meta: Meta{Title: "用户管理", Icon: "coordinate"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "dictionary", Name: "dictionary", Component: "view/superAdmin/dictionary/sysDictionary.vue", Sort: 5, Meta: Meta{Title: "字典管理", Icon: "notebook"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "operation", Name: "operation", Component: "view/superAdmin/operation/sysOperationRecord.vue", Sort: 6, Meta: Meta{Title: "操作历史", Icon: "pie-chart"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysParams", Name: "sysParams", Component: "view/superAdmin/params/sysParams.vue", Sort: 7, Meta: Meta{Title: "参数管理", Icon: "compass"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "system", Name: "system", Component: "view/systemTools/system/system.vue", Sort: 8, Meta: Meta{Title: "系统配置", Icon: "operation"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "apiToken", Name: "apiToken", Component: "view/systemTools/apiToken/index.vue", Sort: 9, Meta: Meta{Title: "API Token", Icon: "key"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "loginLog", Name: "loginLog", Component: "view/systemTools/loginLog/index.vue", Sort: 10, Meta: Meta{Title: "登录日志", Icon: "monitor"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysVersion", Name: "sysVersion", Component: "view/systemTools/version/version.vue", Sort: 11, Meta: Meta{Title: "版本管理", Icon: "server"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysError", Name: "sysError", Component: "view/systemTools/sysError/sysError.vue", Sort: 12, Meta: Meta{Title: "错误日志", Icon: "warn"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "authority", Name: "authority", Component: "features/roles/RoleManagementPage", Sort: 1, Meta: Meta{Title: "角色管理", Icon: "TeamOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "menu", Name: "menu", Component: "features/menus/MenuManagementPage", Sort: 2, Meta: Meta{Title: "菜单管理", Icon: "AppstoreOutlined", KeepAlive: true}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "api", Name: "api", Component: "features/apis/ApiManagementPage", Sort: 3, Meta: Meta{Title: "API管理", Icon: "ApiOutlined", KeepAlive: true}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "user", Name: "user", Component: "features/users/UserManagementPage", Sort: 4, Meta: Meta{Title: "用户管理", Icon: "UserOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "dictionary", Name: "dictionary", Component: "features/dictionaries/DictionaryManagementPage", Sort: 5, Meta: Meta{Title: "字典管理", Icon: "BookOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "operation", Name: "operation", Component: "features/logs/OperationLogPage", Sort: 6, Meta: Meta{Title: "操作历史", Icon: "ProfileOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysParams", Name: "sysParams", Component: "features/params/ParamsManagementPage", Sort: 7, Meta: Meta{Title: "参数管理", Icon: "SettingOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "system", Name: "system", Component: "features/system/SystemConfigPage", Sort: 8, Meta: Meta{Title: "系统配置", Icon: "SettingOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "apiToken", Name: "apiToken", Component: "features/tokens/ApiTokenPage", Sort: 9, Meta: Meta{Title: "API Token", Icon: "LockOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "loginLog", Name: "loginLog", Component: "features/logs/LoginLogPage", Sort: 10, Meta: Meta{Title: "登录日志", Icon: "FileTextOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysError", Name: "sysError", Component: "features/errors/ErrorLogPage", Sort: 11, Meta: Meta{Title: "错误日志", Icon: "BugOutlined"}},
// common子菜单
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["common"], Path: "upload", Name: "upload", Component: "view/example/upload/upload.vue", Sort: 1, Meta: Meta{Title: "媒体库(上传下载)", Icon: "upload"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["common"], Path: "breakpoint", Name: "breakpoint", Component: "view/example/breakpoint/breakpoint.vue", Sort: 2, Meta: Meta{Title: "断点续传", Icon: "upload-filled"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["common"], Path: "upload", Name: "upload", Component: "features/media/MediaLibraryPage", Sort: 1, Meta: Meta{Title: "媒体库(上传下载)", Icon: "UploadOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["common"], Path: "breakpoint", Name: "breakpoint", Component: "features/breakpoint/BreakpointPage", Sort: 2, Meta: Meta{Title: "断点续传", Icon: "DeploymentUnitOutlined"}},
// systemTools子菜单
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "formCreate", Name: "formCreate", Component: "view/systemTools/formCreate/index.vue", Sort: 1, Meta: Meta{Title: "表单生成器", Icon: "magic-stick", KeepAlive: true}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "exportTemplate", Name: "exportTemplate", Component: "view/systemTools/exportTemplate/exportTemplate.vue", Sort: 2, Meta: Meta{Title: "导出模板", Icon: "reading"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTest", Name: "mcpTest", Component: "view/systemTools/mcpTest/index.vue", Sort: 3, Meta: Meta{Title: "MCP Tools管理", Icon: "partly-cloudy"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTool", Name: "mcpTool", Component: "view/systemTools/mcpTool/index.vue", Sort: 4, Meta: Meta{Title: "MCP Tools模板", Icon: "magnet"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTest", Name: "mcpTest", Component: "features/mcp/McpTestPage", Sort: 1, Meta: Meta{Title: "MCP Tools管理", Icon: "ToolOutlined"}},
{MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTool", Name: "mcpTool", Component: "features/mcp/McpToolPage", Sort: 2, Meta: Meta{Title: "MCP Tools模板", Icon: "ToolOutlined"}},
}
// 创建子菜单

View File

@@ -23,7 +23,6 @@
- `system` 系统配置
- `apiToken` API Token
- `loginLog` 登录日志
- `sysVersion` 版本管理
- `sysError` 错误日志
### 公共模块
@@ -33,8 +32,6 @@
### 编程辅助
- `formCreate` 表单生成器
- `exportTemplate` 导出模板
- `mcpTest` MCP Tools 管理
- `mcpTool` MCP Tools 模板
@@ -154,15 +151,12 @@
### 已做成模块入口页
- 关于系统
- 版本管理
- 媒体库
- 断点续传
- 表单生成器
- 导出模板
- MCP Tools 管理
- MCP Tools 模板
## 说明
- 新后台没有修改后端协议,仍然复用原有 token、菜单、权限和接口格式。
- 当前仍有部分研发辅助模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。
- 当前仍有部分模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。

View File

@@ -4,6 +4,7 @@ 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 { InitPage } from '@/features/auth/InitPage'
import { LoginPage } from '@/features/auth/LoginPage'
import { AdminShell } from '@/features/layout/AdminShell'
import { appRoutes } from '@/router/fsRoutes'
@@ -133,6 +134,7 @@ export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/init" element={<InitPage />} />
<Route path="/login" element={<LoginPage />} />
<Route element={<BootstrapGate />}>
<Route path="/*" element={<LayoutFrame />} />

View File

@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Button,
Card,
Col,
Drawer,
Form,
Input,
Modal,
Row,
Select,
Space,
Table,
@@ -16,84 +15,121 @@ import {
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'
import { apiRegistryApi } from '@/lib/api'
import type { ApiRecord, SyncApiPayload } from '@/types/system'
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE']
const methodOptions = [
{ value: 'POST', label: '创建', color: 'green' },
{ value: 'GET', label: '查看', color: 'blue' },
{ value: 'PUT', label: '更新', color: 'orange' },
{ value: 'DELETE', label: '删除', color: 'red' },
]
type SearchValues = {
path?: string
description?: string
apiGroup?: string
method?: string
}
type ApiFormValues = {
path: string
description: string
apiGroup: string
method: string
}
function methodMeta(method: string) {
return methodOptions.find((item) => item.value === method) || { label: method, color: 'default' }
}
function normalizeSyncPayload(payload?: SyncApiPayload): SyncApiPayload {
return {
newApis: payload?.newApis || [],
deleteApis: payload?.deleteApis || [],
ignoreApis: payload?.ignoreApis || [],
}
}
export function ApiManagementPage() {
const [searchForm] = Form.useForm()
const [editForm] = Form.useForm()
const [searchForm] = Form.useForm<SearchValues>()
const [editForm] = Form.useForm<ApiFormValues>()
const [apis, setApis] = useState<ApiRecord[]>([])
const [roles, setRoles] = useState<Authority[]>([])
const [selectedRows, setSelectedRows] = useState<ApiRecord[]>([])
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 [orderKey, setOrderKey] = useState<string>()
const [desc, setDesc] = useState<boolean>(true)
const [drawerOpen, setDrawerOpen] = 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 [syncDrawerOpen, setSyncDrawerOpen] = useState(false)
const [syncPayload, setSyncPayload] = useState<SyncApiPayload>(normalizeSyncPayload())
const [syncing, setSyncing] = useState(false)
const [groupOptions, setGroupOptions] = useState<Array<{ label: string; value: string }>>([])
const [groupMap, setGroupMap] = useState<Record<string, string>>({})
const [editGroupSearch, setEditGroupSearch] = useState('')
const [syncGroupSearch, setSyncGroupSearch] = useState('')
const roleOptions = useMemo(
() =>
flattenAuthorities(roles).map((item) => ({
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
})),
[roles],
)
const ensureGroupOption = useCallback((rawValue: string) => {
const value = rawValue.trim()
if (!value) {
return ''
}
setGroupOptions((current) => {
if (current.some((item) => item.value === value)) {
return current
}
return [...current, { label: value, value }]
})
return value
}, [])
const apiGroupOptions = useMemo(
() =>
Array.from(new Set(apis.map((item) => item.apiGroup)))
.filter(Boolean)
.map((group) => ({
label: group,
value: group,
})),
[apis],
)
const loadGroupOptions = useCallback(async () => {
const response = await apiRegistryApi.getApiGroups()
setGroupOptions(response.data.groups.map((item) => ({ label: item, value: item })))
setGroupMap(response.data.apiGroupMap)
}, [])
const reloadApis = useCallback(async () => {
setLoading(true)
try {
const [apiRes, roleRes] = await Promise.all([
apiRegistryApi.getApiList({
const apiRes = await apiRegistryApi.getApiList({
page,
pageSize,
orderKey,
desc,
...searchForm.getFieldsValue(),
}),
authorityApi.getAuthorityList(),
])
})
setApis(apiRes.data.list)
setTotal(apiRes.data.total)
setRoles(roleRes.data)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
}, [desc, orderKey, page, pageSize, searchForm])
useEffect(() => {
reloadApis()
void reloadApis()
}, [reloadApis])
useEffect(() => {
void loadGroupOptions()
}, [loadGroupOptions])
const openCreate = () => {
setEditingApi(null)
editForm.resetFields()
editForm.setFieldsValue({ method: 'POST' })
setModalOpen(true)
editForm.setFieldsValue({ method: 'POST', path: '', apiGroup: '', description: '' })
setDrawerOpen(true)
}
const openEdit = async (record: ApiRecord) => {
const response = await apiRegistryApi.getApiById(record.ID)
setEditingApi(response.data.api)
editForm.setFieldsValue(response.data.api)
setModalOpen(true)
setDrawerOpen(true)
}
const saveApi = async () => {
@@ -101,17 +137,15 @@ export function ApiManagementPage() {
setSaving(true)
try {
if (editingApi) {
await apiRegistryApi.updateApi({
ID: editingApi.ID,
...values,
})
await apiRegistryApi.updateApi({ ID: editingApi.ID, ...values })
message.success('接口已更新')
} else {
await apiRegistryApi.createApi(values)
message.success('接口已创建')
message.success('接口已创建,请到角色管理页分配权限')
}
setModalOpen(false)
reloadApis()
setDrawerOpen(false)
await reloadApis()
await loadGroupOptions()
} finally {
setSaving(false)
}
@@ -120,64 +154,143 @@ export function ApiManagementPage() {
const deleteApi = (record: ApiRecord) => {
Modal.confirm({
title: `删除接口 ${record.path}`,
content: '此操作会删除当前 API 在所有角色下的权限关系。',
okButtonProps: { danger: true },
onOk: async () => {
await apiRegistryApi.deleteApi({ ID: record.ID })
message.success('接口已删除')
reloadApis()
if (apis.length === 1 && page > 1) {
setPage((current) => current - 1)
} else {
await reloadApis()
}
await loadGroupOptions()
},
})
}
const openRoleDrawer = async (record: ApiRecord) => {
const response = await apiRegistryApi.getApiRoles(record.path, record.method)
setActiveApi(record)
setSelectedRoles(response.data)
setDrawerOpen(true)
const batchDeleteApis = () => {
Modal.confirm({
title: '批量删除接口',
content: `当前将删除 ${selectedRows.length} 条接口记录,是否继续?`,
okButtonProps: { danger: true },
onOk: async () => {
await apiRegistryApi.deleteApisByIds(selectedRows.map((item) => item.ID))
message.success('批量删除成功')
setSelectedRows([])
if (apis.length === selectedRows.length && page > 1) {
setPage((current) => current - 1)
} else {
await reloadApis()
}
await loadGroupOptions()
},
})
}
const saveRoles = async () => {
if (!activeApi) {
const refreshCasbin = () => {
Modal.confirm({
title: '刷新 Casbin 缓存',
content: '确定立即刷新权限缓存吗?',
onOk: async () => {
await apiRegistryApi.freshCasbin()
message.success('Casbin 缓存已刷新')
},
})
}
const openSyncDrawer = async () => {
const response = await apiRegistryApi.syncApi()
const nextPayload = normalizeSyncPayload(response.data)
nextPayload.newApis = nextPayload.newApis.map((item) => ({
...item,
apiGroup: item.apiGroup || groupMap[item.path.split('/')[1]] || '',
}))
setSyncPayload(nextPayload)
setSyncDrawerOpen(true)
}
const ignoreSyncApi = async (record: ApiRecord, flag: boolean) => {
await apiRegistryApi.ignoreApi({ path: record.path, method: record.method, flag })
message.success(flag ? '已加入忽略列表' : '已取消忽略')
setSyncPayload((current) => {
if (flag) {
return {
...current,
newApis: current.newApis.filter((item) => !(item.path === record.path && item.method === record.method)),
ignoreApis: [...current.ignoreApis, record],
}
}
return {
...current,
ignoreApis: current.ignoreApis.filter((item) => !(item.path === record.path && item.method === record.method)),
newApis: [...current.newApis, record],
}
})
}
const createSingleSyncApi = async (record: ApiRecord) => {
if (!record.apiGroup) {
message.error('请先填写 API 分组')
return
}
setSavingRoles(true)
if (!record.description) {
message.error('请先填写 API 描述')
return
}
await apiRegistryApi.createApi(record)
message.success('接口已新增')
setSyncPayload((current) => ({
...current,
newApis: current.newApis.filter((item) => !(item.path === record.path && item.method === record.method)),
}))
await reloadApis()
await loadGroupOptions()
}
const submitSyncPayload = async () => {
if (syncPayload.newApis.some((item) => !item.apiGroup || !item.description)) {
message.error('存在未填写分组或描述的 API无法确认同步')
return
}
setSyncing(true)
try {
await apiRegistryApi.setApiRoles({
path: activeApi.path,
method: activeApi.method,
authorityIds: selectedRoles,
})
message.success('接口角色关系已更新')
setDrawerOpen(false)
await apiRegistryApi.enterSyncApi(syncPayload)
message.success('API 同步完成')
setSyncDrawerOpen(false)
await reloadApis()
await loadGroupOptions()
} finally {
setSavingRoles(false)
setSyncing(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: 'ID', dataIndex: 'ID', width: 80, sorter: true },
{ title: 'API 路径', dataIndex: 'path', width: 260, sorter: true },
{ title: 'API 分组', dataIndex: 'apiGroup', width: 180, sorter: true },
{ title: 'API 简介', dataIndex: 'description', width: 220, sorter: true },
{
title: '方法',
title: '请求',
dataIndex: 'method',
width: 100,
render: (value: string) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : value === 'PUT' ? 'orange' : 'red'}>{value}</Tag>,
width: 120,
sorter: true,
render: (value: string) => {
const meta = methodMeta(value)
return <Tag color={meta.color}>{`${value} / ${meta.label}`}</Tag>
},
},
{
title: '操作',
key: 'actions',
width: 220,
width: 160,
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>
@@ -189,52 +302,60 @@ export function ApiManagementPage() {
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
API
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
API API
</Typography.Paragraph>
</div>
<Space>
<Button
onClick={async () => {
await apiRegistryApi.freshCasbin()
message.success('Casbin 缓存已刷新')
}}
>
Casbin
</Button>
<Space wrap>
<Button type="primary" onClick={openCreate}>
</Button>
<Button disabled={!selectedRows.length} onClick={batchDeleteApis}>
</Button>
<Button onClick={refreshCasbin}></Button>
<Button onClick={() => void openSyncDrawer()}> API</Button>
</Space>
</div>
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadApis() }}>
<Row gutter={[16, 0]}>
<Col xs={24} md={8}>
<Form
form={searchForm}
layout="inline"
onFinish={() => {
setPage(1)
void reloadApis()
}}
>
<Form.Item name="path" label="路径">
<Input />
<Input placeholder="路径" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="description" label="描述">
<Input />
<Input placeholder="描述" />
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item name="apiGroup" label="分组">
<Select allowClear options={apiGroupOptions} />
<Form.Item name="apiGroup" label="API 分组">
<Select
allowClear
showSearch
style={{ width: 180 }}
options={groupOptions}
placeholder="请选择"
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
/>
</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 name="method" label="请求">
<Select
allowClear
style={{ width: 160 }}
options={methodOptions.map((item) => ({ label: `${item.label}(${item.value})`, value: item.value }))}
placeholder="请选择"
/>
</Form.Item>
</Col>
</Row>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
@@ -243,21 +364,27 @@ export function ApiManagementPage() {
onClick={() => {
searchForm.resetFields()
setPage(1)
reloadApis()
void reloadApis()
}}
>
</Button>
</Space>
</Form.Item>
</Form>
</Space>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={apis}
scroll={{ x: 1200 }}
scroll={{ x: 1400 }}
rowSelection={{
onChange: (_, rows) => setSelectedRows(rows),
}}
pagination={{
current: page,
pageSize,
@@ -268,50 +395,231 @@ export function ApiManagementPage() {
setPageSize(nextPageSize)
},
}}
onChange={(_, __, sorter) => {
if (Array.isArray(sorter)) {
return
}
if (!sorter.order || !sorter.field) {
setOrderKey(undefined)
setDesc(true)
return
}
setOrderKey(sorter.field === 'ID' ? 'id' : String(sorter.field))
setDesc(sorter.order === 'descend')
}}
/>
</Card>
<Modal
open={modalOpen}
title={editingApi ? '编辑接口' : '新建接口'}
onCancel={() => setModalOpen(false)}
onOk={saveApi}
confirmLoading={saving}
<Drawer
open={drawerOpen}
title={editingApi ? `编辑 API · ${editingApi.path}` : '新增 API'}
onClose={() => setDrawerOpen(false)}
width={520}
extra={
<Space>
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" loading={saving} onClick={saveApi}>
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="新增 API 后不会自动授权角色。正确流程是:新建 API -> 新建菜单 -> 到角色管理页统一分配菜单和 API 权限。"
/>
<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 name="method" label="请求" rules={[{ required: true, message: '请选择请求方式' }]}>
<Select options={methodOptions.map((item) => ({ label: `${item.label}(${item.value})`, value: item.value }))} />
</Form.Item>
<Form.Item name="apiGroup" label="分组" rules={[{ required: true, message: '请输入分组' }]}>
<Input />
<Form.Item name="apiGroup" label="API 分组" rules={[{ required: true, message: '请选择或输入 API 分组' }]}>
<Select
showSearch
placeholder="输入后会先模糊搜索;若不存在,按回车创建新分组"
options={groupOptions}
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
onSearch={setEditGroupSearch}
onInputKeyDown={(event) => {
if (event.key !== 'Enter') {
return
}
event.preventDefault()
const createdGroup = ensureGroupOption(editGroupSearch)
if (createdGroup) {
editForm.setFieldValue('apiGroup', createdGroup)
setEditGroupSearch('')
}
}}
/>
</Form.Item>
<Form.Item name="method" label="方法" rules={[{ required: true, message: '请选择方法' }]}>
<Select options={methodOptions.map((item) => ({ label: item, value: item }))} />
<Form.Item name="description" label="API 简介" rules={[{ required: true, message: '请输入 API 简介' }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</Drawer>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={activeApi ? `接口角色分配 · ${activeApi.path}` : '接口角色分配'}
width={520}
open={syncDrawerOpen}
onClose={() => setSyncDrawerOpen(false)}
width={1080}
title="同步路由"
extra={
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
<Space>
<Button onClick={() => setSyncDrawerOpen(false)}></Button>
<Button type="primary" loading={syncing} onClick={() => void submitSyncPayload()}>
</Button>
</Space>
}
>
<Select
mode="multiple"
style={{ width: '100%' }}
value={selectedRoles}
options={roleOptions}
onChange={setSelectedRoles}
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="同步 API 会把路由变更写入接口表。新增路由需要先补齐分组和描述;忽略路由不会参与同步。"
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey={(record) => `${record.path}::${record.method}`}
pagination={false}
dataSource={syncPayload.newApis}
columns={[
{ title: 'API 路径', dataIndex: 'path', width: 220 },
{
title: 'API 分组',
width: 220,
render: (_, record) => (
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={record.apiGroup}
options={groupOptions}
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
onSearch={setSyncGroupSearch}
onInputKeyDown={(event) => {
if (event.key !== 'Enter') {
return
}
event.preventDefault()
const createdGroup = ensureGroupOption(syncGroupSearch)
if (!createdGroup) {
return
}
setSyncGroupSearch('')
setSyncPayload((current) => ({
...current,
newApis: current.newApis.map((item) =>
item.path === record.path && item.method === record.method ? { ...item, apiGroup: createdGroup } : item,
),
}))
}}
onChange={(value) => {
setSyncPayload((current) => ({
...current,
newApis: current.newApis.map((item) =>
item.path === record.path && item.method === record.method ? { ...item, apiGroup: value } : item,
),
}))
}}
/>
),
},
{
title: 'API 简介',
width: 240,
render: (_, record) => (
<Input
value={record.description}
onChange={(event) => {
const value = event.target.value
setSyncPayload((current) => ({
...current,
newApis: current.newApis.map((item) =>
item.path === record.path && item.method === record.method ? { ...item, description: value } : item,
),
}))
}}
/>
),
},
{
title: '请求',
width: 140,
render: (_, record) => {
const meta = methodMeta(record.method)
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
},
},
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button type="link" onClick={() => void createSingleSyncApi(record)}>
</Button>
<Button type="link" onClick={() => void ignoreSyncApi(record, true)}>
</Button>
</Space>
),
},
]}
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey={(record) => `${record.path}::${record.method}`}
pagination={false}
dataSource={syncPayload.deleteApis}
columns={[
{ title: 'API 路径', dataIndex: 'path' },
{ title: 'API 分组', dataIndex: 'apiGroup' },
{ title: 'API 简介', dataIndex: 'description' },
{
title: '请求',
render: (_, record) => {
const meta = methodMeta(record.method)
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
},
},
]}
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey={(record) => `${record.path}::${record.method}`}
pagination={false}
dataSource={syncPayload.ignoreApis}
columns={[
{ title: 'API 路径', dataIndex: 'path' },
{ title: 'API 分组', dataIndex: 'apiGroup' },
{ title: 'API 简介', dataIndex: 'description' },
{
title: '请求',
render: (_, record) => {
const meta = methodMeta(record.method)
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
},
},
{
title: '操作',
render: (_, record) => (
<Button type="link" onClick={() => void ignoreSyncApi(record, false)}>
</Button>
),
},
]}
/>
</Space>
</Drawer>
</div>
)

View File

@@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Alert, Button, Card, Form, Input, Result, Select, Space, Spin, Typography, message } from 'antd'
import { initApi } from '@/lib/api'
import { useAuthStore } from '@/store/auth'
import type { InitCheckResult, InitDBPayload } from '@/types/system'
const dbTypeOptions: Array<{ label: string; value: InitDBPayload['dbType'] }> = [
{ label: 'MySQL', value: 'mysql' },
{ label: 'PostgreSQL', value: 'pgsql' },
{ label: 'SQLite', value: 'sqlite' },
{ label: 'MSSQL', value: 'mssql' },
]
type InitState = 'checking' | 'required' | 'ready'
const dbDefaults: Record<InitDBPayload['dbType'], Partial<InitDBPayload>> = {
mysql: {
host: '127.0.0.1',
port: '3306',
},
pgsql: {
host: '127.0.0.1',
port: '5432',
template: 'template1',
},
sqlite: {
dbPath: 'db',
},
mssql: {
host: '127.0.0.1',
port: '1433',
},
}
function applyDbDefaults(currentValues: InitDBPayload, nextDbType: InitDBPayload['dbType']): InitDBPayload {
return {
...currentValues,
...dbDefaults[nextDbType],
dbType: nextDbType,
}
}
export function InitPage() {
const navigate = useNavigate()
const token = useAuthStore((state) => state.token)
const [form] = Form.useForm<InitDBPayload>()
const [state, setState] = useState<InitState>('checking')
const [checkingError, setCheckingError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const dbType = Form.useWatch('dbType', form) ?? 'mysql'
const checkInitialization = async () => {
try {
const response = await initApi.checkDB()
const data: InitCheckResult = response.data
if (data.needInit) {
setState('required')
} else {
setState('ready')
}
setCheckingError(null)
} catch (error) {
const messageText = error instanceof Error ? error.message : '初始化状态检测失败'
setCheckingError(messageText)
}
}
useEffect(() => {
void checkInitialization()
}, [])
const helperText = useMemo(() => {
switch (dbType) {
case 'mysql':
return '将创建数据库并写回 MySQL 连接配置。'
case 'pgsql':
return '默认使用 PostgreSQL 公共库建库,可按需指定 template。'
case 'sqlite':
return '将创建本地 SQLite 数据库文件,并写回 sqlite 配置。'
case 'mssql':
return '将按当前连接信息接入 MSSQL并写回 mssql 配置。'
default:
return ''
}
}, [dbType])
const onDbTypeChange = (nextDbType: InitDBPayload['dbType']) => {
const currentValues = form.getFieldsValue()
form.setFieldsValue(applyDbDefaults(currentValues, nextDbType))
}
const submit = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
await initApi.initDB(values)
message.success('初始化完成,请使用 admin 账户登录')
navigate('/login', { replace: true })
} finally {
setSubmitting(false)
}
}
if (state === 'checking') {
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Project Bootstrap</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 }}>
使
</Typography.Paragraph>
</div>
</section>
<section className="login-form-wrap">
<Card className="glass-panel login-card" bordered={false}>
<div className="fullscreen-status" style={{ minHeight: 360 }}>
<Spin size="large" />
<span>...</span>
{checkingError ? (
<Alert
type="warning"
showIcon
message="检测失败"
description={checkingError}
action={
<Button size="small" onClick={() => void checkInitialization()}>
</Button>
}
/>
) : null}
</div>
</Card>
</section>
</div>
)
}
if (state === 'ready') {
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Project Bootstrap</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 }}>
</Typography.Paragraph>
</div>
</section>
<section className="login-form-wrap">
<Card className="glass-panel login-card" bordered={false}>
<Result
status="success"
title="无需再次初始化"
subTitle="如果需要调整系统配置,请登录后台后在系统配置页修改。"
extra={[
<Button key="login" type="primary" onClick={() => navigate(token ? '/' : '/login', { replace: true })}>
{token ? '进入后台' : '前往登录'}
</Button>,
<Button key="check" onClick={() => void checkInitialization()}>
</Button>,
]}
/>
</Card>
</section>
</div>
)
}
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Project Bootstrap</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 }}>
</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" style={{ marginBottom: 20 }}>
`admin`使
</Typography.Paragraph>
{checkingError ? (
<Alert
type="warning"
showIcon
message="状态检测存在异常"
description={checkingError}
style={{ marginBottom: 16 }}
/>
) : null}
<Form
form={form}
layout="vertical"
initialValues={{
adminPassword: '',
dbType: 'mysql',
host: '127.0.0.1',
port: '3306',
dbName: 'go_web_template',
userName: 'root',
dbPath: 'db',
template: 'template1',
}}
onFinish={submit}
>
<Form.Item name="dbType" label="数据库类型" rules={[{ required: true, message: '请选择数据库类型' }]}>
<Select options={dbTypeOptions} onChange={onDbTypeChange} />
</Form.Item>
<Typography.Paragraph className="text-muted" style={{ marginTop: -8, marginBottom: 16 }}>
{helperText}
</Typography.Paragraph>
<div className="init-form-grid">
{dbType === 'sqlite' ? (
<Form.Item
name="dbPath"
label="数据库目录"
rules={[{ required: true, message: '请输入 SQLite 数据库目录' }]}
>
<Input placeholder="例如 db" />
</Form.Item>
) : (
<>
<Form.Item name="host" label="数据库地址" rules={[{ required: true, message: '请输入数据库地址' }]}>
<Input placeholder="例如 127.0.0.1" />
</Form.Item>
<Form.Item name="port" label="数据库端口" rules={[{ required: true, message: '请输入数据库端口' }]}>
<Input placeholder="例如 3306" />
</Form.Item>
<Form.Item
name="userName"
label="数据库用户名"
rules={[{ required: true, message: '请输入数据库用户名' }]}
>
<Input placeholder="例如 root" />
</Form.Item>
<Form.Item name="password" label="数据库密码">
<Input.Password placeholder="请输入数据库密码" />
</Form.Item>
</>
)}
<Form.Item name="dbName" label="数据库名" rules={[{ required: true, message: '请输入数据库名' }]}>
<Input placeholder={dbType === 'sqlite' ? '例如 go_web_template' : '例如 go_web_template'} />
</Form.Item>
{dbType === 'pgsql' ? (
<Form.Item name="template" label="建库模板">
<Input placeholder="默认 template1" />
</Form.Item>
) : (
<div />
)}
<Form.Item
name="adminPassword"
label="管理员密码"
rules={[
{ required: true, message: '请输入管理员密码' },
{ min: 6, message: '管理员密码至少 6 位' },
]}
>
<Input.Password placeholder="初始化后 admin 账户使用此密码登录" />
</Form.Item>
</div>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="初始化完成后,后端会立即持有数据库连接,并将连接配置写回当前配置文件。"
/>
<Space wrap>
<Button type="primary" htmlType="submit" loading={submitting}>
</Button>
<Button onClick={() => void checkInitialization()}></Button>
<Button onClick={() => navigate('/login', { replace: true })}></Button>
</Space>
</Form>
</Card>
</section>
</div>
)
}

View File

@@ -1,10 +1,10 @@
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 { Alert, Button, Card, Form, Input, Result, Spin, Typography, message } from 'antd'
import { authApi, initApi, menuApi } from '@/lib/api'
import { buildFullMenus, findDefaultRoute } from '@/lib/menu'
import { useAuthStore } from '@/store/auth'
import type { CaptchaInfo } from '@/types/system'
import type { CaptchaInfo, InitCheckResult } from '@/types/system'
type LoginForm = {
username: string
@@ -34,6 +34,9 @@ export function LoginPage() {
const [captcha, setCaptcha] = useState<CaptchaInfo | null>(null)
const [submitting, setSubmitting] = useState(false)
const [loadCaptchaError, setLoadCaptchaError] = useState<string | null>(null)
const [checkingInit, setCheckingInit] = useState(true)
const [needInit, setNeedInit] = useState(false)
const [loadInitError, setLoadInitError] = useState<string | null>(null)
const redirectTarget = useMemo(() => {
const state = location.state as { redirectTo?: string } | null
@@ -51,8 +54,38 @@ export function LoginPage() {
}
}
const checkInitialization = async () => {
try {
setCheckingInit(true)
const response = await initApi.checkDB()
const data: InitCheckResult = response.data
setNeedInit(data.needInit)
setLoadInitError(null)
return data.needInit
} catch (error) {
const messageText = error instanceof Error ? error.message : '初始化状态检测失败'
setLoadInitError(messageText)
return false
} finally {
setCheckingInit(false)
}
}
useEffect(() => {
fetchCaptcha()
let ignore = false
const prepare = async () => {
const required = await checkInitialization()
if (!required && !ignore) {
await fetchCaptcha()
}
}
void prepare()
return () => {
ignore = true
}
}, [])
const submit = async () => {
@@ -87,10 +120,84 @@ export function LoginPage() {
}
}
if (checkingInit) {
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Gin-Vue-Admin · React </span>
<span className="capsule">Gin-React-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 }}>
</Typography.Paragraph>
</div>
</section>
<section className="login-form-wrap">
<Card className="glass-panel login-card" bordered={false}>
<div className="fullscreen-status" style={{ minHeight: 360 }}>
<Spin size="large" />
<span>...</span>
</div>
</Card>
</section>
</div>
)
}
if (needInit) {
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Gin-React-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 }}>
使
</Typography.Paragraph>
</div>
</section>
<section className="login-form-wrap">
<Card className="glass-panel login-card" bordered={false}>
<Result
status="warning"
title="检测到项目尚未初始化"
subTitle="当前登录流程不可用。请先进入初始化页,完成数据库和管理员账号初始化。"
extra={[
<Button key="init" type="primary" onClick={() => navigate('/init', { replace: true })}>
</Button>,
<Button key="retry" onClick={() => void checkInitialization()}>
</Button>,
]}
/>
{loadInitError ? (
<Alert
type="warning"
showIcon
message="初始化状态检测异常"
description={loadInitError}
style={{ marginTop: 16 }}
/>
) : null}
</Card>
</section>
</div>
)
}
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Gin-React-Admin · React </span>
<div style={{ maxWidth: 620, marginTop: 72 }}>
<Typography.Title style={{ color: 'rgba(255,255,255,0.96)', fontSize: 52, marginBottom: 18 }}>
@@ -110,6 +217,20 @@ export function LoginPage() {
<Typography.Paragraph className="text-muted">
沿
</Typography.Paragraph>
{loadInitError ? (
<Alert
type="warning"
showIcon
message="初始化状态检测失败"
description={loadInitError}
action={
<Button size="small" onClick={() => void checkInitialization()}>
</Button>
}
style={{ marginBottom: 16 }}
/>
) : null}
{loadCaptchaError ? (
<Alert
type="warning"

View File

@@ -42,18 +42,18 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
title: '菜单管理',
group: '平台治理',
status: 'ready',
summary: '维护后台菜单树、路由属性、按钮定义和角色关联。',
features: ['菜单树编辑', '路由路径维护', '角色分配', '按钮权限入口'],
endpoints: ['/menu/getBaseMenuTree', '/menu/addBaseMenu', '/menu/updateBaseMenu', '/menu/setMenuRoles'],
summary: '维护后台菜单树、路由属性、参数和按钮定义。',
features: ['菜单树编辑', '路由路径维护', '参数配置', '按钮定义'],
endpoints: ['/menu/getMenuList', '/menu/addBaseMenu', '/menu/updateBaseMenu'],
},
api: {
name: 'api',
title: 'API 管理',
group: '平台治理',
status: 'ready',
summary: '管理接口目录、接口角色授权和 Casbin 刷新。',
features: ['接口列表', '接口增删改', '角色授权', '接口分组'],
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/setApiRoles'],
summary: '管理接口目录、同步结果、分组和 Casbin 刷新。',
features: ['接口列表', '接口增删改', '接口同步', '接口分组'],
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/syncApi'],
},
user: {
name: 'user',
@@ -118,15 +118,6 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
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: '错误日志',
@@ -168,27 +159,9 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
title: '编程辅助',
group: '研发辅助',
status: 'partial',
summary: '聚合表单设计、导出模板和 MCP 管理等研发辅助能力。',
features: ['表单设计', '导出模板', 'MCP 管理'],
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/mcp/status'],
},
formCreate: {
name: 'formCreate',
title: '表单生成器',
group: '研发辅助',
status: 'partial',
summary: '拖拽式表单构建入口。',
features: ['表单设计'],
endpoints: [],
},
exportTemplate: {
name: 'exportTemplate',
title: '导出模板',
group: '研发辅助',
status: 'partial',
summary: '维护 Excel 导入导出模板。',
features: ['模板管理', 'SQL 预览'],
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/sysExportTemplate/previewSQL'],
summary: '聚合 MCP 管理等研发辅助能力。',
features: ['MCP 管理', 'MCP 模板'],
endpoints: ['/mcp/status', '/mcp/tools', '/mcp/createTool'],
},
mcpTest: {
name: 'mcpTest',

View File

@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Cascader,
Card,
Divider,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Segmented,
Space,
Switch,
Table,
@@ -16,33 +19,136 @@ import {
Typography,
message,
} from 'antd'
import {
AppstoreOutlined,
ApiOutlined,
BugOutlined,
BookOutlined,
CloudServerOutlined,
DashboardOutlined,
DatabaseOutlined,
DeleteOutlined,
DeploymentUnitOutlined,
FileTextOutlined,
HomeOutlined,
InfoCircleOutlined,
LockOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
TeamOutlined,
ToolOutlined,
UploadOutlined,
UserOutlined,
} from '@ant-design/icons'
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'
import { authorityBtnApi, menuApi } from '@/lib/api'
import {
buildMenuComponentOptions,
getComponentLabelByRouteName,
getComponentSuggestedRouteName,
getComponentValueByRouteName,
} from '@/features/menus/menuComponentCatalog'
import { flattenMenusForOptions } from '@/lib/tree'
import { filterRemovedMenus } from '@/lib/menu'
import type { MenuButton, MenuNode, MenuParameter } from '@/types/system'
type MenuFormValues = {
parentId: number
path: string
name: string
hidden: boolean
component: string
sort: number
meta: {
title: string
icon?: string
activeName?: string
defaultMenu?: boolean
closeTab?: boolean
keepAlive?: boolean
transitionType?: string
}
parameters: MenuParameter[]
menuBtn: MenuButton[]
}
type ComponentInputMode = 'selector' | 'manual'
const componentOptions = buildMenuComponentOptions()
const iconRegistry = {
AppstoreOutlined,
ApiOutlined,
BugOutlined,
BookOutlined,
CloudServerOutlined,
DashboardOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
FileTextOutlined,
HomeOutlined,
InfoCircleOutlined,
LockOutlined,
ProfileOutlined,
SettingOutlined,
TeamOutlined,
ToolOutlined,
UploadOutlined,
UserOutlined,
}
const iconOptions = Object.entries(iconRegistry).map(([name, IconComponent]) => ({
value: name,
searchText: name.toLowerCase(),
label: (
<Space>
<IconComponent />
<span>{name}</span>
</Space>
),
}))
function createEmptyMenuForm(parentId = 0): MenuFormValues {
return {
parentId,
path: '',
name: '',
hidden: false,
component: '',
sort: 1,
meta: {
title: '',
icon: '',
activeName: '',
defaultMenu: false,
closeTab: false,
keepAlive: false,
transitionType: '',
},
parameters: [],
menuBtn: [],
}
}
function resolveMenuComponentValue(menu: Pick<MenuNode, 'name' | 'component'>) {
return getComponentValueByRouteName(menu.name) || menu.component
}
function resolveMenuComponentDisplay(menu: Pick<MenuNode, 'name' | 'component'>) {
return getComponentLabelByRouteName(menu.name) || resolveMenuComponentValue(menu)
}
export function MenuManagementPage() {
const [form] = Form.useForm()
const [form] = Form.useForm<MenuFormValues>()
const [menus, setMenus] = useState<MenuNode[]>([])
const [roles, setRoles] = useState<Authority[]>([])
const [loading, setLoading] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editorOpen, setEditorOpen] = 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 [componentInputMode, setComponentInputMode] = useState<ComponentInputMode>('selector')
const selectedComponent = Form.useWatch('component', form)
const selectedIconName = Form.useWatch(['meta', 'icon'], form)
const menuOptions = useMemo(
() =>
@@ -56,48 +162,51 @@ export function MenuManagementPage() {
const reloadMenus = async () => {
setLoading(true)
try {
const [menuRes, roleRes] = await Promise.all([menuApi.getBaseMenuTree(), authorityApi.getAuthorityList()])
setMenus(menuRes.data.menus)
setRoles(roleRes.data)
const menuRes = await menuApi.getMenuList()
setMenus(filterRemovedMenus(menuRes.data))
} finally {
setLoading(false)
}
}
useEffect(() => {
reloadMenus()
void reloadMenus()
}, [])
const openCreate = (parentId = 0) => {
setEditingMenu(null)
setComponentInputMode('selector')
form.resetFields()
form.setFieldsValue({
parentId,
sort: 1,
hidden: false,
keepAlive: false,
closeTab: false,
})
setModalOpen(true)
form.setFieldsValue(createEmptyMenuForm(parentId))
setEditorOpen(true)
}
const openEdit = async (record: MenuNode) => {
const response = await menuApi.getBaseMenuById(record.ID)
const menu = response.data.menu
const resolvedComponent = resolveMenuComponentValue(menu)
setEditingMenu(menu)
setComponentInputMode(getComponentValueByRouteName(menu.name) || getComponentSuggestedRouteName(menu.component) ? 'selector' : 'manual')
form.setFieldsValue({
parentId: menu.parentId,
title: menu.meta.title,
name: menu.name,
path: menu.path,
component: menu.component,
icon: menu.meta.icon,
name: menu.name,
hidden: Boolean(menu.hidden),
component: resolvedComponent,
sort: menu.sort,
hidden: menu.hidden,
keepAlive: menu.meta.keepAlive,
closeTab: menu.meta.closeTab,
meta: {
title: menu.meta.title,
icon: menu.meta.icon,
activeName: menu.meta.activeName,
defaultMenu: Boolean(menu.meta.defaultMenu),
closeTab: Boolean(menu.meta.closeTab),
keepAlive: Boolean(menu.meta.keepAlive),
transitionType: menu.meta.transitionType,
},
parameters: menu.parameters || [],
menuBtn: menu.menuBtn || [],
})
setModalOpen(true)
setEditorOpen(true)
}
const saveMenu = async () => {
@@ -105,17 +214,31 @@ export function MenuManagementPage() {
const payload = {
ID: editingMenu?.ID,
parentId: values.parentId || 0,
name: values.name,
path: values.path,
name: values.name,
hidden: Boolean(values.hidden),
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),
title: values.meta.title,
icon: values.meta.icon,
activeName: values.meta.activeName,
defaultMenu: Boolean(values.meta.defaultMenu),
closeTab: Boolean(values.meta.closeTab),
keepAlive: Boolean(values.meta.keepAlive),
transitionType: values.meta.transitionType,
},
parameters: (values.parameters || []).map((item) => ({
ID: item.ID,
type: item.type,
key: item.key,
value: item.value,
})),
menuBtn: (values.menuBtn || []).map((item) => ({
ID: item.ID,
name: item.name,
desc: item.desc,
})),
}
setSaving(true)
@@ -125,10 +248,10 @@ export function MenuManagementPage() {
message.success('菜单已更新')
} else {
await menuApi.addBaseMenu(payload)
message.success('菜单已创建')
message.success('菜单已创建,请到角色管理分配菜单权限')
}
setModalOpen(false)
reloadMenus()
setEditorOpen(false)
await reloadMenus()
} finally {
setSaving(false)
}
@@ -137,39 +260,29 @@ export function MenuManagementPage() {
const deleteMenu = (record: MenuNode) => {
Modal.confirm({
title: `删除菜单 ${record.meta.title}`,
content: '如果该菜单已被角色使用,删除前请先调整菜单授权。',
content: '此操作会删除所有角色下的当前菜单关系,请确认后继续。',
okButtonProps: { danger: true },
onOk: async () => {
await menuApi.deleteBaseMenu(record.ID)
message.success('菜单已删除')
reloadMenus()
await 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) {
const removeMenuButton = async (index: number) => {
const currentButtons = form.getFieldValue('menuBtn') || []
const target = currentButtons[index]
if (!target) {
return
}
setSavingRoles(true)
try {
await menuApi.setMenuRoles({
menuId: activeMenu.ID,
authorityIds: selectedRoles,
})
message.success('菜单角色关系已更新')
setDrawerOpen(false)
} finally {
setSavingRoles(false)
if (target.ID) {
await authorityBtnApi.canRemoveAuthorityBtn(target.ID)
}
form.setFieldValue(
'menuBtn',
currentButtons.filter((_: MenuButton, currentIndex: number) => currentIndex !== index),
)
}
const columns: ColumnsType<MenuNode> = [
@@ -179,23 +292,36 @@ export function MenuManagementPage() {
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,
title: '图标',
width: 140,
render: (_, record) => record.meta.icon || '-',
},
{ title: '路由 Name', dataIndex: 'name', width: 160 },
{ title: '路由 Path', dataIndex: 'path', width: 180 },
{ title: '父节点', dataIndex: 'parentId', width: 100 },
{ title: '排序', dataIndex: 'sort', width: 90 },
{
title: '组件路径',
width: 320,
render: (_, record) => resolveMenuComponentDisplay(record),
},
{
title: '显示配置',
width: 280,
render: (_, record) => (
<Space wrap>
<Tag color={record.hidden ? 'default' : 'green'}>{record.hidden ? '隐藏' : '显示'}</Tag>
{record.meta.keepAlive ? <Tag color="blue">KeepAlive</Tag> : null}
{record.meta.closeTab ? <Tag color="orange">CloseTab</Tag> : null}
{record.meta.defaultMenu ? <Tag color="purple"></Tag> : null}
</Space>
),
},
{
title: '操作',
key: 'actions',
width: 260,
width: 200,
fixed: 'right',
render: (_, record) => (
<Space wrap>
@@ -205,9 +331,6 @@ export function MenuManagementPage() {
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button type="link" onClick={() => openRoleDrawer(record)}>
</Button>
<Button danger type="link" onClick={() => deleteMenu(record)}>
</Button>
@@ -219,13 +342,19 @@ export function MenuManagementPage() {
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="菜单页只负责维护菜单结构。角色授权统一放到角色管理中处理:先新建 API再新建菜单最后到角色管理分配菜单和 API 权限。"
/>
<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()}>
@@ -239,98 +368,251 @@ export function MenuManagementPage() {
dataSource={menus}
expandable={{ defaultExpandAllRows: true }}
pagination={false}
scroll={{ x: 1300 }}
scroll={{ x: 1700 }}
/>
</Space>
</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}
open={editorOpen}
title={editingMenu ? `编辑菜单 · ${editingMenu.meta.title}` : '新增菜单'}
onClose={() => setEditorOpen(false)}
width={920}
extra={
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
<Space>
<Button onClick={() => setEditorOpen(false)}></Button>
<Button type="primary" loading={saving} onClick={saveMenu}>
</Button>
</Space>
}
>
{defaultRouterRoles.length ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`${defaultRouterRoles.length} 个角色将当前菜单设为默认首页,调整前请确认首页策略。`}
message="文件路径支持组件选择器和手动输入两种模式。新增菜单后,请到角色管理页继续分配菜单权限。"
/>
) : null}
<Select
mode="multiple"
<Form form={form} layout="vertical">
<Typography.Title level={5}></Typography.Title>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
<Typography.Text type="secondary"></Typography.Text>
<Segmented<ComponentInputMode>
value={componentInputMode}
options={[
{ label: '组件选择器', value: 'selector' },
{ label: '手动输入', value: 'manual' },
]}
onChange={(value) => setComponentInputMode(value)}
/>
</Space>
{componentInputMode === 'selector' ? (
<Form.Item label="文件路径" required>
<Cascader
style={{ width: '100%' }}
value={selectedRoles}
options={roleOptions}
onChange={setSelectedRoles}
options={componentOptions}
value={(selectedComponent || '').split('/').filter(Boolean)}
onChange={(value) => {
const componentValue = value.join('/')
form.setFieldValue('component', componentValue)
if (!editingMenu) {
const suggestedRouteName = getComponentSuggestedRouteName(componentValue)
if (suggestedRouteName && !form.getFieldValue('name')) {
form.setFieldValue('name', suggestedRouteName)
form.setFieldValue('path', suggestedRouteName)
}
}
}}
placeholder="请选择页面组件"
/>
<Form.Item name="component" hidden rules={[{ required: true, message: '请输入文件路径' }]}>
<Input />
</Form.Item>
</Form.Item>
) : (
<Form.Item name="component" label="文件路径" rules={[{ required: true, message: '请输入文件路径' }]}>
<Input placeholder="例如 features/users/UserManagementPage" />
</Form.Item>
)}
<div className="config-form-grid">
<Form.Item
className="config-grid-span-full"
name={['meta', 'title']}
label="展示名称"
rules={[{ required: true, message: '请输入菜单展示名称' }]}
>
<Input placeholder="请输入菜单展示名称" />
</Form.Item>
<Form.Item name="name" label="路由 Name" rules={[{ required: true, message: '请输入路由 Name' }]}>
<Input
placeholder="唯一英文字符串"
onChange={(event) => {
const currentPath = form.getFieldValue('path')
if (!editingMenu || currentPath === form.getFieldValue('name')) {
form.setFieldValue('path', event.target.value)
}
}}
/>
</Form.Item>
<Form.Item name="path" label="路由 Path" rules={[{ required: true, message: '请输入路由 Path' }]}>
<Input placeholder="建议与路由 Name 保持一致" />
</Form.Item>
</div>
<Divider />
<Typography.Title level={5}></Typography.Title>
<div className="config-form-grid">
<Form.Item name="parentId" label="父节点">
<Select options={[{ label: '根目录', value: 0 }, ...menuOptions]} />
</Form.Item>
<Form.Item name="sort" label="排序标记">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</div>
<Divider />
<Typography.Title level={5}></Typography.Title>
<div className="config-form-grid">
<Form.Item name={['meta', 'icon']} label="图标">
<Select
showSearch
allowClear
placeholder="请选择图标"
options={iconOptions}
filterOption={(input, option) => String((option as { searchText?: string })?.searchText || '').includes(input.toLowerCase())}
/>
</Form.Item>
<Form.Item name="hidden" label="是否隐藏" valuePropName="checked">
<Switch />
</Form.Item>
</div>
{selectedIconName && selectedIconName in iconRegistry ? (
<div style={{ marginBottom: 12 }}>
<Tag icon={(() => {
const PreviewIcon = iconRegistry[selectedIconName as keyof typeof iconRegistry]
return <PreviewIcon />
})()}>
{selectedIconName}
</Tag>
</div>
) : null}
<Divider />
<Typography.Title level={5}></Typography.Title>
<div className="config-form-grid">
<Form.Item name={['meta', 'activeName']} label="高亮菜单">
<Input placeholder="为空时默认使用当前路由 Name" />
</Form.Item>
<Form.Item name={['meta', 'transitionType']} label="路由切换动画">
<Select
allowClear
options={[
{ label: '淡入淡出', value: 'fade' },
{ label: '滑动', value: 'slide' },
{ label: '缩放', value: 'zoom' },
{ label: '无动画', value: 'none' },
]}
/>
</Form.Item>
</div>
<Space size="large" wrap>
<Form.Item name={['meta', 'keepAlive']} label="KeepAlive" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['meta', 'closeTab']} label="CloseTab" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['meta', 'defaultMenu']} label="基础页面" valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<Divider />
<div className="section-heading" style={{ marginBottom: 12 }}>
<Typography.Title level={5} style={{ marginBottom: 0 }}>
</Typography.Title>
</div>
<Form.List name="parameters">
{(fields, { add, remove }) => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{fields.map((field) => (
<Card key={field.key} size="small" className="config-array-item">
<div className="config-form-grid">
<Form.Item
name={[field.name, 'type']}
label="参数类型"
rules={[{ required: true, message: '请选择参数类型' }]}
>
<Select
options={[
{ label: 'query', value: 'query' },
{ label: 'params', value: 'params' },
]}
/>
</Form.Item>
<Form.Item
name={[field.name, 'key']}
label="参数 Key"
rules={[{ required: true, message: '请输入参数 Key' }]}
>
<Input />
</Form.Item>
<Form.Item className="config-grid-span-full" name={[field.name, 'value']} label="参数值">
<Input />
</Form.Item>
</div>
<Button danger type="link" icon={<DeleteOutlined />} onClick={() => remove(field.name)}>
</Button>
</Card>
))}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => add({ type: 'query', key: '', value: '' })}
>
</Button>
</Space>
)}
</Form.List>
<Divider />
<div className="section-heading" style={{ marginBottom: 12 }}>
<Typography.Title level={5} style={{ marginBottom: 0 }}>
</Typography.Title>
</div>
<Form.List name="menuBtn">
{(fields, { add }) => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{fields.map((field, index) => (
<Card key={field.key} size="small" className="config-array-item">
<div className="config-form-grid">
<Form.Item
name={[field.name, 'name']}
label="按钮名称"
rules={[{ required: true, message: '请输入按钮名称' }]}
>
<Input />
</Form.Item>
<Form.Item name={[field.name, 'desc']} label="按钮备注">
<Input />
</Form.Item>
</div>
<Button danger type="link" icon={<DeleteOutlined />} onClick={() => void removeMenuButton(index)}>
</Button>
</Card>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ name: '', desc: '' })}>
</Button>
</Space>
)}
</Form.List>
</Form>
</Drawer>
</div>
)

View File

@@ -12,6 +12,7 @@ type ComponentTreeNode = {
const componentOptions: ComponentOption[] = [
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
{ value: 'features/discovery/ModuleLandingPage:about', label: 'ModuleLandingPage · 关于系统', routeName: 'about' },
{ value: 'features/roles/RoleManagementPage', label: 'RoleManagementPage · 角色管理', routeName: 'authority' },
{ value: 'features/menus/MenuManagementPage', label: 'MenuManagementPage · 菜单管理', routeName: 'menu' },
{ value: 'features/apis/ApiManagementPage', label: 'ApiManagementPage · API 管理', routeName: 'api' },
@@ -54,17 +55,14 @@ const componentOptions: ComponentOption[] = [
label: 'ModuleLandingPage · 编程辅助',
routeName: 'systemTools',
},
{
value: 'features/discovery/ModuleLandingPage:exportTemplate',
label: 'ModuleLandingPage · 导出模板',
routeName: 'exportTemplate',
},
{ value: 'features/mcp/McpTestPage', label: 'McpTestPage · MCP Tools 管理', routeName: 'mcpTest' },
{ value: 'features/mcp/McpToolPage', label: 'McpToolPage · MCP Tools 模板', routeName: 'mcpTool' },
{ value: 'features/media/MediaLibraryPage', label: 'MediaLibraryPage · 媒体库', routeName: 'upload' },
]
const componentRouteNameMap = new Map(componentOptions.map((item) => [item.value, item.routeName]))
const routeNameComponentMap = new Map(componentOptions.map((item) => [item.routeName, item.value]))
const routeNameLabelMap = new Map(componentOptions.map((item) => [item.routeName, item.label]))
export function buildMenuComponentOptions(): ComponentTreeNode[] {
const result: ComponentTreeNode[] = []
@@ -105,3 +103,11 @@ export function isKnownMenuComponent(componentValue: string) {
export function getComponentSuggestedRouteName(componentValue: string) {
return componentRouteNameMap.get(componentValue)
}
export function getComponentValueByRouteName(routeName: string) {
return routeNameComponentMap.get(routeName)
}
export function getComponentLabelByRouteName(routeName: string) {
return routeNameLabelMap.get(routeName)
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Card,
Drawer,
@@ -17,26 +18,52 @@ import {
} 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'
import { authorityBtnApi, apiRegistryApi, authorityApi, casbinApi, menuApi, userApi } from '@/lib/api'
import { filterRemovedMenus } from '@/lib/menu'
import { collectMenusByIds, flattenAuthorities } from '@/lib/tree'
import type { ApiRecord, Authority, MenuButton, MenuNode, UserInfo } from '@/types/system'
type PermissionTab = 'menus' | 'apis' | 'users'
type PermissionTab = 'menus' | 'apis'
function mapMenusToTree(menus: MenuNode[]): DataNode[] {
return menus.map((menu) => ({
key: menu.ID,
title: menu.meta.title,
children: mapMenusToTree(menu.children || []),
}))
type RoleFormValues = {
authorityId: number
authorityName: string
parentId: number
}
function mapApisToTree(apis: ApiRecord[]): DataNode[] {
const grouped = apis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
if (!accumulator[api.apiGroup]) {
accumulator[api.apiGroup] = []
type UserSearchValues = {
username: string
nickName: string
}
accumulator[api.apiGroup].push(api)
function filterMenuTree(nodes: MenuNode[], keyword: string): MenuNode[] {
const normalized = keyword.trim()
if (!normalized) {
return nodes
}
return nodes.flatMap((node) => {
const children = filterMenuTree(node.children || [], normalized)
if (node.meta.title.includes(normalized) || node.name.includes(normalized) || children.length > 0) {
return [{ ...node, children }]
}
return []
})
}
function buildApiTree(apis: ApiRecord[], nameKeyword: string, pathKeyword: string): DataNode[] {
const filteredApis = apis.filter((api) => {
const matchedName = !nameKeyword || api.description.includes(nameKeyword)
const matchedPath = !pathKeyword || api.path.includes(pathKeyword)
return matchedName && matchedPath
})
const grouped = filteredApis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
const group = api.apiGroup || '未分组'
if (!accumulator[group]) {
accumulator[group] = []
}
accumulator[group].push(api)
return accumulator
}, {})
@@ -45,30 +72,96 @@ function mapApisToTree(apis: ApiRecord[]): DataNode[] {
title: `${group}`,
children: items.map((item) => ({
key: `${item.path}::${item.method}`,
title: `${item.method} ${item.path} · ${item.description}`,
title: (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span>{item.description || `${item.method} ${item.path}`}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.path}
</Typography.Text>
</div>
),
})),
}))
}
function buildMenuTree(
menus: MenuNode[],
defaultRouter: string | undefined,
onOpenButtons: (menu: MenuNode) => void,
onSetHomepage: (menu: MenuNode) => void,
): DataNode[] {
return menus.map((menu) => ({
key: menu.ID,
title: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>{menu.meta.title}</span>
{defaultRouter === menu.name ? <Tag color="gold"></Tag> : null}
{menu.name && !(menu.children || []).length ? (
<Button
size="small"
type="link"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onSetHomepage(menu)
}}
>
</Button>
) : null}
{menu.menuBtn?.length ? (
<Button
size="small"
type="link"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onOpenButtons(menu)
}}
>
</Button>
) : null}
</div>
),
children: buildMenuTree(menu.children || [], defaultRouter, onOpenButtons, onSetHomepage),
}))
}
export function RoleManagementPage() {
const [form] = Form.useForm()
const [form] = Form.useForm<RoleFormValues>()
const [userSearchForm] = Form.useForm<UserSearchValues>()
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 [roleDrawerOpen, setRoleDrawerOpen] = useState(false)
const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false)
const [userDrawerOpen, setUserDrawerOpen] = useState(false)
const [savingRole, setSavingRole] = useState(false)
const [loadingUsers, setLoadingUsers] = useState(false)
const [savingPermission, setSavingPermission] = useState(false)
const [savingUsers, setSavingUsers] = 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 [menuHalfChecked, setMenuHalfChecked] = useState<number[]>([])
const [defaultRouter, setDefaultRouter] = useState<string>()
const [apiChecked, setApiChecked] = useState<string[]>([])
const [userIds, setUserIds] = useState<number[]>([])
const [savingPermission, setSavingPermission] = useState(false)
const [menuFilter, setMenuFilter] = useState('')
const [apiNameFilter, setApiNameFilter] = useState('')
const [apiPathFilter, setApiPathFilter] = useState('')
const [users, setUsers] = useState<UserInfo[]>([])
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set())
const [userPage, setUserPage] = useState(1)
const [userPageSize, setUserPageSize] = useState(10)
const [userTotal, setUserTotal] = useState(0)
const [buttonModalOpen, setButtonModalOpen] = useState(false)
const [buttonMenu, setButtonMenu] = useState<MenuNode | null>(null)
const [selectedButtonIds, setSelectedButtonIds] = useState<number[]>([])
const [savingButtons, setSavingButtons] = useState(false)
const roleOptions = useMemo(
() =>
@@ -79,6 +172,29 @@ export function RoleManagementPage() {
[roles],
)
const defaultRouterOptions = useMemo(
() =>
collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
.filter((menu) => menu.name && !menu.name.startsWith('http://') && !menu.name.startsWith('https://'))
.filter((menu) => !(menu.children || []).length)
.map((menu) => ({
label: menu.meta.title,
value: menu.name,
})),
[menuChecked, menuHalfChecked, menuTree],
)
useEffect(() => {
if (!permissionDrawerOpen || !defaultRouter) {
return
}
if (defaultRouterOptions.some((item) => item.value === defaultRouter)) {
return
}
setDefaultRouter(undefined)
message.warning('当前默认首页已不在授权菜单内,已自动清空,请重新选择')
}, [defaultRouter, defaultRouterOptions, permissionDrawerOpen])
const reloadRoles = async () => {
setLoading(true)
try {
@@ -90,15 +206,77 @@ export function RoleManagementPage() {
}
useEffect(() => {
reloadRoles()
void reloadRoles()
}, [])
const openCreate = (parentId?: number) => {
const loadUserList = useCallback(async (roleId: number) => {
setLoadingUsers(true)
try {
const [userRes, roleUserRes] = await Promise.all([
userApi.getUserList({
page: userPage,
pageSize: userPageSize,
username: userSearchForm.getFieldValue('username') || '',
nickName: userSearchForm.getFieldValue('nickName') || '',
orderKey: 'id',
desc: true,
}),
authorityApi.getUsersByAuthorityId(roleId),
])
setUsers(userRes.data.list)
setUserTotal(userRes.data.total)
setSelectedUserIds(new Set(roleUserRes.data))
} finally {
setLoadingUsers(false)
}
}, [userPage, userPageSize, userSearchForm])
const openButtonModal = useCallback(async (menu: MenuNode) => {
if (!activeRole) {
return
}
const response = await authorityBtnApi.getAuthorityBtn({
menuID: menu.ID,
authorityId: activeRole.authorityId,
})
setButtonMenu(menu)
setSelectedButtonIds(response.data.selected)
setButtonModalOpen(true)
}, [activeRole])
const filteredMenus = useMemo(() => filterMenuTree(menuTree, menuFilter), [menuFilter, menuTree])
const menuTreeData = useMemo(
() =>
buildMenuTree(
filteredMenus,
defaultRouter,
(menu) => void openButtonModal(menu),
(menu) => {
const checkedSet = new Set([...menuChecked, ...menuHalfChecked])
if (!checkedSet.has(menu.ID)) {
message.warning('请先勾选菜单,再将其设为首页')
return
}
setDefaultRouter(menu.name)
},
),
[defaultRouter, filteredMenus, menuChecked, menuHalfChecked, openButtonModal],
)
const apiTreeData = useMemo(() => buildApiTree(apis, apiNameFilter, apiPathFilter), [apiNameFilter, apiPathFilter, apis])
useEffect(() => {
if (userDrawerOpen && activeRole) {
void loadUserList(activeRole.authorityId)
}
}, [activeRole, loadUserList, userDrawerOpen])
const openCreate = (parentId = 0) => {
setEditingRole(null)
setCopySource(null)
form.resetFields()
form.setFieldsValue({ parentId: parentId ?? 0 })
setRoleModalOpen(true)
form.setFieldsValue({ parentId, authorityId: undefined as never, authorityName: '' })
setRoleDrawerOpen(true)
}
const openEdit = (record: Authority) => {
@@ -109,18 +287,18 @@ export function RoleManagementPage() {
authorityName: record.authorityName,
parentId: record.parentId ?? 0,
})
setRoleModalOpen(true)
setRoleDrawerOpen(true)
}
const openCopy = (record: Authority) => {
setEditingRole(null)
setCopySource(record)
form.setFieldsValue({
authorityId: undefined,
authorityId: undefined as never,
authorityName: `${record.authorityName}-副本`,
parentId: record.parentId ?? 0,
})
setRoleModalOpen(true)
setRoleDrawerOpen(true)
}
const saveRole = async () => {
@@ -131,7 +309,7 @@ export function RoleManagementPage() {
await authorityApi.copyAuthority({
oldAuthorityId: copySource.authorityId,
authority: {
authorityId: values.authorityId,
authorityId: Number(values.authorityId),
authorityName: values.authorityName,
parentId: values.parentId,
},
@@ -147,15 +325,15 @@ export function RoleManagementPage() {
message.success('角色已更新')
} else {
await authorityApi.createAuthority({
authorityId: values.authorityId,
authorityId: Number(values.authorityId),
authorityName: values.authorityName,
parentId: values.parentId,
})
message.success('角色已创建')
}
setRoleModalOpen(false)
reloadRoles()
setRoleDrawerOpen(false)
await reloadRoles()
} finally {
setSavingRole(false)
}
@@ -164,37 +342,49 @@ export function RoleManagementPage() {
const deleteRole = (record: Authority) => {
Modal.confirm({
title: `删除角色 ${record.authorityName}`,
content: '删除前请确认没有用户正在依赖该角色。',
content: '删除前请确认没有用户仍依赖当前角色。',
okButtonProps: { danger: true },
onOk: async () => {
await authorityApi.deleteAuthority(record.authorityId)
message.success('角色已删除')
reloadRoles()
await reloadRoles()
},
})
}
const openPermissionDrawer = async (record: Authority) => {
setActiveRole(record)
setDrawerOpen(true)
const [menuRes, checkedMenusRes, apiRes, policyRes, userRes, roleUserRes] = await Promise.all([
setPermissionDrawerOpen(true)
setActiveTab('menus')
setMenuFilter('')
setApiNameFilter('')
setApiPathFilter('')
const [menuRes, checkedMenusRes, apiRes, policyRes] = 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)
setMenuTree(filterRemovedMenus(menuRes.data.menus))
setMenuChecked(
checkedMenusRes.data.menus.map((item) => Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID)),
checkedMenusRes.data.menus.map((item) =>
Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID),
),
)
setMenuHalfChecked([])
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 openUserDrawer = async (record: Authority) => {
setActiveRole(record)
setUserDrawerOpen(true)
userSearchForm.setFieldsValue({ username: '', nickName: '' })
setUserPage(1)
setUserPageSize(10)
await loadUserList(record.authorityId)
}
const saveCurrentPermission = async () => {
@@ -204,7 +394,7 @@ export function RoleManagementPage() {
setSavingPermission(true)
try {
if (activeTab === 'menus') {
const checkedMenus = collectCheckedLeafMenus(menuTree, new Set(menuChecked))
const checkedMenus = collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
await menuApi.addMenuAuthority({
authorityId: activeRole.authorityId,
menus: checkedMenus,
@@ -216,7 +406,7 @@ export function RoleManagementPage() {
defaultRouter,
})
message.success('菜单权限已保存')
} else if (activeTab === 'apis') {
} else {
const selectedApis = apis
.filter((item) => apiChecked.includes(`${item.path}::${item.method}`))
.map((item) => ({ path: item.path, method: item.method }))
@@ -225,59 +415,82 @@ export function RoleManagementPage() {
casbinInfos: selectedApis,
})
message.success('API 权限已保存')
} else {
await authorityApi.setRoleUsers({
authorityId: activeRole.authorityId,
userIds,
})
message.success('角色用户关系已保存')
}
reloadRoles()
await reloadRoles()
} finally {
setSavingPermission(false)
}
}
const menuOptions = useMemo(
() =>
collectCheckedLeafMenus(menuTree, new Set(menuChecked)).map((menu) => ({
label: menu.meta.title,
value: menu.name,
})),
[menuChecked, menuTree],
)
const saveRoleUsers = async () => {
if (!activeRole) {
return
}
setSavingUsers(true)
try {
await authorityApi.setRoleUsers({
authorityId: activeRole.authorityId,
userIds: Array.from(selectedUserIds),
})
message.success('角色用户关系已保存')
setUserDrawerOpen(false)
} finally {
setSavingUsers(false)
}
}
const saveButtons = async () => {
if (!activeRole || !buttonMenu) {
return
}
setSavingButtons(true)
try {
await authorityBtnApi.setAuthorityBtn({
menuID: buttonMenu.ID,
authorityId: activeRole.authorityId,
selected: selectedButtonIds,
})
message.success('按钮权限已保存')
setButtonModalOpen(false)
} finally {
setSavingButtons(false)
}
}
const columns: ColumnsType<Authority> = [
{
title: '角色 ID',
dataIndex: 'authorityId',
width: 120,
width: 140,
},
{
title: '角色名称',
dataIndex: 'authorityName',
width: 200,
width: 220,
},
{
title: '默认首页',
dataIndex: 'defaultRouter',
width: 140,
width: 180,
render: (value: string | undefined) => value || '-',
},
{
title: '操作',
key: 'actions',
width: 320,
width: 420,
render: (_, record) => (
<Space wrap>
<Button type="link" onClick={() => openPermissionDrawer(record)}>
</Button>
<Button type="link" onClick={() => openUserDrawer(record)}>
</Button>
<Button type="link" onClick={() => openCreate(record.authorityId)}>
</Button>
<Button type="link" onClick={() => openCopy(record)}>
</Button>
<Button type="link" onClick={() => openEdit(record)}>
@@ -293,13 +506,15 @@ export function RoleManagementPage() {
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert type="info" showIcon message="右上角头像支持切换角色。角色页同时负责菜单授权、API 授权和角色用户绑定。" />
<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()}>
@@ -314,33 +529,51 @@ export function RoleManagementPage() {
expandable={{ defaultExpandAllRows: true }}
pagination={false}
/>
</Space>
</Card>
<Modal
open={roleModalOpen}
title={copySource ? `复制角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新角色'}
onCancel={() => setRoleModalOpen(false)}
onOk={saveRole}
confirmLoading={savingRole}
<Drawer
open={roleDrawerOpen}
title={copySource ? `拷贝角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新角色'}
onClose={() => setRoleDrawerOpen(false)}
width={520}
extra={
<Space>
<Button onClick={() => setRoleDrawerOpen(false)}></Button>
<Button type="primary" loading={savingRole} onClick={saveRole}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="parentId" label="父角色">
<Form.Item name="parentId" label="父角色" rules={[{ required: true, message: '请选择父级角色' }]}>
<Select options={[{ label: '根角色', value: 0 }, ...roleOptions]} />
</Form.Item>
<Form.Item name="authorityId" label="角色 ID" rules={[{ required: true, message: '请输入角色 ID' }]}>
<Input />
<Form.Item
name="authorityId"
label="角色 ID"
rules={[
{ required: true, message: '请输入角色 ID' },
{
validator: (_, value: number) =>
String(value || '').match(/^[1-9]\d*$/) ? Promise.resolve() : Promise.reject(new Error('请输入正整数')),
},
]}
>
<Input disabled={Boolean(editingRole)} />
</Form.Item>
<Form.Item name="authorityName" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</Drawer>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={900}
title={activeRole ? `角色权限 · ${activeRole.authorityName}` : '角色权限'}
open={permissionDrawerOpen}
onClose={() => setPermissionDrawerOpen(false)}
width={960}
title={activeRole ? `角色配置 · ${activeRole.authorityName}` : '角色配置'}
extra={
<Button type="primary" loading={savingPermission} onClick={saveCurrentPermission}>
@@ -348,50 +581,199 @@ export function RoleManagementPage() {
}
>
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as PermissionTab)}>
<Tabs.TabPane tab="菜单权限" key="menus">
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Tabs.TabPane tab="角色菜单" key="menus">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input placeholder="筛选菜单" value={menuFilter} onChange={(event) => setMenuFilter(event.target.value)} />
<Select
value={defaultRouter}
options={menuOptions}
allowClear
placeholder="请选择默认首页"
style={{ minWidth: 260 }}
options={defaultRouterOptions}
onChange={(value) => setDefaultRouter(value)}
/>
</div>
<Alert
type="info"
showIcon
message="默认首页必须来自已勾选菜单。菜单节点上的“分配按钮”用于维护当前角色的按钮权限。"
/>
<Tree
checkable
defaultExpandAll
checkedKeys={menuChecked}
treeData={mapMenusToTree(menuTree)}
onCheck={(checkedKeys) => setMenuChecked((checkedKeys as number[]).map((item) => Number(item)))}
treeData={menuTreeData}
onCheck={(checkedKeys, info) => {
if (Array.isArray(checkedKeys)) {
setMenuChecked(checkedKeys.map((item) => Number(item)))
setMenuHalfChecked(((info as { halfCheckedKeys?: Array<string | number> }).halfCheckedKeys || []).map((item) => Number(item)))
return
}
setMenuChecked(checkedKeys.checked.map((item) => Number(item)))
setMenuHalfChecked(checkedKeys.halfChecked.map((item) => Number(item)))
}}
/>
</Space>
</Tabs.TabPane>
<Tabs.TabPane tab="API 权限" key="apis">
<Tabs.TabPane tab="角色 API" key="apis">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'flex', gap: 12 }}>
<Input placeholder="筛选描述" value={apiNameFilter} onChange={(event) => setApiNameFilter(event.target.value)} />
<Input placeholder="筛选路径" value={apiPathFilter} onChange={(event) => setApiPathFilter(event.target.value)} />
</div>
<Tree
checkable
defaultExpandAll
checkedKeys={apiChecked}
treeData={mapApisToTree(apis)}
onCheck={(checkedKeys) => setApiChecked((checkedKeys as string[]).filter((item) => !item.startsWith('group:')))}
treeData={apiTreeData}
onCheck={(checkedKeys) => {
if (Array.isArray(checkedKeys)) {
setApiChecked(checkedKeys.filter((item) => !String(item).startsWith('group:')).map(String))
return
}
setApiChecked(
checkedKeys.checked.filter((item) => !String(item).startsWith('group:')).map((item) => String(item)),
)
}}
/>
</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>
</Space>
</Tabs.TabPane>
</Tabs>
</Drawer>
<Drawer
open={userDrawerOpen}
onClose={() => setUserDrawerOpen(false)}
width={900}
title={activeRole ? `分配给用户 · ${activeRole.authorityName}` : '分配给用户'}
extra={
<Space>
<Button onClick={() => setUserDrawerOpen(false)}></Button>
<Button type="primary" loading={savingUsers} onClick={saveRoleUsers}>
</Button>
</Space>
}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="保存时会全量覆盖当前角色的用户关联关系。若用户仅剩这一个角色,被移除后其主角色不会自动调整。"
/>
<Form
form={userSearchForm}
layout="inline"
onFinish={() => {
setUserPage(1)
if (activeRole) {
void loadUserList(activeRole.authorityId)
}
}}
>
<Form.Item name="username" label="用户名">
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="nickName" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
userSearchForm.resetFields()
setUserPage(1)
if (activeRole) {
void loadUserList(activeRole.authorityId)
}
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="ID"
loading={loadingUsers}
dataSource={users}
pagination={{
current: userPage,
pageSize: userPageSize,
total: userTotal,
showSizeChanger: true,
onChange: (page, pageSize) => {
setUserPage(page)
setUserPageSize(pageSize)
},
}}
rowSelection={{
preserveSelectedRowKeys: true,
selectedRowKeys: Array.from(selectedUserIds),
onSelect: (record, selected) => {
setSelectedUserIds((previous) => {
const next = new Set(previous)
if (selected) {
next.add(record.ID)
} else {
next.delete(record.ID)
}
return next
})
},
onSelectAll: (selected, _, changeRows) => {
setSelectedUserIds((previous) => {
const next = new Set(previous)
changeRows.forEach((user) => {
if (selected) {
next.add(user.ID)
} else {
next.delete(user.ID)
}
})
return next
})
},
}}
columns={[
{ title: 'ID', dataIndex: 'ID', width: 80 },
{ title: '用户名', dataIndex: 'userName', width: 160 },
{ title: '昵称', dataIndex: 'nickName', width: 160 },
{
title: '主角色',
width: 160,
render: (_, user) => user.authority?.authorityName || '-',
},
]}
/>
</Space>
</Drawer>
<Modal
open={buttonModalOpen}
title={buttonMenu ? `分配按钮 · ${buttonMenu.meta.title}` : '分配按钮'}
onCancel={() => setButtonModalOpen(false)}
onOk={() => void saveButtons()}
confirmLoading={savingButtons}
>
<Table<MenuButton>
rowKey="ID"
pagination={false}
dataSource={buttonMenu?.menuBtn || []}
rowSelection={{
selectedRowKeys: selectedButtonIds,
onChange: (keys) => setSelectedButtonIds(keys.map((item) => Number(item))),
}}
columns={[
{ title: '按钮名称', dataIndex: 'name' },
{ title: '按钮备注', dataIndex: 'desc' },
]}
/>
</Modal>
</div>
)
}

View File

@@ -78,8 +78,9 @@ img {
background:
linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%);
color: var(--text-light);
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.admin-brand {
@@ -117,6 +118,8 @@ img {
}
.admin-nav-menu .ant-menu-sub.ant-menu-inline {
width: 100%;
box-sizing: border-box;
margin: 8px 0 12px;
padding: 8px;
border-radius: 20px;
@@ -124,6 +127,11 @@ img {
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-nav-menu .ant-menu-submenu,
.admin-nav-menu .ant-menu-submenu-inline {
width: 100%;
}
.admin-nav-menu.ant-menu-dark .ant-menu-item,
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-title,
.admin-nav-menu.ant-menu-dark .ant-menu-item a,
@@ -303,6 +311,12 @@ img {
padding: 28px;
}
.init-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.capsule {
display: inline-flex;
align-items: center;
@@ -319,6 +333,41 @@ img {
gap: 16px;
}
@media (max-width: 960px) {
.admin-shell,
.login-shell,
.hero-grid,
.metric-grid,
.config-form-grid,
.server-meter-grid,
.server-runtime-grid,
.server-disk-grid,
.catalog-grid,
.init-form-grid {
grid-template-columns: 1fr;
}
.admin-shell {
display: block;
}
.admin-sidebar {
position: relative;
width: 100%;
min-width: 0;
height: auto;
}
.login-hero {
min-height: 320px;
padding: 40px 24px;
}
.login-card {
padding: 20px;
}
}
.catalog-card {
border-radius: 22px;
padding: 18px;

View File

@@ -1,12 +1,16 @@
import { http } from './http'
import type {
ApiRecord,
ApiGroupsPayload,
ApiTokenRecord,
AttachmentCategory,
AuthorityButtonSelection,
Authority,
CaptchaInfo,
Dictionary,
DictionaryDetail,
InitCheckResult,
InitDBPayload,
LoginLog,
LoginResult,
McpContent,
@@ -19,6 +23,7 @@ import type {
OperationRecord,
PagePayload,
ServerState,
SyncApiPayload,
SysErrorRecord,
SysParam,
UserInfo,
@@ -48,6 +53,15 @@ export const authApi = {
},
}
export const initApi = {
checkDB() {
return http.post<InitCheckResult>('/init/checkdb')
},
initDB(payload: InitDBPayload) {
return http.post<Record<string, never>>('/init/initdb', payload)
},
}
export const menuApi = {
getMenu() {
return http.post<{ menus: MenuNode[] }>('/menu/getMenu')
@@ -167,12 +181,27 @@ export const apiRegistryApi = {
deleteApi(payload: { ID: number }) {
return http.post<Record<string, never>>('/api/deleteApi', payload)
},
deleteApisByIds(ids: number[]) {
return http.delete<Record<string, never>>('/api/deleteApisByIds', { data: { ids } })
},
getApiById(id: number) {
return http.post<{ api: ApiRecord }>('/api/getApiById', { ID: id })
},
freshCasbin() {
return http.get<Record<string, never>>('/api/freshCasbin')
},
syncApi() {
return http.get<SyncApiPayload>('/api/syncApi')
},
getApiGroups() {
return http.get<ApiGroupsPayload>('/api/getApiGroups')
},
ignoreApi(payload: { path: string; method: string; flag: boolean }) {
return http.post<Record<string, never>>('/api/ignoreApi', payload)
},
enterSyncApi(payload: SyncApiPayload) {
return http.post<Record<string, never>>('/api/enterSyncApi', payload)
},
getApiRoles(path: string, method: string) {
return http.get<number[]>('/api/getApiRoles', { params: { path, method } })
},
@@ -181,6 +210,20 @@ export const apiRegistryApi = {
},
}
export const authorityBtnApi = {
getAuthorityBtn(payload: { menuID: number; authorityId: number }) {
return http.post<AuthorityButtonSelection>('/authorityBtn/getAuthorityBtn', payload)
},
setAuthorityBtn(payload: { menuID: number; authorityId: number; selected: number[] }) {
return http.post<Record<string, never>>('/authorityBtn/setAuthorityBtn', payload)
},
canRemoveAuthorityBtn(id: number) {
return http.post<Record<string, never>>('/authorityBtn/canRemoveAuthorityBtn', undefined, {
params: { id },
})
},
}
export const dictionaryApi = {
getDictionaryList(params?: Record<string, unknown>) {
return http.get<Dictionary[]>('/sysDictionary/getSysDictionaryList', { params })

View File

@@ -1,5 +1,7 @@
import type { AppMenu, MenuNode } from '@/types/system'
const removedFrontendMenuNames = new Set(['sysVersion', 'exportTemplate', 'formCreate'])
export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
return (
menu.path.startsWith('http://') ||
@@ -9,6 +11,17 @@ export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
)
}
export function filterRemovedMenus<T extends MenuNode>(menus: T[]): T[] {
return menus.flatMap((menu) => {
if (removedFrontendMenuNames.has(menu.name)) {
return []
}
const children = filterRemovedMenus((menu.children || []) as T[])
return [{ ...menu, children } as T]
})
}
function normalizePathSegment(path: string) {
if (!path) {
return ''
@@ -22,7 +35,7 @@ export function buildFullMenus(
parentPath = '',
parentName?: string,
): AppMenu[] {
return [...menus]
return [...filterRemovedMenus(menus)]
.sort((left, right) => left.sort - right.sort)
.map((menu) => {
const normalized = normalizePathSegment(menu.path)

View File

@@ -24,3 +24,13 @@ export function collectCheckedLeafMenus(menus: MenuNode[], checkedKeys: Set<numb
return collectCheckedLeafMenus(children, checkedKeys)
})
}
export function collectMenusByIds(menus: MenuNode[], checkedKeys: Set<number>): MenuNode[] {
return menus.flatMap((menu) => {
const matchedChildren = collectMenusByIds(menu.children || [], checkedKeys)
if (checkedKeys.has(menu.ID)) {
return [{ ...menu, children: undefined }, ...matchedChildren]
}
return matchedChildren
})
}

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'sysVersion',
}
export default createModulePage('sysVersion')

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'exportTemplate',
}
export default createModulePage('exportTemplate')

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'formCreate',
}
export default createModulePage('formCreate')

View File

@@ -42,13 +42,24 @@ export type UserInfo = BaseEntity & {
export type MenuMeta = {
title: string
icon?: string
activeName?: string
keepAlive?: boolean
closeTab?: boolean
defaultMenu?: boolean
activeName?: string
transitionType?: string
}
export type MenuParameter = BaseEntity & {
type: 'query' | 'params'
key: string
value: string
}
export type MenuButton = BaseEntity & {
name: string
desc?: string
}
export type MenuNode = BaseEntity & {
parentId: number
path: string
@@ -57,7 +68,8 @@ export type MenuNode = BaseEntity & {
component: string
sort: number
meta: MenuMeta
menuBtn?: Array<{ ID: number; name: string; desc?: string }>
parameters?: MenuParameter[]
menuBtn?: MenuButton[]
btns?: Record<string, number>
children?: MenuNode[]
}
@@ -82,6 +94,22 @@ export type CaptchaInfo = {
openCaptcha: boolean
}
export type InitCheckResult = {
needInit: boolean
}
export type InitDBPayload = {
adminPassword: string
dbType: 'mysql' | 'pgsql' | 'sqlite' | 'mssql'
host?: string
port?: string
userName?: string
password?: string
dbName: string
dbPath?: string
template?: string
}
export type ApiRecord = BaseEntity & {
path: string
description: string
@@ -89,6 +117,21 @@ export type ApiRecord = BaseEntity & {
method: string
}
export type SyncApiPayload = {
newApis: ApiRecord[]
deleteApis: ApiRecord[]
ignoreApis: ApiRecord[]
}
export type ApiGroupsPayload = {
groups: string[]
apiGroupMap: Record<string, string>
}
export type AuthorityButtonSelection = {
selected: number[]
}
export type Dictionary = BaseEntity & {
name: string
type: string