Compare commits

..
Author SHA1 Message Date
pixelmaxQM 98ccdc6295 feat: 更新版本号至2.9.0 2026-03-09 03:47:30 +01:00
PiexlMax(奇淼 d143b326b4 v2.9.0Beta版本发布
- 优化了AI规则,更利于大家规范化使用VibeCoding
- skills增加了在线板块,可以直接下载在线的skills。
- skill增加了一键打包能力
- 角色页面可以把角色可以直接分配给用户
- 菜单页面可以直接把菜单分配给角色
- API页面可以直接把API分配给角色
- 升级了AWS S3的版本
- 修复了富文本组件样式缺失的bug
- dashboard页面样式调整
- 优化了插件市场的UI,增加了分享和点赞能力
- 优化了vite环境变量的使用规则
2026-03-06 18:14:46 +08:00
50 changed files with 2828 additions and 275 deletions
+129
View File
@@ -194,6 +194,8 @@ web/
- 结构体应继承 `global.GVA_MODEL` 以包含 `ID`, `CreatedAt`, `UpdatedAt` 等基础字段。
- 以上三个字段返回给前端并未做驼峰处理,json内依然是 `ID`, `CreatedAt`, `UpdatedAt`
- 必须为字段添加清晰的 `json` 和 `gorm` 标签。
- **⚠️ 重要提醒:数据类型一致性**
@@ -495,6 +497,9 @@ API函数的Swagger注释不仅用于生成API文档,也是前端开发的重
- **必须**进行响应式数据管理
- **必须**处理加载状态和错误状态
- **必须**遵循Element Plus组件规范
- **必须**优先使用UnoCSS原子化类名进行样式设计
- **必须**优先el-drawer组件进行编辑,新增,步骤等操作
- **必须**使用el-drawer和el-dialog组件是后一定携带,destroy-on-close属性,确保组件销毁,避免内存泄漏和状态污染
#### **4. 状态管理 (`src/pinia/`)**
@@ -622,6 +627,130 @@ src/plugin/[插件名]/
---
## **⚠️ 前端工具库使用规范(强制)**
> **核心原则:在开发任何前端功能时,必须优先检查并使用 `src/utils/` 目录下已封装好的工具函数,严禁重复造轮子。**
`src/utils/` 目录提供了项目级别的通用工具集,涵盖 HTTP 请求、日期处理、格式转换、字符串操作、图片处理等多个方面。以下是各工具文件的功能说明:
### **工具文件清单**
#### `request.js` — HTTP 请求封装(核心)
- 基于 Axios 封装的统一 HTTP 请求实例,内置全局 Loading 状态管理、JWT Token 自动注入、统一错误处理和响应拦截
- **所有 API 请求必须且只能通过此模块发送,禁止直接使用 axios**
- 用法:`import service from '@/utils/request'`
#### `date.js` — 日期格式化
- 扩展了 `Date.prototype.Format` 方法,支持自定义格式如 `yyyy-MM-dd hh:mm:ss`
- 导出 `formatTimeToStr(times, pattern)` 将时间戳或日期对象格式化为字符串
- **需要格式化日期时,优先使用此工具,禁止自行手写日期格式化逻辑**
- 用法:`import { formatTimeToStr } from '@/utils/date'`
#### `format.js` — 数据展示格式化(综合工具)
- `formatBoolean(bool)` — 将布尔值转为 "是"/"否" 中文展示
- `formatDate(time)` — 将时间转为 `yyyy-MM-dd hh:mm:ss` 格式字符串
- `filterDict(value, options)` — 在字典选项数组(支持多级树形)中根据 value 查找对应的 label
- `filterDataSource(dataSource, value)` — 在数据源(支持多级树形)中根据 value 查找 label,支持数组批量查找
- `getDictFunc(type)` — 异步获取指定类型的字典数据
- `ReturnArrImg(arr)` — 将图片路径(单个或数组)转为完整 URL,自动补全服务器前缀
- `onDownloadFile(url)` — 触发文件下载
- `setBodyPrimaryColor(primaryColor, darkMode)` — 动态设置主题色相关的 CSS 变量(支持亮/暗模式)
- `CreateUUID()` — 生成 UUID v4 字符串
- `getBaseUrl()` — 获取当前环境的 API BaseURL
- **以上所有格式化场景优先使用此文件中的工具函数**
- 用法:`import { formatBoolean, formatDate, filterDict, CreateUUID, ... } from '@/utils/format'`
#### `dictionary.js` — 字典数据获取
- `getDict(type, options)` — 异步获取字典数据,支持 `depth`(深度)和 `value`(指定节点)参数,内置 Pinia store 缓存,避免重复请求
- **凡是需要字典下拉数据、字典树形数据的场景,必须使用此工具**
- 用法:`import { getDict } from '@/utils/dictionary'`
#### `stringFun.js` — 字符串处理
- `toUpperCase(str)` — 首字母转大写
- `toLowerCase(str)` — 首字母转小写
- `toSQLLine(str)` — 驼峰命名转下划线(snake_case),如 `userName` → `user_name`
- `toHump(name)` — 下划线命名转驼峰,如 `user_name` → `userName`
- **进行命名格式转换时必须使用此工具,禁止使用正则手写**
- 用法:`import { toUpperCase, toSQLLine, toHump } from '@/utils/stringFun'`
#### `params.js` — 系统参数获取
- `getParams(key)` — 异步从 Pinia store 中获取系统参数,内置缓存
- **获取系统配置参数时,优先使用此工具**
- 用法:`import { getParams } from '@/utils/params'`
#### `bus.js` — 全局事件总线
- 基于 `mitt` 封装的全局事件总线实例 `emitter`,用于跨组件通信
- **跨层级组件通信优先使用此事件总线,避免滥用 Pinia**
- 用法:`import { emitter } from '@/utils/bus'`
#### `closeThisPage.js` — 关闭当前标签页
- `closeThisPage()` — 触发关闭当前多标签页的操作(通过事件总线发送 `closeThisPage` 事件)
- **在需要程序化关闭当前页面时,必须使用此工具**
- 用法:`import { closeThisPage } from '@/utils/closeThisPage'`
#### `downloadImg.js` — 图片下载
- `downloadImage(imgsrc, name)` — 通过 Canvas 将图片转为 base64 后触发下载,支持跨域
- **需要下载图片时,优先使用此工具**
- 用法:`import { downloadImage } from '@/utils/downloadImg'`
#### `image.js` — 图片压缩
- 导出 `ImageCompress` 类,支持图片等比压缩至指定最大宽高,并可限制文件大小
- **上传图片前需要做压缩处理时,使用此工具**
- 用法:`import ImageCompress from '@/utils/image'`
#### `event.js` — DOM 事件监听管理
- `addEventListen(target, event, handler, capture)` — 安全地添加 DOM 事件监听
- `removeEventListen(target, event, handler, capture)` — 安全地移除 DOM 事件监听
- **手动操作 DOM 事件时,使用此工具以确保安全性**
- 用法:`import { addEventListen, removeEventListen } from '@/utils/event'`
#### `env.js` — 环境判断
- `isDev` — 是否为开发环境(Boolean)
- `isProd` — 是否为生产环境(Boolean)
- **需要区分运行环境时,使用此工具,禁止直接读取 `import.meta.env`**
- 用法:`import { isDev, isProd } from '@/utils/env'`
#### `doc.js` — 外部文档跳转
- `toDoc(url)` — 在新标签页打开指定 URL
- 用法:`import { toDoc } from '@/utils/doc'`
#### `fmtRouterTitle.js` — 路由标题格式化
- `fmtTitle(title, route)` — 解析路由标题中的动态参数插值(如 `${id}` 替换为路由 params/query 值)
- 用法:`import { fmtTitle } from '@/utils/fmtRouterTitle'`
#### `page.js` — 页面标题生成
- `getPageTitle(pageTitle, route)` — 根据页面标题和路由生成完整的浏览器 Tab 标题(格式:`页面名 - 应用名`)
- 用法:`import getPageTitle from '@/utils/page'`
#### `asyncRouter.js` — 异步路由处理
- `asyncRouterHandle(asyncRouter)` — 将后端返回的路由配置(字符串 component 路径)动态转换为 Vue 组件的 import 函数,支持 `view/` 和 `plugin/` 目录
- **动态路由相关逻辑已由此工具处理,不需要也不应该手动实现**
- 用法:`import { asyncRouterHandle } from '@/utils/asyncRouter'`
#### `btnAuth.js` — 按钮权限
- `useBtnAuth()` — Composition API Hook,返回当前路由挂载的按钮权限对象(来自 `route.meta.btns`),用于控制操作按钮的显示
- **实现按钮级别权限控制时,必须使用此 Hook**
- 用法:`import { useBtnAuth } from '@/utils/btnAuth'`
### **使用强制要求**
| 场景 | 必须使用的工具 |
|------|----------------|
| 发送 HTTP 请求 | `@/utils/request` |
| 格式化日期时间 | `@/utils/date` 或 `@/utils/format` 中的 `formatDate` |
| 获取字典数据 | `@/utils/dictionary` 中的 `getDict` |
| 布尔值/字典值展示转换 | `@/utils/format` 中的 `formatBoolean` / `filterDict` |
| 生成 UUID | `@/utils/format` 中的 `CreateUUID` |
| 驼峰/下划线命名转换 | `@/utils/stringFun` |
| 获取系统参数 | `@/utils/params` 中的 `getParams` |
| 按钮权限判断 | `@/utils/btnAuth` 中的 `useBtnAuth` |
| 跨组件事件通信 | `@/utils/bus` 中的 `emitter` |
| 图片下载 | `@/utils/downloadImg` 中的 `downloadImage` |
| 图片上传压缩 | `@/utils/image` 中的 `ImageCompress` |
| 关闭当前 Tab 页 | `@/utils/closeThisPage` 中的 `closeThisPage` |
---
## **前后端协作规范**
### **接口协作规范**
+129
View File
@@ -194,6 +194,8 @@ web/
- 结构体应继承 `global.GVA_MODEL` 以包含 `ID`, `CreatedAt`, `UpdatedAt` 等基础字段。
- 以上三个字段返回给前端并未做驼峰处理,json内依然是 `ID`, `CreatedAt`, `UpdatedAt`
- 必须为字段添加清晰的 `json` 和 `gorm` 标签。
- **⚠️ 重要提醒:数据类型一致性**
@@ -495,6 +497,9 @@ API函数的Swagger注释不仅用于生成API文档,也是前端开发的重
- **必须**进行响应式数据管理
- **必须**处理加载状态和错误状态
- **必须**遵循Element Plus组件规范
- **必须**优先使用UnoCSS原子化类名进行样式设计
- **必须**优先el-drawer组件进行编辑,新增,步骤等操作
- **必须**使用el-drawer和el-dialog组件是后一定携带,destroy-on-close属性,确保组件销毁,避免内存泄漏和状态污染
#### **4. 状态管理 (`src/pinia/`)**
@@ -622,6 +627,130 @@ src/plugin/[插件名]/
---
## **⚠️ 前端工具库使用规范(强制)**
> **核心原则:在开发任何前端功能时,必须优先检查并使用 `src/utils/` 目录下已封装好的工具函数,严禁重复造轮子。**
`src/utils/` 目录提供了项目级别的通用工具集,涵盖 HTTP 请求、日期处理、格式转换、字符串操作、图片处理等多个方面。以下是各工具文件的功能说明:
### **工具文件清单**
#### `request.js` — HTTP 请求封装(核心)
- 基于 Axios 封装的统一 HTTP 请求实例,内置全局 Loading 状态管理、JWT Token 自动注入、统一错误处理和响应拦截
- **所有 API 请求必须且只能通过此模块发送,禁止直接使用 axios**
- 用法:`import service from '@/utils/request'`
#### `date.js` — 日期格式化
- 扩展了 `Date.prototype.Format` 方法,支持自定义格式如 `yyyy-MM-dd hh:mm:ss`
- 导出 `formatTimeToStr(times, pattern)` 将时间戳或日期对象格式化为字符串
- **需要格式化日期时,优先使用此工具,禁止自行手写日期格式化逻辑**
- 用法:`import { formatTimeToStr } from '@/utils/date'`
#### `format.js` — 数据展示格式化(综合工具)
- `formatBoolean(bool)` — 将布尔值转为 "是"/"否" 中文展示
- `formatDate(time)` — 将时间转为 `yyyy-MM-dd hh:mm:ss` 格式字符串
- `filterDict(value, options)` — 在字典选项数组(支持多级树形)中根据 value 查找对应的 label
- `filterDataSource(dataSource, value)` — 在数据源(支持多级树形)中根据 value 查找 label,支持数组批量查找
- `getDictFunc(type)` — 异步获取指定类型的字典数据
- `ReturnArrImg(arr)` — 将图片路径(单个或数组)转为完整 URL,自动补全服务器前缀
- `onDownloadFile(url)` — 触发文件下载
- `setBodyPrimaryColor(primaryColor, darkMode)` — 动态设置主题色相关的 CSS 变量(支持亮/暗模式)
- `CreateUUID()` — 生成 UUID v4 字符串
- `getBaseUrl()` — 获取当前环境的 API BaseURL
- **以上所有格式化场景优先使用此文件中的工具函数**
- 用法:`import { formatBoolean, formatDate, filterDict, CreateUUID, ... } from '@/utils/format'`
#### `dictionary.js` — 字典数据获取
- `getDict(type, options)` — 异步获取字典数据,支持 `depth`(深度)和 `value`(指定节点)参数,内置 Pinia store 缓存,避免重复请求
- **凡是需要字典下拉数据、字典树形数据的场景,必须使用此工具**
- 用法:`import { getDict } from '@/utils/dictionary'`
#### `stringFun.js` — 字符串处理
- `toUpperCase(str)` — 首字母转大写
- `toLowerCase(str)` — 首字母转小写
- `toSQLLine(str)` — 驼峰命名转下划线(snake_case),如 `userName` → `user_name`
- `toHump(name)` — 下划线命名转驼峰,如 `user_name` → `userName`
- **进行命名格式转换时必须使用此工具,禁止使用正则手写**
- 用法:`import { toUpperCase, toSQLLine, toHump } from '@/utils/stringFun'`
#### `params.js` — 系统参数获取
- `getParams(key)` — 异步从 Pinia store 中获取系统参数,内置缓存
- **获取系统配置参数时,优先使用此工具**
- 用法:`import { getParams } from '@/utils/params'`
#### `bus.js` — 全局事件总线
- 基于 `mitt` 封装的全局事件总线实例 `emitter`,用于跨组件通信
- **跨层级组件通信优先使用此事件总线,避免滥用 Pinia**
- 用法:`import { emitter } from '@/utils/bus'`
#### `closeThisPage.js` — 关闭当前标签页
- `closeThisPage()` — 触发关闭当前多标签页的操作(通过事件总线发送 `closeThisPage` 事件)
- **在需要程序化关闭当前页面时,必须使用此工具**
- 用法:`import { closeThisPage } from '@/utils/closeThisPage'`
#### `downloadImg.js` — 图片下载
- `downloadImage(imgsrc, name)` — 通过 Canvas 将图片转为 base64 后触发下载,支持跨域
- **需要下载图片时,优先使用此工具**
- 用法:`import { downloadImage } from '@/utils/downloadImg'`
#### `image.js` — 图片压缩
- 导出 `ImageCompress` 类,支持图片等比压缩至指定最大宽高,并可限制文件大小
- **上传图片前需要做压缩处理时,使用此工具**
- 用法:`import ImageCompress from '@/utils/image'`
#### `event.js` — DOM 事件监听管理
- `addEventListen(target, event, handler, capture)` — 安全地添加 DOM 事件监听
- `removeEventListen(target, event, handler, capture)` — 安全地移除 DOM 事件监听
- **手动操作 DOM 事件时,使用此工具以确保安全性**
- 用法:`import { addEventListen, removeEventListen } from '@/utils/event'`
#### `env.js` — 环境判断
- `isDev` — 是否为开发环境(Boolean)
- `isProd` — 是否为生产环境(Boolean)
- **需要区分运行环境时,使用此工具,禁止直接读取 `import.meta.env`**
- 用法:`import { isDev, isProd } from '@/utils/env'`
#### `doc.js` — 外部文档跳转
- `toDoc(url)` — 在新标签页打开指定 URL
- 用法:`import { toDoc } from '@/utils/doc'`
#### `fmtRouterTitle.js` — 路由标题格式化
- `fmtTitle(title, route)` — 解析路由标题中的动态参数插值(如 `${id}` 替换为路由 params/query 值)
- 用法:`import { fmtTitle } from '@/utils/fmtRouterTitle'`
#### `page.js` — 页面标题生成
- `getPageTitle(pageTitle, route)` — 根据页面标题和路由生成完整的浏览器 Tab 标题(格式:`页面名 - 应用名`)
- 用法:`import getPageTitle from '@/utils/page'`
#### `asyncRouter.js` — 异步路由处理
- `asyncRouterHandle(asyncRouter)` — 将后端返回的路由配置(字符串 component 路径)动态转换为 Vue 组件的 import 函数,支持 `view/` 和 `plugin/` 目录
- **动态路由相关逻辑已由此工具处理,不需要也不应该手动实现**
- 用法:`import { asyncRouterHandle } from '@/utils/asyncRouter'`
#### `btnAuth.js` — 按钮权限
- `useBtnAuth()` — Composition API Hook,返回当前路由挂载的按钮权限对象(来自 `route.meta.btns`),用于控制操作按钮的显示
- **实现按钮级别权限控制时,必须使用此 Hook**
- 用法:`import { useBtnAuth } from '@/utils/btnAuth'`
### **使用强制要求**
| 场景 | 必须使用的工具 |
|------|----------------|
| 发送 HTTP 请求 | `@/utils/request` |
| 格式化日期时间 | `@/utils/date` 或 `@/utils/format` 中的 `formatDate` |
| 获取字典数据 | `@/utils/dictionary` 中的 `getDict` |
| 布尔值/字典值展示转换 | `@/utils/format` 中的 `formatBoolean` / `filterDict` |
| 生成 UUID | `@/utils/format` 中的 `CreateUUID` |
| 驼峰/下划线命名转换 | `@/utils/stringFun` |
| 获取系统参数 | `@/utils/params` 中的 `getParams` |
| 按钮权限判断 | `@/utils/btnAuth` 中的 `useBtnAuth` |
| 跨组件事件通信 | `@/utils/bus` 中的 `emitter` |
| 图片下载 | `@/utils/downloadImg` 中的 `downloadImage` |
| 图片上传压缩 | `@/utils/image` 中的 `ImageCompress` |
| 关闭当前 Tab 页 | `@/utils/closeThisPage` 中的 `closeThisPage` |
---
## **前后端协作规范**
### **接口协作规范**
+129
View File
@@ -194,6 +194,8 @@ web/
- 结构体应继承 `global.GVA_MODEL` 以包含 `ID`, `CreatedAt`, `UpdatedAt` 等基础字段。
- 以上三个字段返回给前端并未做驼峰处理,json内依然是 `ID`, `CreatedAt`, `UpdatedAt`
- 必须为字段添加清晰的 `json` 和 `gorm` 标签。
- **⚠️ 重要提醒:数据类型一致性**
@@ -495,6 +497,9 @@ API函数的Swagger注释不仅用于生成API文档,也是前端开发的重
- **必须**进行响应式数据管理
- **必须**处理加载状态和错误状态
- **必须**遵循Element Plus组件规范
- **必须**优先使用UnoCSS原子化类名进行样式设计
- **必须**优先el-drawer组件进行编辑,新增,步骤等操作
- **必须**使用el-drawer和el-dialog组件是后一定携带,destroy-on-close属性,确保组件销毁,避免内存泄漏和状态污染
#### **4. 状态管理 (`src/pinia/`)**
@@ -622,6 +627,130 @@ src/plugin/[插件名]/
---
## **⚠️ 前端工具库使用规范(强制)**
> **核心原则:在开发任何前端功能时,必须优先检查并使用 `src/utils/` 目录下已封装好的工具函数,严禁重复造轮子。**
`src/utils/` 目录提供了项目级别的通用工具集,涵盖 HTTP 请求、日期处理、格式转换、字符串操作、图片处理等多个方面。以下是各工具文件的功能说明:
### **工具文件清单**
#### `request.js` — HTTP 请求封装(核心)
- 基于 Axios 封装的统一 HTTP 请求实例,内置全局 Loading 状态管理、JWT Token 自动注入、统一错误处理和响应拦截
- **所有 API 请求必须且只能通过此模块发送,禁止直接使用 axios**
- 用法:`import service from '@/utils/request'`
#### `date.js` — 日期格式化
- 扩展了 `Date.prototype.Format` 方法,支持自定义格式如 `yyyy-MM-dd hh:mm:ss`
- 导出 `formatTimeToStr(times, pattern)` 将时间戳或日期对象格式化为字符串
- **需要格式化日期时,优先使用此工具,禁止自行手写日期格式化逻辑**
- 用法:`import { formatTimeToStr } from '@/utils/date'`
#### `format.js` — 数据展示格式化(综合工具)
- `formatBoolean(bool)` — 将布尔值转为 "是"/"否" 中文展示
- `formatDate(time)` — 将时间转为 `yyyy-MM-dd hh:mm:ss` 格式字符串
- `filterDict(value, options)` — 在字典选项数组(支持多级树形)中根据 value 查找对应的 label
- `filterDataSource(dataSource, value)` — 在数据源(支持多级树形)中根据 value 查找 label,支持数组批量查找
- `getDictFunc(type)` — 异步获取指定类型的字典数据
- `ReturnArrImg(arr)` — 将图片路径(单个或数组)转为完整 URL,自动补全服务器前缀
- `onDownloadFile(url)` — 触发文件下载
- `setBodyPrimaryColor(primaryColor, darkMode)` — 动态设置主题色相关的 CSS 变量(支持亮/暗模式)
- `CreateUUID()` — 生成 UUID v4 字符串
- `getBaseUrl()` — 获取当前环境的 API BaseURL
- **以上所有格式化场景优先使用此文件中的工具函数**
- 用法:`import { formatBoolean, formatDate, filterDict, CreateUUID, ... } from '@/utils/format'`
#### `dictionary.js` — 字典数据获取
- `getDict(type, options)` — 异步获取字典数据,支持 `depth`(深度)和 `value`(指定节点)参数,内置 Pinia store 缓存,避免重复请求
- **凡是需要字典下拉数据、字典树形数据的场景,必须使用此工具**
- 用法:`import { getDict } from '@/utils/dictionary'`
#### `stringFun.js` — 字符串处理
- `toUpperCase(str)` — 首字母转大写
- `toLowerCase(str)` — 首字母转小写
- `toSQLLine(str)` — 驼峰命名转下划线(snake_case),如 `userName` → `user_name`
- `toHump(name)` — 下划线命名转驼峰,如 `user_name` → `userName`
- **进行命名格式转换时必须使用此工具,禁止使用正则手写**
- 用法:`import { toUpperCase, toSQLLine, toHump } from '@/utils/stringFun'`
#### `params.js` — 系统参数获取
- `getParams(key)` — 异步从 Pinia store 中获取系统参数,内置缓存
- **获取系统配置参数时,优先使用此工具**
- 用法:`import { getParams } from '@/utils/params'`
#### `bus.js` — 全局事件总线
- 基于 `mitt` 封装的全局事件总线实例 `emitter`,用于跨组件通信
- **跨层级组件通信优先使用此事件总线,避免滥用 Pinia**
- 用法:`import { emitter } from '@/utils/bus'`
#### `closeThisPage.js` — 关闭当前标签页
- `closeThisPage()` — 触发关闭当前多标签页的操作(通过事件总线发送 `closeThisPage` 事件)
- **在需要程序化关闭当前页面时,必须使用此工具**
- 用法:`import { closeThisPage } from '@/utils/closeThisPage'`
#### `downloadImg.js` — 图片下载
- `downloadImage(imgsrc, name)` — 通过 Canvas 将图片转为 base64 后触发下载,支持跨域
- **需要下载图片时,优先使用此工具**
- 用法:`import { downloadImage } from '@/utils/downloadImg'`
#### `image.js` — 图片压缩
- 导出 `ImageCompress` 类,支持图片等比压缩至指定最大宽高,并可限制文件大小
- **上传图片前需要做压缩处理时,使用此工具**
- 用法:`import ImageCompress from '@/utils/image'`
#### `event.js` — DOM 事件监听管理
- `addEventListen(target, event, handler, capture)` — 安全地添加 DOM 事件监听
- `removeEventListen(target, event, handler, capture)` — 安全地移除 DOM 事件监听
- **手动操作 DOM 事件时,使用此工具以确保安全性**
- 用法:`import { addEventListen, removeEventListen } from '@/utils/event'`
#### `env.js` — 环境判断
- `isDev` — 是否为开发环境(Boolean)
- `isProd` — 是否为生产环境(Boolean)
- **需要区分运行环境时,使用此工具,禁止直接读取 `import.meta.env`**
- 用法:`import { isDev, isProd } from '@/utils/env'`
#### `doc.js` — 外部文档跳转
- `toDoc(url)` — 在新标签页打开指定 URL
- 用法:`import { toDoc } from '@/utils/doc'`
#### `fmtRouterTitle.js` — 路由标题格式化
- `fmtTitle(title, route)` — 解析路由标题中的动态参数插值(如 `${id}` 替换为路由 params/query 值)
- 用法:`import { fmtTitle } from '@/utils/fmtRouterTitle'`
#### `page.js` — 页面标题生成
- `getPageTitle(pageTitle, route)` — 根据页面标题和路由生成完整的浏览器 Tab 标题(格式:`页面名 - 应用名`)
- 用法:`import getPageTitle from '@/utils/page'`
#### `asyncRouter.js` — 异步路由处理
- `asyncRouterHandle(asyncRouter)` — 将后端返回的路由配置(字符串 component 路径)动态转换为 Vue 组件的 import 函数,支持 `view/` 和 `plugin/` 目录
- **动态路由相关逻辑已由此工具处理,不需要也不应该手动实现**
- 用法:`import { asyncRouterHandle } from '@/utils/asyncRouter'`
#### `btnAuth.js` — 按钮权限
- `useBtnAuth()` — Composition API Hook,返回当前路由挂载的按钮权限对象(来自 `route.meta.btns`),用于控制操作按钮的显示
- **实现按钮级别权限控制时,必须使用此 Hook**
- 用法:`import { useBtnAuth } from '@/utils/btnAuth'`
### **使用强制要求**
| 场景 | 必须使用的工具 |
|------|----------------|
| 发送 HTTP 请求 | `@/utils/request` |
| 格式化日期时间 | `@/utils/date` 或 `@/utils/format` 中的 `formatDate` |
| 获取字典数据 | `@/utils/dictionary` 中的 `getDict` |
| 布尔值/字典值展示转换 | `@/utils/format` 中的 `formatBoolean` / `filterDict` |
| 生成 UUID | `@/utils/format` 中的 `CreateUUID` |
| 驼峰/下划线命名转换 | `@/utils/stringFun` |
| 获取系统参数 | `@/utils/params` 中的 `getParams` |
| 按钮权限判断 | `@/utils/btnAuth` 中的 `useBtnAuth` |
| 跨组件事件通信 | `@/utils/bus` 中的 `emitter` |
| 图片下载 | `@/utils/downloadImg` 中的 `downloadImage` |
| 图片上传压缩 | `@/utils/image` 中的 `ImageCompress` |
| 关闭当前 Tab 页 | `@/utils/closeThisPage` 中的 `closeThisPage` |
---
## **前后端协作规范**
### **接口协作规范**
+129
View File
@@ -194,6 +194,8 @@ web/
- 结构体应继承 `global.GVA_MODEL` 以包含 `ID`, `CreatedAt`, `UpdatedAt` 等基础字段。
- 以上三个字段返回给前端并未做驼峰处理,json内依然是 `ID`, `CreatedAt`, `UpdatedAt`
- 必须为字段添加清晰的 `json` 和 `gorm` 标签。
- **⚠️ 重要提醒:数据类型一致性**
@@ -495,6 +497,9 @@ API函数的Swagger注释不仅用于生成API文档,也是前端开发的重
- **必须**进行响应式数据管理
- **必须**处理加载状态和错误状态
- **必须**遵循Element Plus组件规范
- **必须**优先使用UnoCSS原子化类名进行样式设计
- **必须**优先el-drawer组件进行编辑,新增,步骤等操作
- **必须**使用el-drawer和el-dialog组件是后一定携带,destroy-on-close属性,确保组件销毁,避免内存泄漏和状态污染
#### **4. 状态管理 (`src/pinia/`)**
@@ -622,6 +627,130 @@ src/plugin/[插件名]/
---
## **⚠️ 前端工具库使用规范(强制)**
> **核心原则:在开发任何前端功能时,必须优先检查并使用 `src/utils/` 目录下已封装好的工具函数,严禁重复造轮子。**
`src/utils/` 目录提供了项目级别的通用工具集,涵盖 HTTP 请求、日期处理、格式转换、字符串操作、图片处理等多个方面。以下是各工具文件的功能说明:
### **工具文件清单**
#### `request.js` — HTTP 请求封装(核心)
- 基于 Axios 封装的统一 HTTP 请求实例,内置全局 Loading 状态管理、JWT Token 自动注入、统一错误处理和响应拦截
- **所有 API 请求必须且只能通过此模块发送,禁止直接使用 axios**
- 用法:`import service from '@/utils/request'`
#### `date.js` — 日期格式化
- 扩展了 `Date.prototype.Format` 方法,支持自定义格式如 `yyyy-MM-dd hh:mm:ss`
- 导出 `formatTimeToStr(times, pattern)` 将时间戳或日期对象格式化为字符串
- **需要格式化日期时,优先使用此工具,禁止自行手写日期格式化逻辑**
- 用法:`import { formatTimeToStr } from '@/utils/date'`
#### `format.js` — 数据展示格式化(综合工具)
- `formatBoolean(bool)` — 将布尔值转为 "是"/"否" 中文展示
- `formatDate(time)` — 将时间转为 `yyyy-MM-dd hh:mm:ss` 格式字符串
- `filterDict(value, options)` — 在字典选项数组(支持多级树形)中根据 value 查找对应的 label
- `filterDataSource(dataSource, value)` — 在数据源(支持多级树形)中根据 value 查找 label,支持数组批量查找
- `getDictFunc(type)` — 异步获取指定类型的字典数据
- `ReturnArrImg(arr)` — 将图片路径(单个或数组)转为完整 URL,自动补全服务器前缀
- `onDownloadFile(url)` — 触发文件下载
- `setBodyPrimaryColor(primaryColor, darkMode)` — 动态设置主题色相关的 CSS 变量(支持亮/暗模式)
- `CreateUUID()` — 生成 UUID v4 字符串
- `getBaseUrl()` — 获取当前环境的 API BaseURL
- **以上所有格式化场景优先使用此文件中的工具函数**
- 用法:`import { formatBoolean, formatDate, filterDict, CreateUUID, ... } from '@/utils/format'`
#### `dictionary.js` — 字典数据获取
- `getDict(type, options)` — 异步获取字典数据,支持 `depth`(深度)和 `value`(指定节点)参数,内置 Pinia store 缓存,避免重复请求
- **凡是需要字典下拉数据、字典树形数据的场景,必须使用此工具**
- 用法:`import { getDict } from '@/utils/dictionary'`
#### `stringFun.js` — 字符串处理
- `toUpperCase(str)` — 首字母转大写
- `toLowerCase(str)` — 首字母转小写
- `toSQLLine(str)` — 驼峰命名转下划线(snake_case),如 `userName` → `user_name`
- `toHump(name)` — 下划线命名转驼峰,如 `user_name` → `userName`
- **进行命名格式转换时必须使用此工具,禁止使用正则手写**
- 用法:`import { toUpperCase, toSQLLine, toHump } from '@/utils/stringFun'`
#### `params.js` — 系统参数获取
- `getParams(key)` — 异步从 Pinia store 中获取系统参数,内置缓存
- **获取系统配置参数时,优先使用此工具**
- 用法:`import { getParams } from '@/utils/params'`
#### `bus.js` — 全局事件总线
- 基于 `mitt` 封装的全局事件总线实例 `emitter`,用于跨组件通信
- **跨层级组件通信优先使用此事件总线,避免滥用 Pinia**
- 用法:`import { emitter } from '@/utils/bus'`
#### `closeThisPage.js` — 关闭当前标签页
- `closeThisPage()` — 触发关闭当前多标签页的操作(通过事件总线发送 `closeThisPage` 事件)
- **在需要程序化关闭当前页面时,必须使用此工具**
- 用法:`import { closeThisPage } from '@/utils/closeThisPage'`
#### `downloadImg.js` — 图片下载
- `downloadImage(imgsrc, name)` — 通过 Canvas 将图片转为 base64 后触发下载,支持跨域
- **需要下载图片时,优先使用此工具**
- 用法:`import { downloadImage } from '@/utils/downloadImg'`
#### `image.js` — 图片压缩
- 导出 `ImageCompress` 类,支持图片等比压缩至指定最大宽高,并可限制文件大小
- **上传图片前需要做压缩处理时,使用此工具**
- 用法:`import ImageCompress from '@/utils/image'`
#### `event.js` — DOM 事件监听管理
- `addEventListen(target, event, handler, capture)` — 安全地添加 DOM 事件监听
- `removeEventListen(target, event, handler, capture)` — 安全地移除 DOM 事件监听
- **手动操作 DOM 事件时,使用此工具以确保安全性**
- 用法:`import { addEventListen, removeEventListen } from '@/utils/event'`
#### `env.js` — 环境判断
- `isDev` — 是否为开发环境(Boolean)
- `isProd` — 是否为生产环境(Boolean)
- **需要区分运行环境时,使用此工具,禁止直接读取 `import.meta.env`**
- 用法:`import { isDev, isProd } from '@/utils/env'`
#### `doc.js` — 外部文档跳转
- `toDoc(url)` — 在新标签页打开指定 URL
- 用法:`import { toDoc } from '@/utils/doc'`
#### `fmtRouterTitle.js` — 路由标题格式化
- `fmtTitle(title, route)` — 解析路由标题中的动态参数插值(如 `${id}` 替换为路由 params/query 值)
- 用法:`import { fmtTitle } from '@/utils/fmtRouterTitle'`
#### `page.js` — 页面标题生成
- `getPageTitle(pageTitle, route)` — 根据页面标题和路由生成完整的浏览器 Tab 标题(格式:`页面名 - 应用名`)
- 用法:`import getPageTitle from '@/utils/page'`
#### `asyncRouter.js` — 异步路由处理
- `asyncRouterHandle(asyncRouter)` — 将后端返回的路由配置(字符串 component 路径)动态转换为 Vue 组件的 import 函数,支持 `view/` 和 `plugin/` 目录
- **动态路由相关逻辑已由此工具处理,不需要也不应该手动实现**
- 用法:`import { asyncRouterHandle } from '@/utils/asyncRouter'`
#### `btnAuth.js` — 按钮权限
- `useBtnAuth()` — Composition API Hook,返回当前路由挂载的按钮权限对象(来自 `route.meta.btns`),用于控制操作按钮的显示
- **实现按钮级别权限控制时,必须使用此 Hook**
- 用法:`import { useBtnAuth } from '@/utils/btnAuth'`
### **使用强制要求**
| 场景 | 必须使用的工具 |
|------|----------------|
| 发送 HTTP 请求 | `@/utils/request` |
| 格式化日期时间 | `@/utils/date` 或 `@/utils/format` 中的 `formatDate` |
| 获取字典数据 | `@/utils/dictionary` 中的 `getDict` |
| 布尔值/字典值展示转换 | `@/utils/format` 中的 `formatBoolean` / `filterDict` |
| 生成 UUID | `@/utils/format` 中的 `CreateUUID` |
| 驼峰/下划线命名转换 | `@/utils/stringFun` |
| 获取系统参数 | `@/utils/params` 中的 `getParams` |
| 按钮权限判断 | `@/utils/btnAuth` 中的 `useBtnAuth` |
| 跨组件事件通信 | `@/utils/bus` 中的 `emitter` |
| 图片下载 | `@/utils/downloadImg` 中的 `downloadImage` |
| 图片上传压缩 | `@/utils/image` 中的 `ImageCompress` |
| 关闭当前 Tab 页 | `@/utils/closeThisPage` 中的 `closeThisPage` |
---
## **前后端协作规范**
### **接口协作规范**
+129
View File
@@ -194,6 +194,8 @@ web/
- 结构体应继承 `global.GVA_MODEL` 以包含 `ID`, `CreatedAt`, `UpdatedAt` 等基础字段。
- 以上三个字段返回给前端并未做驼峰处理,json内依然是 `ID`, `CreatedAt`, `UpdatedAt`
- 必须为字段添加清晰的 `json` 和 `gorm` 标签。
- **⚠️ 重要提醒:数据类型一致性**
@@ -495,6 +497,9 @@ API函数的Swagger注释不仅用于生成API文档,也是前端开发的重
- **必须**进行响应式数据管理
- **必须**处理加载状态和错误状态
- **必须**遵循Element Plus组件规范
- **必须**优先使用UnoCSS原子化类名进行样式设计
- **必须**优先el-drawer组件进行编辑,新增,步骤等操作
- **必须**使用el-drawer和el-dialog组件是后一定携带,destroy-on-close属性,确保组件销毁,避免内存泄漏和状态污染
#### **4. 状态管理 (`src/pinia/`)**
@@ -622,6 +627,130 @@ src/plugin/[插件名]/
---
## **⚠️ 前端工具库使用规范(强制)**
> **核心原则:在开发任何前端功能时,必须优先检查并使用 `src/utils/` 目录下已封装好的工具函数,严禁重复造轮子。**
`src/utils/` 目录提供了项目级别的通用工具集,涵盖 HTTP 请求、日期处理、格式转换、字符串操作、图片处理等多个方面。以下是各工具文件的功能说明:
### **工具文件清单**
#### `request.js` — HTTP 请求封装(核心)
- 基于 Axios 封装的统一 HTTP 请求实例,内置全局 Loading 状态管理、JWT Token 自动注入、统一错误处理和响应拦截
- **所有 API 请求必须且只能通过此模块发送,禁止直接使用 axios**
- 用法:`import service from '@/utils/request'`
#### `date.js` — 日期格式化
- 扩展了 `Date.prototype.Format` 方法,支持自定义格式如 `yyyy-MM-dd hh:mm:ss`
- 导出 `formatTimeToStr(times, pattern)` 将时间戳或日期对象格式化为字符串
- **需要格式化日期时,优先使用此工具,禁止自行手写日期格式化逻辑**
- 用法:`import { formatTimeToStr } from '@/utils/date'`
#### `format.js` — 数据展示格式化(综合工具)
- `formatBoolean(bool)` — 将布尔值转为 "是"/"否" 中文展示
- `formatDate(time)` — 将时间转为 `yyyy-MM-dd hh:mm:ss` 格式字符串
- `filterDict(value, options)` — 在字典选项数组(支持多级树形)中根据 value 查找对应的 label
- `filterDataSource(dataSource, value)` — 在数据源(支持多级树形)中根据 value 查找 label,支持数组批量查找
- `getDictFunc(type)` — 异步获取指定类型的字典数据
- `ReturnArrImg(arr)` — 将图片路径(单个或数组)转为完整 URL,自动补全服务器前缀
- `onDownloadFile(url)` — 触发文件下载
- `setBodyPrimaryColor(primaryColor, darkMode)` — 动态设置主题色相关的 CSS 变量(支持亮/暗模式)
- `CreateUUID()` — 生成 UUID v4 字符串
- `getBaseUrl()` — 获取当前环境的 API BaseURL
- **以上所有格式化场景优先使用此文件中的工具函数**
- 用法:`import { formatBoolean, formatDate, filterDict, CreateUUID, ... } from '@/utils/format'`
#### `dictionary.js` — 字典数据获取
- `getDict(type, options)` — 异步获取字典数据,支持 `depth`(深度)和 `value`(指定节点)参数,内置 Pinia store 缓存,避免重复请求
- **凡是需要字典下拉数据、字典树形数据的场景,必须使用此工具**
- 用法:`import { getDict } from '@/utils/dictionary'`
#### `stringFun.js` — 字符串处理
- `toUpperCase(str)` — 首字母转大写
- `toLowerCase(str)` — 首字母转小写
- `toSQLLine(str)` — 驼峰命名转下划线(snake_case),如 `userName` → `user_name`
- `toHump(name)` — 下划线命名转驼峰,如 `user_name` → `userName`
- **进行命名格式转换时必须使用此工具,禁止使用正则手写**
- 用法:`import { toUpperCase, toSQLLine, toHump } from '@/utils/stringFun'`
#### `params.js` — 系统参数获取
- `getParams(key)` — 异步从 Pinia store 中获取系统参数,内置缓存
- **获取系统配置参数时,优先使用此工具**
- 用法:`import { getParams } from '@/utils/params'`
#### `bus.js` — 全局事件总线
- 基于 `mitt` 封装的全局事件总线实例 `emitter`,用于跨组件通信
- **跨层级组件通信优先使用此事件总线,避免滥用 Pinia**
- 用法:`import { emitter } from '@/utils/bus'`
#### `closeThisPage.js` — 关闭当前标签页
- `closeThisPage()` — 触发关闭当前多标签页的操作(通过事件总线发送 `closeThisPage` 事件)
- **在需要程序化关闭当前页面时,必须使用此工具**
- 用法:`import { closeThisPage } from '@/utils/closeThisPage'`
#### `downloadImg.js` — 图片下载
- `downloadImage(imgsrc, name)` — 通过 Canvas 将图片转为 base64 后触发下载,支持跨域
- **需要下载图片时,优先使用此工具**
- 用法:`import { downloadImage } from '@/utils/downloadImg'`
#### `image.js` — 图片压缩
- 导出 `ImageCompress` 类,支持图片等比压缩至指定最大宽高,并可限制文件大小
- **上传图片前需要做压缩处理时,使用此工具**
- 用法:`import ImageCompress from '@/utils/image'`
#### `event.js` — DOM 事件监听管理
- `addEventListen(target, event, handler, capture)` — 安全地添加 DOM 事件监听
- `removeEventListen(target, event, handler, capture)` — 安全地移除 DOM 事件监听
- **手动操作 DOM 事件时,使用此工具以确保安全性**
- 用法:`import { addEventListen, removeEventListen } from '@/utils/event'`
#### `env.js` — 环境判断
- `isDev` — 是否为开发环境(Boolean)
- `isProd` — 是否为生产环境(Boolean)
- **需要区分运行环境时,使用此工具,禁止直接读取 `import.meta.env`**
- 用法:`import { isDev, isProd } from '@/utils/env'`
#### `doc.js` — 外部文档跳转
- `toDoc(url)` — 在新标签页打开指定 URL
- 用法:`import { toDoc } from '@/utils/doc'`
#### `fmtRouterTitle.js` — 路由标题格式化
- `fmtTitle(title, route)` — 解析路由标题中的动态参数插值(如 `${id}` 替换为路由 params/query 值)
- 用法:`import { fmtTitle } from '@/utils/fmtRouterTitle'`
#### `page.js` — 页面标题生成
- `getPageTitle(pageTitle, route)` — 根据页面标题和路由生成完整的浏览器 Tab 标题(格式:`页面名 - 应用名`)
- 用法:`import getPageTitle from '@/utils/page'`
#### `asyncRouter.js` — 异步路由处理
- `asyncRouterHandle(asyncRouter)` — 将后端返回的路由配置(字符串 component 路径)动态转换为 Vue 组件的 import 函数,支持 `view/` 和 `plugin/` 目录
- **动态路由相关逻辑已由此工具处理,不需要也不应该手动实现**
- 用法:`import { asyncRouterHandle } from '@/utils/asyncRouter'`
#### `btnAuth.js` — 按钮权限
- `useBtnAuth()` — Composition API Hook,返回当前路由挂载的按钮权限对象(来自 `route.meta.btns`),用于控制操作按钮的显示
- **实现按钮级别权限控制时,必须使用此 Hook**
- 用法:`import { useBtnAuth } from '@/utils/btnAuth'`
### **使用强制要求**
| 场景 | 必须使用的工具 |
|------|----------------|
| 发送 HTTP 请求 | `@/utils/request` |
| 格式化日期时间 | `@/utils/date` 或 `@/utils/format` 中的 `formatDate` |
| 获取字典数据 | `@/utils/dictionary` 中的 `getDict` |
| 布尔值/字典值展示转换 | `@/utils/format` 中的 `formatBoolean` / `filterDict` |
| 生成 UUID | `@/utils/format` 中的 `CreateUUID` |
| 驼峰/下划线命名转换 | `@/utils/stringFun` |
| 获取系统参数 | `@/utils/params` 中的 `getParams` |
| 按钮权限判断 | `@/utils/btnAuth` 中的 `useBtnAuth` |
| 跨组件事件通信 | `@/utils/bus` 中的 `emitter` |
| 图片下载 | `@/utils/downloadImg` 中的 `downloadImage` |
| 图片上传压缩 | `@/utils/image` 中的 `ImageCompress` |
| 关闭当前 Tab 页 | `@/utils/closeThisPage` 中的 `closeThisPage` |
---
## **前后端协作规范**
### **接口协作规范**
+58
View File
@@ -321,3 +321,61 @@ func (s *SystemApiApi) FreshCasbin(c *gin.Context) {
}
response.OkWithMessage("刷新成功", c)
}
// GetApiRoles
// @Tags SysApi
// @Summary 获取拥有指定API权限的角色ID列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param path query string true "API路径"
// @Param method query string true "请求方法"
// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取成功"
// @Router /api/getApiRoles [get]
func (s *SystemApiApi) GetApiRoles(c *gin.Context) {
path := c.Query("path")
method := c.Query("method")
if path == "" || method == "" {
response.FailWithMessage("API路径和请求方法不能为空", c)
return
}
authorityIds, err := casbinService.GetAuthoritiesByApi(path, method)
if err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败"+err.Error(), c)
return
}
if authorityIds == nil {
authorityIds = []uint{}
}
response.OkWithDetailed(authorityIds, "获取成功", c)
}
// SetApiRoles
// @Tags SysApi
// @Summary 全量覆盖某API关联的角色列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body systemReq.SetApiAuthorities true "API路径、请求方法和角色ID列表"
// @Success 200 {object} response.Response{msg=string} "设置成功"
// @Router /api/setApiRoles [post]
func (s *SystemApiApi) SetApiRoles(c *gin.Context) {
var req systemReq.SetApiAuthorities
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.Path == "" || req.Method == "" {
response.FailWithMessage("API路径和请求方法不能为空", c)
return
}
if err := casbinService.SetApiAuthorities(req.Path, req.Method, req.AuthorityIds); err != nil {
global.GVA_LOG.Error("设置失败!", zap.Error(err))
response.FailWithMessage("设置失败"+err.Error(), c)
return
}
// 刷新casbin缓存使策略立即生效
_ = casbinService.FreshCasbin()
response.OkWithMessage("设置成功", c)
}
+55
View File
@@ -4,6 +4,7 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
systemRes "github.com/flipped-aurora/gin-vue-admin/server/model/system/response"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
@@ -200,3 +201,57 @@ func (a *AuthorityApi) SetDataAuthority(c *gin.Context) {
}
response.OkWithMessage("设置成功", c)
}
// GetUsersByAuthority
// @Tags Authority
// @Summary 获取拥有指定角色的用户ID列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param authorityId query uint true "角色ID"
// @Success 200 {object} response.Response{data=[]uint,msg=string} "获取成功"
// @Router /authority/getUsersByAuthority [get]
func (a *AuthorityApi) GetUsersByAuthority(c *gin.Context) {
var req systemReq.SetRoleUsers
if err := c.ShouldBindQuery(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
userIds, err := authorityService.GetUserIdsByAuthorityId(req.AuthorityId)
if err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败"+err.Error(), c)
return
}
if userIds == nil {
userIds = []uint{}
}
response.OkWithDetailed(userIds, "获取成功", c)
}
// SetRoleUsers
// @Tags Authority
// @Summary 全量覆盖某角色关联的用户列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body systemReq.SetRoleUsers true "角色ID和用户ID列表"
// @Success 200 {object} response.Response{msg=string} "设置成功"
// @Router /authority/setRoleUsers [post]
func (a *AuthorityApi) SetRoleUsers(c *gin.Context) {
var req systemReq.SetRoleUsers
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.AuthorityId == 0 {
response.FailWithMessage("角色ID不能为空", c)
return
}
if err := authorityService.SetRoleUsers(req.AuthorityId, req.UserIds); err != nil {
global.GVA_LOG.Error("设置失败!", zap.Error(err))
response.FailWithMessage("设置失败"+err.Error(), c)
return
}
response.OkWithMessage("设置成功", c)
}
+70
View File
@@ -244,6 +244,76 @@ func (a *AuthorityMenuApi) GetBaseMenuById(c *gin.Context) {
response.OkWithDetailed(systemRes.SysBaseMenuResponse{Menu: menu}, "获取成功", c)
}
// GetMenuRoles
// @Tags AuthorityMenu
// @Summary 获取拥有指定菜单的角色ID列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param menuId query uint true "菜单ID"
// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取成功"
// @Router /menu/getMenuRoles [get]
func (a *AuthorityMenuApi) GetMenuRoles(c *gin.Context) {
var req systemReq.SetMenuAuthorities
if err := c.ShouldBindQuery(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.MenuId == 0 {
response.FailWithMessage("菜单ID不能为空", c)
return
}
authorityIds, err := menuService.GetAuthoritiesByMenuId(req.MenuId)
if err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败"+err.Error(), c)
return
}
if authorityIds == nil {
authorityIds = []uint{}
}
defaultRouterAuthorityIds, err := menuService.GetDefaultRouterAuthorityIds(req.MenuId)
if err != nil {
global.GVA_LOG.Error("获取首页角色失败!", zap.Error(err))
response.FailWithMessage("获取失败"+err.Error(), c)
return
}
if defaultRouterAuthorityIds == nil {
defaultRouterAuthorityIds = []uint{}
}
response.OkWithDetailed(gin.H{
"authorityIds": authorityIds,
"defaultRouterAuthorityIds": defaultRouterAuthorityIds,
}, "获取成功", c)
}
// SetMenuRoles
// @Tags AuthorityMenu
// @Summary 全量覆盖某菜单关联的角色列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body systemReq.SetMenuAuthorities true "菜单ID和角色ID列表"
// @Success 200 {object} response.Response{msg=string} "设置成功"
// @Router /menu/setMenuRoles [post]
func (a *AuthorityMenuApi) SetMenuRoles(c *gin.Context) {
var req systemReq.SetMenuAuthorities
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if req.MenuId == 0 {
response.FailWithMessage("菜单ID不能为空", c)
return
}
if err := menuService.SetMenuAuthorities(req.MenuId, req.AuthorityIds); err != nil {
global.GVA_LOG.Error("设置失败!", zap.Error(err))
response.FailWithMessage("设置失败"+err.Error(), c)
return
}
response.OkWithMessage("设置成功", c)
}
// GetMenuList
// @Tags Menu
// @Summary 分页获取基础menu列表
+44
View File
@@ -1,6 +1,8 @@
package system
import (
"net/http"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
@@ -55,6 +57,17 @@ func (s *SkillsApi) SaveSkill(c *gin.Context) {
response.OkWithMessage("保存成功", c)
}
func (s *SkillsApi) DeleteSkill(c *gin.Context) {
var req request.SkillDeleteRequest
_ = c.ShouldBindJSON(&req)
if err := skillsService.Delete(c.Request.Context(), req); err != nil {
global.GVA_LOG.Error("删除技能失败", zap.Error(err))
response.FailWithMessage("删除技能失败: "+err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
func (s *SkillsApi) CreateScript(c *gin.Context) {
var req request.SkillScriptCreateRequest
_ = c.ShouldBindJSON(&req)
@@ -217,3 +230,34 @@ func (s *SkillsApi) SaveGlobalConstraint(c *gin.Context) {
}
response.OkWithMessage("保存成功", c)
}
func (s *SkillsApi) PackageSkill(c *gin.Context) {
var req request.SkillPackageRequest
_ = c.ShouldBindJSON(&req)
fileName, data, err := skillsService.Package(c.Request.Context(), req)
if err != nil {
global.GVA_LOG.Error("打包技能失败", zap.Error(err))
response.FailWithMessage("打包技能失败: "+err.Error(), c)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename=\""+fileName+"\"")
c.Data(http.StatusOK, "application/zip", data)
}
func (s *SkillsApi) DownloadOnlineSkill(c *gin.Context) {
var req request.DownloadOnlineSkillReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误", c)
return
}
if err := skillsService.DownloadOnlineSkill(c.Request.Context(), req); err != nil {
global.GVA_LOG.Error("下载在线技能失败", zap.Error(err))
response.FailWithMessage("下载在线技能失败: "+err.Error(), c)
return
}
response.OkWithMessage("下载成功", c)
}
+1 -1
View File
@@ -4,7 +4,7 @@ package global
// 目前只有Version正式使用 其余为预留
const (
// Version 当前版本号
Version = "v2.8.9"
Version = "v2.9.0"
// AppName 应用名称
AppName = "Gin-Vue-Admin"
// Description 应用描述
+21 -3
View File
@@ -6,7 +6,11 @@ toolchain go1.24.2
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go v1.55.6
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1
github.com/casbin/casbin/v2 v2.103.0
github.com/casbin/gorm-adapter/v3 v3.32.0
github.com/dzwvip/gorm-oracle v0.1.2
@@ -46,6 +50,7 @@ require (
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/text v0.24.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.5
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
@@ -61,6 +66,21 @@ require (
github.com/STARRY-S/zip v0.2.1 // indirect
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aws/smithy-go v1.24.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.8.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
@@ -108,7 +128,6 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
@@ -178,7 +197,6 @@ require (
golang.org/x/tools v0.29.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/hints v1.1.2 // indirect
gorm.io/plugin/dbresolver v1.5.3 // indirect
modernc.org/fileutil v1.3.0 // indirect
+40 -7
View File
@@ -54,8 +54,46 @@ github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3 h1:+mQ8NQBh7B7c2FBtppRnwkrmuwFON1XQQ+5yblomZKk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3/go.mod h1:u67RKh3BRmS4FYLH+rN3N4T5fqpd9m2ttAwBJYEdosU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
@@ -273,10 +311,6 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -782,7 +816,6 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -111,7 +111,7 @@ func Routers() *gin.Engine {
systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志
systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志
systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发
systemRouter.InitSkillsRouter(PrivateGroup) // Skills 定义器
systemRouter.InitSkillsRouter(PrivateGroup,PublicGroup) // Skills 定义器
exampleRouter.InitCustomerRouter(PrivateGroup) // 客户路由
exampleRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由
exampleRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// @Tag.Description 用户
// @title Gin-Vue-Admin Swagger API接口文档
// @version v2.8.9
// @version v2.9.0
// @description 使用gin+vue进行极速开发的全栈开发基础平台
// @securityDefinitions.apikey ApiKeyAuth
// @in header
+7
View File
@@ -12,3 +12,10 @@ type SearchApiParams struct {
OrderKey string `json:"orderKey"` // 排序
Desc bool `json:"desc"` // 排序方式:升序false(默认)|降序true
}
// SetApiAuthorities 通过API路径和方法全量覆盖关联角色列表
type SetApiAuthorities struct {
Path string `json:"path" form:"path"` // API路径
Method string `json:"method" form:"method"` // 请求方法
AuthorityIds []uint `json:"authorityIds" form:"authorityIds"` // 角色ID列表
}
+6
View File
@@ -11,6 +11,12 @@ type AddMenuAuthorityInfo struct {
AuthorityId uint `json:"authorityId"` // 角色ID
}
// SetMenuAuthorities 通过菜单ID全量覆盖关联角色列表
type SetMenuAuthorities struct {
MenuId uint `json:"menuId" form:"menuId"` // 菜单ID
AuthorityIds []uint `json:"authorityIds" form:"authorityIds"` // 角色ID列表
}
func DefaultMenu() []system.SysBaseMenu {
return []system.SysBaseMenu{{
GVA_MODEL: global.GVA_MODEL{ID: 1},
+16
View File
@@ -11,6 +11,16 @@ type SkillDetailRequest struct {
Skill string `json:"skill"`
}
type SkillDeleteRequest struct {
Tool string `json:"tool"`
Skill string `json:"skill"`
}
type SkillPackageRequest struct {
Tool string `json:"tool"`
Skill string `json:"skill"`
}
type SkillSaveRequest struct {
Tool string `json:"tool"`
Skill string `json:"skill"`
@@ -62,3 +72,9 @@ type SkillGlobalConstraintSaveRequest struct {
Content string `json:"content"`
SyncTools []string `json:"syncTools"`
}
type DownloadOnlineSkillReq struct {
Tool string `json:"tool" binding:"required"`
ID uint `json:"id" binding:"required"`
Version string `json:"version" binding:"required"`
}
+8
View File
@@ -66,4 +66,12 @@ type GetUserList struct {
NickName string `json:"nickName" form:"nickName"`
Phone string `json:"phone" form:"phone"`
Email string `json:"email" form:"email"`
OrderKey string `json:"orderKey" form:"orderKey"` // 排序
Desc bool `json:"desc" form:"desc"` // 排序方式:升序false(默认)|降序true
}
// SetRoleUsers 通过角色ID全量覆盖关联用户列表
type SetRoleUsers struct {
AuthorityId uint `json:"authorityId" form:"authorityId"` // 角色ID
UserIds []uint `json:"userIds" form:"userIds"` // 用户ID列表
}
+2
View File
@@ -22,10 +22,12 @@ func (s *ApiRouter) InitApiRouter(Router *gin.RouterGroup, RouterPub *gin.Router
apiRouter.POST("getApiById", apiRouterApi.GetApiById) // 获取单条Api消息
apiRouter.POST("updateApi", apiRouterApi.UpdateApi) // 更新api
apiRouter.DELETE("deleteApisByIds", apiRouterApi.DeleteApisByIds) // 删除选中api
apiRouter.POST("setApiRoles", apiRouterApi.SetApiRoles) // 全量覆盖API关联角色
}
{
apiRouterWithoutRecord.POST("getAllApis", apiRouterApi.GetAllApis) // 获取所有api
apiRouterWithoutRecord.POST("getApiList", apiRouterApi.GetApiList) // 获取Api列表
apiRouterWithoutRecord.GET("getApiRoles", apiRouterApi.GetApiRoles) // 获取API关联角色ID列表
}
{
apiPublicRouterWithoutRecord.GET("freshCasbin", apiRouterApi.FreshCasbin) // 刷新casbin权限
+3 -1
View File
@@ -16,8 +16,10 @@ func (s *AuthorityRouter) InitAuthorityRouter(Router *gin.RouterGroup) {
authorityRouter.PUT("updateAuthority", authorityApi.UpdateAuthority) // 更新角色
authorityRouter.POST("copyAuthority", authorityApi.CopyAuthority) // 拷贝角色
authorityRouter.POST("setDataAuthority", authorityApi.SetDataAuthority) // 设置角色资源权限
authorityRouter.POST("setRoleUsers", authorityApi.SetRoleUsers) // 全量覆盖角色关联用户
}
{
authorityRouterWithoutRecord.POST("getAuthorityList", authorityApi.GetAuthorityList) // 获取角色列表
authorityRouterWithoutRecord.POST("getAuthorityList", authorityApi.GetAuthorityList) // 获取角色列表
authorityRouterWithoutRecord.GET("getUsersByAuthority", authorityApi.GetUsersByAuthority) // 获取角色关联用户ID列表
}
}
+2
View File
@@ -15,6 +15,7 @@ func (s *MenuRouter) InitMenuRouter(Router *gin.RouterGroup) (R gin.IRoutes) {
menuRouter.POST("addMenuAuthority", authorityMenuApi.AddMenuAuthority) // 增加menu和角色关联关系
menuRouter.POST("deleteBaseMenu", authorityMenuApi.DeleteBaseMenu) // 删除菜单
menuRouter.POST("updateBaseMenu", authorityMenuApi.UpdateBaseMenu) // 更新菜单
menuRouter.POST("setMenuRoles", authorityMenuApi.SetMenuRoles) // 全量覆盖菜单关联角色
}
{
menuRouterWithoutRecord.POST("getMenu", authorityMenuApi.GetMenu) // 获取菜单树
@@ -22,6 +23,7 @@ func (s *MenuRouter) InitMenuRouter(Router *gin.RouterGroup) (R gin.IRoutes) {
menuRouterWithoutRecord.POST("getBaseMenuTree", authorityMenuApi.GetBaseMenuTree) // 获取用户动态路由
menuRouterWithoutRecord.POST("getMenuAuthority", authorityMenuApi.GetMenuAuthority) // 获取指定角色menu
menuRouterWithoutRecord.POST("getBaseMenuById", authorityMenuApi.GetBaseMenuById) // 根据id获取菜单
menuRouterWithoutRecord.GET("getMenuRoles", authorityMenuApi.GetMenuRoles) // 获取菜单关联角色ID列表
}
return menuRouter
}
+7 -1
View File
@@ -4,13 +4,15 @@ import "github.com/gin-gonic/gin"
type SkillsRouter struct{}
func (s *SkillsRouter) InitSkillsRouter(Router *gin.RouterGroup) {
func (s *SkillsRouter) InitSkillsRouter(Router *gin.RouterGroup, pubRouter *gin.RouterGroup) {
skillsRouter := Router.Group("skills")
skillsRouterPub := pubRouter.Group("skills")
{
skillsRouter.GET("getTools", skillsApi.GetTools)
skillsRouter.POST("getSkillList", skillsApi.GetSkillList)
skillsRouter.POST("getSkillDetail", skillsApi.GetSkillDetail)
skillsRouter.POST("saveSkill", skillsApi.SaveSkill)
skillsRouter.POST("deleteSkill", skillsApi.DeleteSkill)
skillsRouter.POST("createScript", skillsApi.CreateScript)
skillsRouter.POST("getScript", skillsApi.GetScript)
skillsRouter.POST("saveScript", skillsApi.SaveScript)
@@ -25,5 +27,9 @@ func (s *SkillsRouter) InitSkillsRouter(Router *gin.RouterGroup) {
skillsRouter.POST("saveTemplate", skillsApi.SaveTemplate)
skillsRouter.POST("getGlobalConstraint", skillsApi.GetGlobalConstraint)
skillsRouter.POST("saveGlobalConstraint", skillsApi.SaveGlobalConstraint)
skillsRouter.POST("packageSkill", skillsApi.PackageSkill)
}
{
skillsRouterPub.POST("downloadOnlineSkill", skillsApi.DownloadOnlineSkill)
}
}
+79
View File
@@ -331,3 +331,82 @@ func (authorityService *AuthorityService) GetParentAuthorityID(authorityID uint)
}
return *authority.ParentId, nil
}
// GetUserIdsByAuthorityId 获取拥有指定角色的所有用户ID
func (authorityService *AuthorityService) GetUserIdsByAuthorityId(authorityId uint) (userIds []uint, err error) {
var records []system.SysUserAuthority
err = global.GVA_DB.Where("sys_authority_authority_id = ?", authorityId).Find(&records).Error
if err != nil {
return nil, err
}
for _, r := range records {
userIds = append(userIds, r.SysUserId)
}
return userIds, nil
}
// SetRoleUsers 全量覆盖某角色关联的用户列表
// 入参:角色ID + 目标用户ID列表,保存时将该角色的关联关系完全替换为传入列表
func (authorityService *AuthorityService) SetRoleUsers(authorityId uint, userIds []uint) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 1. 查出当前拥有该角色的所有用户ID
var existingRecords []system.SysUserAuthority
if err := tx.Where("sys_authority_authority_id = ?", authorityId).Find(&existingRecords).Error; err != nil {
return err
}
currentSet := make(map[uint]struct{})
for _, r := range existingRecords {
currentSet[r.SysUserId] = struct{}{}
}
targetSet := make(map[uint]struct{})
for _, id := range userIds {
targetSet[id] = struct{}{}
}
// 2. 删除该角色所有已有的用户关联
if err := tx.Delete(&system.SysUserAuthority{}, "sys_authority_authority_id = ?", authorityId).Error; err != nil {
return err
}
// 3. 对被移除的用户:若该角色是其主角色,则将主角色切换为其剩余的其他角色
for userId := range currentSet {
if _, ok := targetSet[userId]; ok {
continue // 仍在目标列表中,不处理
}
var user system.SysUser
if err := tx.First(&user, "id = ?", userId).Error; err != nil {
continue
}
if user.AuthorityId == authorityId {
// 从剩余关联(已删除当前角色后)中找另一个角色作为主角色
var another system.SysUserAuthority
if err := tx.Where("sys_user_id = ?", userId).First(&another).Error; err != nil {
// 没有其他角色,主角色保持不变,不做处理
continue
}
if err := tx.Model(&system.SysUser{}).Where("id = ?", userId).
Update("authority_id", another.SysAuthorityAuthorityId).Error; err != nil {
return err
}
}
}
// 4. 批量插入新的关联记录
if len(userIds) > 0 {
newRecords := make([]system.SysUserAuthority, 0, len(userIds))
for _, userId := range userIds {
newRecords = append(newRecords, system.SysUserAuthority{
SysUserId: userId,
SysAuthorityAuthorityId: authorityId,
})
}
if err := tx.Create(&newRecords).Error; err != nil {
return err
}
}
return nil
})
}
+42
View File
@@ -171,3 +171,45 @@ func (casbinService *CasbinService) FreshCasbin() (err error) {
err = e.LoadPolicy()
return err
}
// GetAuthoritiesByApi 获取拥有指定API权限的所有角色ID
func (casbinService *CasbinService) GetAuthoritiesByApi(path, method string) (authorityIds []uint, err error) {
var rules []gormadapter.CasbinRule
err = global.GVA_DB.Where("ptype = 'p' AND v1 = ? AND v2 = ?", path, method).Find(&rules).Error
if err != nil {
return nil, err
}
for _, r := range rules {
id, e := strconv.Atoi(r.V0)
if e == nil {
authorityIds = append(authorityIds, uint(id))
}
}
return authorityIds, nil
}
// SetApiAuthorities 全量覆盖某API关联的角色列表
func (casbinService *CasbinService) SetApiAuthorities(path, method string, authorityIds []uint) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 1. 删除该API所有已有的角色关联
if err := tx.Where("ptype = 'p' AND v1 = ? AND v2 = ?", path, method).Delete(&gormadapter.CasbinRule{}).Error; err != nil {
return err
}
// 2. 批量插入新的关联记录
if len(authorityIds) > 0 {
newRules := make([]gormadapter.CasbinRule, 0, len(authorityIds))
for _, authorityId := range authorityIds {
newRules = append(newRules, gormadapter.CasbinRule{
Ptype: "p",
V0: strconv.Itoa(int(authorityId)),
V1: path,
V2: method,
})
}
if err := tx.Create(&newRules).Error; err != nil {
return err
}
}
return nil
})
}
+59
View File
@@ -314,6 +314,65 @@ func (menuService *MenuService) GetMenuAuthority(info *request.GetAuthorityId) (
return menus, err
}
// GetAuthoritiesByMenuId 获取拥有指定菜单的所有角色ID
func (menuService *MenuService) GetAuthoritiesByMenuId(menuId uint) (authorityIds []uint, err error) {
var records []system.SysAuthorityMenu
err = global.GVA_DB.Where("sys_base_menu_id = ?", menuId).Find(&records).Error
if err != nil {
return nil, err
}
for _, r := range records {
id, e := strconv.Atoi(r.AuthorityId)
if e == nil {
authorityIds = append(authorityIds, uint(id))
}
}
return authorityIds, nil
}
// GetDefaultRouterAuthorityIds 获取将指定菜单设为首页的角色ID列表
func (menuService *MenuService) GetDefaultRouterAuthorityIds(menuId uint) (authorityIds []uint, err error) {
var menu system.SysBaseMenu
err = global.GVA_DB.First(&menu, menuId).Error
if err != nil {
return nil, err
}
var authorities []system.SysAuthority
err = global.GVA_DB.Where("default_router = ?", menu.Name).Find(&authorities).Error
if err != nil {
return nil, err
}
for _, auth := range authorities {
authorityIds = append(authorityIds, auth.AuthorityId)
}
return authorityIds, nil
}
// SetMenuAuthorities 全量覆盖某菜单关联的角色列表
func (menuService *MenuService) SetMenuAuthorities(menuId uint, authorityIds []uint) error {
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 1. 删除该菜单所有已有的角色关联
if err := tx.Where("sys_base_menu_id = ?", menuId).Delete(&system.SysAuthorityMenu{}).Error; err != nil {
return err
}
// 2. 批量插入新的关联记录
if len(authorityIds) > 0 {
menuIdStr := strconv.Itoa(int(menuId))
newRecords := make([]system.SysAuthorityMenu, 0, len(authorityIds))
for _, authorityId := range authorityIds {
newRecords = append(newRecords, system.SysAuthorityMenu{
MenuId: menuIdStr,
AuthorityId: strconv.Itoa(int(authorityId)),
})
}
if err := tx.Create(&newRecords).Error; err != nil {
return err
}
}
return nil
})
}
// UserAuthorityDefaultRouter 用户角色默认路由检查
//
// Author [SliverHorn](https://github.com/SliverHorn)
+236
View File
@@ -1,10 +1,15 @@
package system
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
@@ -158,6 +163,107 @@ func (s *SkillsService) Save(_ context.Context, req request.SkillSaveRequest) er
return nil
}
func (s *SkillsService) Delete(_ context.Context, req request.SkillDeleteRequest) error {
if strings.TrimSpace(req.Tool) == "" {
return errors.New("工具类型不能为空")
}
if !isSafeName(req.Skill) {
return errors.New("技能名称不合法")
}
skillDir, err := s.skillDir(req.Tool, req.Skill)
if err != nil {
return err
}
info, err := os.Stat(skillDir)
if err != nil {
if os.IsNotExist(err) {
return errors.New("技能不存在")
}
return err
}
if !info.IsDir() {
return errors.New("技能目录异常")
}
return os.RemoveAll(skillDir)
}
func (s *SkillsService) Package(_ context.Context, req request.SkillPackageRequest) (string, []byte, error) {
if strings.TrimSpace(req.Tool) == "" {
return "", nil, errors.New("工具类型不能为空")
}
if !isSafeName(req.Skill) {
return "", nil, errors.New("技能名称不合法")
}
skillDir, err := s.skillDir(req.Tool, req.Skill)
if err != nil {
return "", nil, err
}
info, err := os.Stat(skillDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil, errors.New("技能不存在")
}
return "", nil, err
}
if !info.IsDir() {
return "", nil, errors.New("技能目录异常")
}
buf := bytes.NewBuffer(nil)
zw := zip.NewWriter(buf)
walkErr := filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(skillDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
zipName := filepath.ToSlash(rel)
if d.IsDir() {
_, err = zw.Create(strings.TrimSuffix(zipName, "/") + "/")
return err
}
fileInfo, err := d.Info()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return err
}
header.Name = zipName
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = writer.Write(content)
return err
})
if walkErr != nil {
_ = zw.Close()
return "", nil, walkErr
}
if err = zw.Close(); err != nil {
return "", nil, err
}
return req.Skill + ".zip", buf.Bytes(), nil
}
func (s *SkillsService) CreateScript(_ context.Context, req request.SkillScriptCreateRequest) (string, string, error) {
if !isSafeName(req.Skill) {
return "", "", errors.New("技能名称不合法")
@@ -279,6 +385,136 @@ func (s *SkillsService) SaveGlobalConstraint(_ context.Context, req request.Skil
return nil
}
func (s *SkillsService) DownloadOnlineSkill(_ context.Context, req request.DownloadOnlineSkillReq) error {
skillsDir, err := s.toolSkillsDir(req.Tool)
if err != nil {
return err
}
body, err := json.Marshal(map[string]interface{}{
"plugin_id": req.ID,
"version": req.Version,
})
if err != nil {
return fmt.Errorf("构建下载请求失败: %w", err)
}
downloadReq, err := http.NewRequest(http.MethodPost, "https://plugin.gin-vue-admin.com/api/shopPlugin/downloadSkill", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("构建下载请求失败: %w", err)
}
downloadReq.Header.Set("Content-Type", "application/json")
downloadResp, err := http.DefaultClient.Do(downloadReq)
if err != nil {
return fmt.Errorf("下载技能失败: %w", err)
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
return fmt.Errorf("下载技能失败, HTTP状态码: %d", downloadResp.StatusCode)
}
metaBody, err := io.ReadAll(downloadResp.Body)
if err != nil {
return fmt.Errorf("读取下载结果失败: %w", err)
}
var meta struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err = json.Unmarshal(metaBody, &meta); err != nil {
return fmt.Errorf("解析下载结果失败: %w", err)
}
realDownloadURL := strings.TrimSpace(meta.Data.URL)
if realDownloadURL == "" {
return errors.New("下载结果缺少 url")
}
zipResp, err := http.Get(realDownloadURL)
if err != nil {
return fmt.Errorf("下载压缩包失败: %w", err)
}
defer zipResp.Body.Close()
if zipResp.StatusCode != http.StatusOK {
return fmt.Errorf("下载压缩包失败, HTTP状态码: %d", zipResp.StatusCode)
}
tmpFile, err := os.CreateTemp("", "gva-skill-*.zip")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err = io.Copy(tmpFile, zipResp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("保存技能包失败: %w", err)
}
tmpFile.Close()
if err = extractZipToDir(tmpPath, skillsDir); err != nil {
return fmt.Errorf("解压技能包失败: %w", err)
}
return nil
}
func extractZipToDir(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
name := filepath.FromSlash(f.Name)
if strings.Contains(name, "..") {
continue
}
target := filepath.Join(destDir, name)
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)) {
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, os.ModePerm); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
rc.Close()
return err
}
_, err = io.Copy(out, rc)
rc.Close()
out.Close()
if err != nil {
return err
}
}
return nil
}
func (s *SkillsService) toolSkillsDir(tool string) (string, error) {
toolDir, ok := skillToolDirs[tool]
if !ok {
+19 -1
View File
@@ -109,7 +109,25 @@ func (userService *UserService) GetUserInfoList(info systemReq.GetUserList) (lis
if err != nil {
return
}
err = db.Limit(limit).Offset(offset).Preload("Authorities").Preload("Authority").Find(&userList).Error
orderStr := "id desc"
if info.OrderKey != "" {
allowedOrders := map[string]bool{
"id": true,
"username": true,
"nick_name": true,
"phone": true,
"email": true,
}
if allowedOrders[info.OrderKey] {
orderStr = info.OrderKey
if info.Desc {
orderStr = info.OrderKey + " desc"
}
}
}
err = db.Limit(limit).Offset(offset).Order(orderStr).Preload("Authorities").Preload("Authority").Find(&userList).Error
return userList, total, err
}
+4
View File
@@ -85,6 +85,8 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "角色", Method: "PUT", Path: "/authority/updateAuthority", Description: "更新角色信息"},
{ApiGroup: "角色", Method: "POST", Path: "/authority/getAuthorityList", Description: "获取角色列表"},
{ApiGroup: "角色", Method: "POST", Path: "/authority/setDataAuthority", Description: "设置角色资源权限"},
{ApiGroup: "角色", Method: "GET", Path: "/authority/getUsersByAuthority", Description: "获取角色关联用户ID列表"},
{ApiGroup: "角色", Method: "POST", Path: "/authority/setRoleUsers", Description: "全量覆盖角色关联用户"},
{ApiGroup: "casbin", Method: "POST", Path: "/casbin/updateCasbin", Description: "更改角色api权限"},
{ApiGroup: "casbin", Method: "POST", Path: "/casbin/getPolicyPathByAuthorityId", Description: "获取权限列表"},
@@ -118,6 +120,7 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "skills", Method: "POST", Path: "/skills/getSkillList", Description: "获取技能列表"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/getSkillDetail", Description: "获取技能详情"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/saveSkill", Description: "保存技能定义"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/deleteSkill", Description: "删除技能"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/createScript", Description: "创建技能脚本"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/getScript", Description: "读取技能脚本"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/saveScript", Description: "保存技能脚本"},
@@ -132,6 +135,7 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "skills", Method: "POST", Path: "/skills/saveTemplate", Description: "保存技能模板"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/getGlobalConstraint", Description: "读取全局约束"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/saveGlobalConstraint", Description: "保存全局约束"},
{ApiGroup: "skills", Method: "POST", Path: "/skills/packageSkill", Description: "打包技能"},
{ApiGroup: "客户", Method: "PUT", Path: "/customer/customer", Description: "更新客户"},
{ApiGroup: "客户", Method: "POST", Path: "/customer/customer", Description: "创建客户"},
+8
View File
@@ -74,6 +74,8 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/authority/deleteAuthority", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/authority/getAuthorityList", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/authority/setDataAuthority", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/authority/getUsersByAuthority", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/authority/setRoleUsers", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/menu/getMenu", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/menu/getMenuList", V2: "POST"},
@@ -120,6 +122,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/skills/getSkillList", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/getSkillDetail", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/saveSkill", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/deleteSkill", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/createScript", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/getScript", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/saveScript", V2: "POST"},
@@ -134,6 +137,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/skills/saveTemplate", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/getGlobalConstraint", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/saveGlobalConstraint", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/skills/packageSkill", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/customer/customer", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/customer/customer", V2: "PUT"},
@@ -256,6 +260,8 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "8881", V1: "/authority/deleteAuthority", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/authority/getAuthorityList", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/authority/setDataAuthority", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/authority/getUsersByAuthority", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/authority/setRoleUsers", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/menu/getMenu", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/menu/getMenuList", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/menu/addBaseMenu", V2: "POST"},
@@ -297,6 +303,8 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "9528", V1: "/authority/deleteAuthority", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/authority/getAuthorityList", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/authority/setDataAuthority", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/authority/getUsersByAuthority", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/authority/setRoleUsers", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/menu/getMenu", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/menu/getMenuList", V2: "POST"},
+51 -35
View File
@@ -1,18 +1,19 @@
package upload
import (
"context"
"errors"
"fmt"
"mime/multipart"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"go.uber.org/zap"
)
@@ -21,13 +22,13 @@ type AwsS3 struct{}
//@author: [WqyJh](https://github.com/WqyJh)
//@object: *AwsS3
//@function: UploadFile
//@description: Upload file to Aws S3 using aws-sdk-go. See https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/s3-example-basic-bucket-operations.html#s3-examples-bucket-ops-upload-file-to-bucket
//@description: Upload file to Aws S3 using aws-sdk-go-v2. See https://docs.aws.amazon.com/sdk-for-go/v2/developer-guide/s3-example-basic-bucket-operations.html
//@param: file *multipart.FileHeader
//@return: string, string, error
func (*AwsS3) UploadFile(file *multipart.FileHeader) (string, string, error) {
session := newSession()
uploader := s3manager.NewUploader(session)
client := newS3Client()
uploader := manager.NewUploader(client)
fileKey := fmt.Sprintf("%d%s", time.Now().Unix(), file.Filename)
filename := global.GVA_CONFIG.AwsS3.PathPrefix + "/" + fileKey
@@ -38,10 +39,10 @@ func (*AwsS3) UploadFile(file *multipart.FileHeader) (string, string, error) {
}
defer f.Close() // 创建文件 defer 关闭
_, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(global.GVA_CONFIG.AwsS3.Bucket),
Key: aws.String(filename),
Body: f,
_, err := uploader.Upload(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(global.GVA_CONFIG.AwsS3.Bucket),
Key: aws.String(filename),
Body: f,
ContentType: aws.String(file.Header.Get("Content-Type")),
})
if err != nil {
@@ -55,44 +56,59 @@ func (*AwsS3) UploadFile(file *multipart.FileHeader) (string, string, error) {
//@author: [WqyJh](https://github.com/WqyJh)
//@object: *AwsS3
//@function: DeleteFile
//@description: Delete file from Aws S3 using aws-sdk-go. See https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/s3-example-basic-bucket-operations.html#s3-examples-bucket-ops-delete-bucket-item
//@param: file *multipart.FileHeader
//@return: string, string, error
//@description: Delete file from Aws S3 using aws-sdk-go-v2. See https://docs.aws.amazon.com/sdk-for-go/v2/developer-guide/s3-example-basic-bucket-operations.html
//@param: key string
//@return: error
func (*AwsS3) DeleteFile(key string) error {
session := newSession()
svc := s3.New(session)
client := newS3Client()
filename := global.GVA_CONFIG.AwsS3.PathPrefix + "/" + key
bucket := global.GVA_CONFIG.AwsS3.Bucket
_, err := svc.DeleteObject(&s3.DeleteObjectInput{
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(filename),
})
if err != nil {
global.GVA_LOG.Error("function svc.DeleteObject() failed", zap.Any("err", err.Error()))
return errors.New("function svc.DeleteObject() failed, err:" + err.Error())
global.GVA_LOG.Error("function client.DeleteObject() failed", zap.Any("err", err.Error()))
return errors.New("function client.DeleteObject() failed, err:" + err.Error())
}
_ = svc.WaitUntilObjectNotExists(&s3.HeadObjectInput{
waiter := s3.NewObjectNotExistsWaiter(client)
_ = waiter.Wait(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(filename),
})
}, 30*time.Second)
return nil
}
// newSession Create S3 session
func newSession() *session.Session {
sess, _ := session.NewSession(&aws.Config{
Region: aws.String(global.GVA_CONFIG.AwsS3.Region),
Endpoint: aws.String(global.GVA_CONFIG.AwsS3.Endpoint), //minio在这里设置地址,可以兼容
S3ForcePathStyle: aws.Bool(global.GVA_CONFIG.AwsS3.S3ForcePathStyle),
DisableSSL: aws.Bool(global.GVA_CONFIG.AwsS3.DisableSSL),
Credentials: credentials.NewStaticCredentials(
global.GVA_CONFIG.AwsS3.SecretID,
global.GVA_CONFIG.AwsS3.SecretKey,
// newS3Client creates an S3 v2 client with static credentials and optional custom endpoint.
// minio在这里设置Endpoint地址,可以兼容
func newS3Client() *s3.Client {
cfg := global.GVA_CONFIG.AwsS3
awsCfg, _ := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(cfg.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.SecretID,
cfg.SecretKey,
"",
),
)),
)
return s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
endpoint := cfg.Endpoint
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
if cfg.DisableSSL {
endpoint = "http://" + endpoint
} else {
endpoint = "https://" + endpoint
}
}
o.BaseEndpoint = aws.String(endpoint)
}
o.UsePathStyle = cfg.S3ForcePathStyle
})
return sess
}
+30 -30
View File
@@ -1,16 +1,17 @@
package upload
import (
"context"
"errors"
"fmt"
"mime/multipart"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"go.uber.org/zap"
)
@@ -18,8 +19,8 @@ import (
type CloudflareR2 struct{}
func (c *CloudflareR2) UploadFile(file *multipart.FileHeader) (fileUrl string, fileName string, err error) {
session := c.newSession()
client := s3manager.NewUploader(session)
client := c.newR2Client()
uploader := manager.NewUploader(client)
fileKey := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename)
fileName = fmt.Sprintf("%s/%s", global.GVA_CONFIG.CloudflareR2.Path, fileKey)
@@ -30,56 +31,55 @@ func (c *CloudflareR2) UploadFile(file *multipart.FileHeader) (fileUrl string, f
}
defer f.Close() // 创建文件 defer 关闭
input := &s3manager.UploadInput{
_, err = uploader.Upload(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(global.GVA_CONFIG.CloudflareR2.Bucket),
Key: aws.String(fileName),
Body: f,
}
_, err = client.Upload(input)
})
if err != nil {
global.GVA_LOG.Error("function uploader.Upload() failed", zap.Any("err", err.Error()))
return "", "", err
}
return fmt.Sprintf("%s/%s", global.GVA_CONFIG.CloudflareR2.BaseURL,
fileName),
fileKey,
nil
return fmt.Sprintf("%s/%s", global.GVA_CONFIG.CloudflareR2.BaseURL, fileName), fileKey, nil
}
func (c *CloudflareR2) DeleteFile(key string) error {
session := newSession()
svc := s3.New(session)
client := c.newR2Client()
filename := global.GVA_CONFIG.CloudflareR2.Path + "/" + key
bucket := global.GVA_CONFIG.CloudflareR2.Bucket
_, err := svc.DeleteObject(&s3.DeleteObjectInput{
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(filename),
})
if err != nil {
global.GVA_LOG.Error("function svc.DeleteObject() failed", zap.Any("err", err.Error()))
return errors.New("function svc.DeleteObject() failed, err:" + err.Error())
global.GVA_LOG.Error("function client.DeleteObject() failed", zap.Any("err", err.Error()))
return errors.New("function client.DeleteObject() failed, err:" + err.Error())
}
_ = svc.WaitUntilObjectNotExists(&s3.HeadObjectInput{
waiter := s3.NewObjectNotExistsWaiter(client)
_ = waiter.Wait(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(filename),
})
}, 30*time.Second)
return nil
}
func (*CloudflareR2) newSession() *session.Session {
endpoint := fmt.Sprintf("%s.r2.cloudflarestorage.com", global.GVA_CONFIG.CloudflareR2.AccountID)
func (*CloudflareR2) newR2Client() *s3.Client {
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", global.GVA_CONFIG.CloudflareR2.AccountID)
return session.Must(session.NewSession(&aws.Config{
Region: aws.String("auto"),
Endpoint: aws.String(endpoint),
Credentials: credentials.NewStaticCredentials(
cfg, _ := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
global.GVA_CONFIG.CloudflareR2.AccessKeyID,
global.GVA_CONFIG.CloudflareR2.SecretAccessKey,
"",
),
}))
)),
)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gin-vue-admin",
"version": "2.8.9",
"version": "2.9.0",
"private": true,
"scripts": {
"dev": "node openDocument.js && vite --host --mode development",
+30
View File
@@ -174,3 +174,33 @@ export const enterSyncApi = (data) => {
data
})
}
/**
* 获取拥有指定API权限的角色ID列表
* @param {string} path API路径
* @param {string} method 请求方法
* @returns {Promise<number[]>} 角色ID数组
*/
export const getApiRoles = (path, method) => {
return service({
url: '/api/getApiRoles',
method: 'get',
params: { path, method }
})
}
/**
* 全量覆盖某API关联的角色列表
* @param {Object} data
* @param {string} data.path API路径
* @param {string} data.method 请求方法
* @param {number[]} data.authorityIds 角色ID列表
* @returns {Promise}
*/
export const setApiRoles = (data) => {
return service({
url: '/api/setApiRoles',
method: 'post',
data
})
}
+28
View File
@@ -83,3 +83,31 @@ export const updateAuthority = (data) => {
data
})
}
/**
* 获取拥有指定角色的用户ID列表
* @param {number} authorityId 角色ID
* @returns {Promise<number[]>} 用户ID数组
*/
export const getUsersByAuthorityId = (authorityId) => {
return service({
url: '/authority/getUsersByAuthority',
method: 'get',
params: { authorityId }
})
}
/**
* 全量覆盖某角色关联的用户列表
* @param {Object} data
* @param {number} data.authorityId 角色ID
* @param {number[]} data.userIds 用户ID列表
* @returns {Promise}
*/
export const setRoleUsers = (data) => {
return service({
url: '/authority/setRoleUsers',
method: 'post',
data
})
}
+28
View File
@@ -111,3 +111,31 @@ export const getBaseMenuById = (data) => {
data
})
}
/**
* 获取拥有指定菜单的角色ID列表
* @param {number} menuId 菜单ID
* @returns {Promise<number[]>} 角色ID数组
*/
export const getMenuRoles = (menuId) => {
return service({
url: '/menu/getMenuRoles',
method: 'get',
params: { menuId }
})
}
/**
* 全量覆盖某菜单关联的角色列表
* @param {Object} data
* @param {number} data.menuId 菜单ID
* @param {number[]} data.authorityIds 角色ID列表
* @returns {Promise}
*/
export const setMenuRoles = (data) => {
return service({
url: '/menu/setMenuRoles',
method: 'post',
data
})
}
+25
View File
@@ -31,6 +31,14 @@ export const saveSkill = (data) => {
})
}
export const deleteSkill = (data) => {
return service({
url: '/skills/deleteSkill',
method: 'post',
data
})
}
export const createSkillScript = (data) => {
return service({
url: '/skills/createScript',
@@ -142,3 +150,20 @@ export const saveGlobalConstraint = (data) => {
data
})
}
export const packageSkill = (data) => {
return service({
url: '/skills/packageSkill',
method: 'post',
data,
responseType: 'blob'
})
}
export const downloadOnlineSkill = (data) => {
return service({
url: '/skills/downloadOnlineSkill',
method: 'post',
data
})
}
+3
View File
@@ -60,6 +60,7 @@
"/src/view/superAdmin/params/sysParams.vue": "SysParams",
"/src/view/superAdmin/user/user.vue": "User",
"/src/view/system/state.vue": "State",
"/src/view/systemTools/apiToken/index.vue": "Index",
"/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog",
"/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog",
"/src/view/systemTools/autoCode/index.vue": "AutoCode",
@@ -72,7 +73,9 @@
"/src/view/systemTools/formCreate/index.vue": "FormGenerator",
"/src/view/systemTools/index.vue": "System",
"/src/view/systemTools/installPlugin/index.vue": "Index",
"/src/view/systemTools/loginLog/index.vue": "Index",
"/src/view/systemTools/pubPlug/pubPlug.vue": "PubPlug",
"/src/view/systemTools/skills/index.vue": "Skills",
"/src/view/systemTools/sysError/sysError.vue": "SysError",
"/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/version/version.vue": "SysVersion",
+4 -3
View File
@@ -1,6 +1,6 @@
<template>
<template>
<div
class="rounded-xl border border-black/10 bg-white text-black/80 dark:text-slate-400 dark:bg-slate-900 dark:text-white/80"
class="rounded-lg border border-black/10 bg-white text-black/80 dark:text-slate-400 dark:bg-slate-900 dark:text-white/80"
:class="[customClass || '', withoutPadding ? 'p-0' : 'p-4']"
>
<div v-if="title" class="flex justify-between items-center">
@@ -11,7 +11,7 @@
v-if="showAction"
class="text-xs text-black/60 dark:text-white/60 hover:text-active cursor-pointer"
>
查看更多
更多
</div>
</div>
<div :class="title ? 'mt-3' : ''">
@@ -42,3 +42,4 @@
</script>
<style scoped lang="scss"></style>
+49 -54
View File
@@ -1,72 +1,67 @@
<template>
<el-scrollbar>
<div
v-for="(item, index) in notices"
:key="index"
class="flex items-center gap-3 py-1"
>
<div
class="shrink-0 rounded-full border border-black/10 px-2 py-0.5 text-[11px] leading-4 text-black/70 dark:border-white/10 dark:text-white/70"
>
{{ item.typeTitle }}
</div>
<el-tooltip effect="light" :content="item.title" placement="top">
<div class="min-w-0 text-xs text-black/70 dark:text-white/70 line-clamp-1">
{{ item.title }}
<template>
<div class="space-y-3">
<el-scrollbar max-height="320px">
<div class="space-y-2 pr-1">
<div
v-for="(item, index) in notices"
:key="index"
class="group rounded-lg border border-black/10 bg-white/70 p-3 transition-all duration-200 hover:-translate-y-0.5 hover:border-black/20 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.02] dark:hover:border-white/20"
>
<div class="flex items-start gap-3">
<span class="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" :class="item.dotClass" />
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="rounded-md px-2 py-0.5 text-[11px] font-semibold leading-4" :class="item.tagClass">
{{ item.typeTitle }}
</span>
<span class="shrink-0 text-[11px] text-black/45 dark:text-white/45">{{ item.time }}</span>
</div>
<el-tooltip effect="light" :content="item.title" placement="top">
<p class="mt-1.5 line-clamp-2 text-sm text-black/75 dark:text-white/75">
{{ item.title }}
</p>
</el-tooltip>
</div>
</div>
</div>
</el-tooltip>
</div>
</el-scrollbar>
</div>
</el-scrollbar>
</div>
</template>
<script setup>
const notices = [
{
type: 'success',
typeTitle: '通知',
title: '授权后将进入专属飞书群,获取官方辅助。'
time: '今天',
title: '购买商业授权后可进入专属技术支持通道,加快问题排查和版本升级效率。',
dotClass: 'bg-cyan-500',
tagClass: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200'
},
{
type: 'warning',
typeTitle: '警告',
title: '授权可获得插件市场极大优惠价格。'
typeTitle: '活动',
time: '2天前',
title: '插件市场正在进行限时优惠活动,授权用户可获得更低的插件采购成本。',
dotClass: 'bg-emerald-500',
tagClass: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
},
{
type: 'danger',
typeTitle: '违规',
title: '未授权商用将有可能被资源采集工具爬取并追责。'
typeTitle: '合规',
time: '3天前',
title: '未授权商用存在合规风险,建议团队尽快完成授权以保障项目持续交付。',
dotClass: 'bg-amber-500',
tagClass: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200'
},
{
type: 'info',
typeTitle: '信息',
title: '再次感谢您对开源事业的支持'
},
{
type: 'primary',
typeTitle: '公告',
title: '让创意更有价值。'
},
{
type: 'success',
typeTitle: '通知',
title: '让劳动更有意义。'
},
{
type: 'warning',
typeTitle: '警告',
title: '让思维更有深度。'
},
{
type: 'danger',
typeTitle: '错误',
title: '让生活更有趣味。'
},
{
type: 'info',
typeTitle: '信息',
title: '让公司更有活力。'
typeTitle: '服务',
time: '5天前',
title: '授权用户可获得官方长期维护承诺,包含安全修复与关键版本升级支持。',
dotClass: 'bg-violet-500',
tagClass: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200'
}
]
</script>
<style scoped lang="scss"></style>
@@ -1,44 +1,46 @@
<template>
<div class="mt-4 w-full">
<div class="text-xs tracking-wide text-black/60 dark:text-white/60">快捷入口</div>
<div class="mt-3 grid grid-cols-3 gap-3 sm:grid-cols-4">
<div
v-for="(item, index) in shortcuts"
:key="index"
class="flex flex-col items-center group cursor-pointer"
@click="toPath(item)"
>
<div
class="w-10 h-10 rounded-lg border border-black/10 dark:border-white/10 flex items-center justify-center text-black/70 dark:text-white/70 group-hover:bg-[var(--el-color-primary)] group-hover:text-white transition-colors"
<template>
<div class="h-full space-y-5">
<div>
<div class="mb-2 text-xs tracking-wide text-black/55 dark:text-white/55">常用入口</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-for="(item, index) in shortcuts"
:key="index"
class="group flex w-full items-center gap-3 rounded-lg border border-black/10 bg-white/70 p-2.5 text-left transition-all duration-200 hover:border-[var(--el-color-primary)] hover:shadow-sm dark:border-white/10 dark:bg-white/[0.02]"
type="button"
@click="toPath(item)"
>
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div class="mt-2 text-[11px] text-black/70 dark:text-white/70">
{{ item.title }}
</div>
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-700 transition-colors group-hover:bg-[var(--el-color-primary)] group-hover:text-white dark:bg-slate-800 dark:text-slate-200"
>
<el-icon><component :is="item.icon" /></el-icon>
</span>
<span class="min-w-0 text-sm text-black/75 dark:text-white/75">{{ item.title }}</span>
</button>
</div>
</div>
<div class="mt-6 text-xs tracking-wide text-black/60 dark:text-white/60">最近访问</div>
<div class="mt-3 grid grid-cols-3 gap-3 sm:grid-cols-4">
<div
v-for="(item, index) in recentVisits"
:key="index"
class="flex flex-col items-center group cursor-pointer"
@click="openLink(item)"
>
<div
class="w-10 h-10 rounded-lg border border-black/10 dark:border-white/10 flex items-center justify-center text-black/70 dark:text-white/70 group-hover:bg-[var(--el-color-primary)] group-hover:text-white transition-colors"
<div>
<div class="mb-2 text-xs tracking-wide text-black/55 dark:text-white/55">常用外链</div>
<div class="space-y-2">
<button
v-for="(item, index) in recentVisits"
:key="index"
class="flex w-full items-center justify-between rounded-lg border border-black/10 bg-white/70 px-3 py-2 text-left transition-all duration-200 hover:border-[var(--el-color-primary)] hover:shadow-sm dark:border-white/10 dark:bg-white/[0.02]"
type="button"
@click="openLink(item)"
>
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div class="mt-2 text-[11px] text-black/70 dark:text-white/70">
{{ item.title }}
</div>
<span class="flex items-center gap-2 text-sm text-black/75 dark:text-white/75">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.title }}
</span>
<span class="text-xs text-black/45 dark:text-white/45">打开</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import {
Menu,
@@ -51,6 +53,7 @@
Memo
} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const toPath = (item) => {
@@ -58,52 +61,22 @@
}
const openLink = (item) => {
window.open(item.path, '_blank')
window.open(item.path, '_blank', 'noopener,noreferrer')
}
const shortcuts = [
{
icon: Menu,
title: '菜单管理',
path: 'menu'
},
{
icon: Link,
title: 'API管理',
path: 'api'
},
{
icon: Service,
title: '角色管理',
path: 'authority'
},
{
icon: User,
title: '用户管理',
path: 'user'
},
{
icon: Files,
title: '自动化包',
path: 'autoPkg'
},
{
icon: Memo,
title: '自动代码',
path: 'autoCode'
}
{ icon: Menu, title: '菜单管理', path: 'menu' },
{ icon: Link, title: 'API管理', path: 'api' },
{ icon: Service, title: '角色管理', path: 'authority' },
{ icon: User, title: '用户管理', path: 'user' },
{ icon: Files, title: '自动化包', path: 'autoPkg' },
{ icon: Memo, title: '自动代码', path: 'autoCode' }
]
const recentVisits = [
{
icon: Reading,
title: '授权购买',
path: 'https://plugin.gin-vue-admin.com/license'
},
{
icon: Document,
title: '插件市场',
path: 'https://plugin.gin-vue-admin.com/#/layout/home'
}
{ icon: Reading, title: '授权购买', path: 'https://plugin.gin-vue-admin.com/license' },
{ icon: Document, title: '插件市场', path: 'https://plugin.gin-vue-admin.com/#/layout/home' },
{ icon: Link, title: '项目仓库', path: 'https://github.com/flipped-aurora/gin-vue-admin' }
]
</script>
+67 -28
View File
@@ -1,8 +1,27 @@
<template>
<div
class="h-full gva-container2 overflow-auto bg-white text-black dark:bg-slate-800 dark:text-white"
>
<div class="p-4 lg:p-6">
<template>
<div class="h-full gva-container2 overflow-auto bg-slate-50/60 dark:bg-slate-900">
<div class="space-y-4 p-4 lg:p-6">
<section
class="relative overflow-hidden rounded-xl border border-slate-200/80 bg-white px-5 py-6 shadow-sm dark:border-slate-700 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900"
>
<div class="relative flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs tracking-[0.2em] text-slate-500 dark:text-slate-400">DASHBOARD</p>
<h1 class="mt-2 text-xl font-semibold text-slate-900 dark:text-slate-100 lg:text-2xl">
欢迎回来,开始今天的Coding节奏
</h1>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
{{ today }} · 已为你聚合核心业务数据、插件动态和系统公告
</p>
</div>
<div class="flex items-center gap-2">
<el-button type="primary" @click="goLicense">购买商业授权</el-button>
<el-button @click="goPluginMarket">插件市场</el-button>
</div>
</div>
</section>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<gva-card>
<gva-chart :type="1" title="访问人数" />
@@ -15,41 +34,52 @@
</gva-card>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-12 items-start">
<div class="grid grid-cols-1 gap-4 xl:col-span-8 self-start content-start">
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-12">
<div class="grid grid-cols-1 gap-4 content-start xl:col-span-8 xl:h-full">
<gva-card title="内容数据">
<gva-chart :type="4" />
</gva-card>
<div class="grid grid-cols-1 gap-4">
<gva-card title="最新插件">
<gva-plugin-table />
</gva-card>
</div>
<gva-card title="最新插件">
<gva-plugin-table />
</gva-card>
<div class="grid grid-cols-1 gap-4">
<gva-card title="最新更新">
<gva-table />
</gva-card>
</div>
<gva-card title="最新更新">
<gva-table />
</gva-card>
</div>
<div class="grid grid-cols-1 gap-4 xl:col-span-4 self-start content-start">
<gva-card title="快捷功能" show-action>
<div class="flex flex-col gap-4 xl:col-span-4 xl:h-full">
<gva-card title="快捷功能" show-action custom-class="min-h-[300px]">
<gva-quick-link />
</gva-card>
<gva-card title="公告" show-action>
<gva-card title="公告" show-action custom-class="min-h-[300px]">
<gva-notice />
</gva-card>
<gva-card title="文档" show-action>
<gva-card title="文档" show-action custom-class="min-h-[120px]">
<gva-wiki />
</gva-card>
<gva-card
without-padding
custom-class="overflow-hidden"
<div
class="relative min-h-[200px] flex-1 overflow-hidden rounded-lg border border-slate-200 bg-slate-900 p-5 text-white shadow-sm dark:border-slate-700"
>
<gva-banner />
</gva-card>
<div class="relative">
<div class="inline-flex rounded-full bg-white/10 px-3 py-1 text-xs">商业授权</div>
<h3 class="mt-3 text-lg font-semibold">解锁完整商用支持与专属服务</h3>
<p class="mt-2 text-sm text-slate-200/90">
购买授权后可获得专属支持通道、插件优惠与商用合规保障,帮助团队更稳定地推进项目交付。
</p>
<div class="mt-4 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-white/10 px-2.5 py-1">专属技术支持</span>
<span class="rounded-full bg-white/10 px-2.5 py-1">插件优惠权益</span>
<span class="rounded-full bg-white/10 px-2.5 py-1">商用授权凭证</span>
</div>
<div class="mt-5 flex items-center gap-3">
<el-button type="primary" @click="goLicense">立即购买</el-button>
<el-button link class="!text-cyan-300" @click="goPluginMarket">查看插件市场</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -65,8 +95,7 @@
GvaWiki,
GvaNotice,
GvaQuickLink,
GvaCard,
GvaBanner
GvaCard
} from './components'
const today = computed(() => {
@@ -81,9 +110,19 @@
return new Date().toISOString().slice(0, 10)
}
})
const goLicense = () => {
window.open('https://plugin.gin-vue-admin.com/license', '_blank', 'noopener,noreferrer')
}
const goPluginMarket = () => {
window.open('https://plugin.gin-vue-admin.com', '_blank', 'noopener,noreferrer')
}
defineOptions({
name: 'Dashboard'
})
</script>
<style lang="scss" scoped></style>
+88 -2
View File
@@ -112,6 +112,14 @@
>
编辑
</el-button>
<el-button
icon="user"
type="primary"
link
@click="openAssignRoleDrawer(scope.row)"
>
分配角色
</el-button>
<el-button
icon="delete"
type="primary"
@@ -395,6 +403,35 @@
</el-form-item>
</el-form>
</el-drawer>
<!-- 分配给角色抽屉 -->
<el-drawer
v-model="assignRoleDrawerVisible"
:size="appStore.drawerSize"
:show-close="false"
destroy-on-close
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">分配角色 - {{ assignApiRow.description }}</span>
<div>
<el-button @click="assignRoleDrawerVisible = false">取 消</el-button>
<el-button type="primary" :loading="assignRoleSubmitting" @click="confirmAssignRole">确 定</el-button>
</div>
</div>
</template>
<warning-bar title="注:保存时将全量覆盖该API的角色关联关系,并自动刷新Casbin缓存" />
<el-tree
ref="roleTreeRef"
v-loading="assignRoleLoading"
:data="authorityTreeData"
:props="{ label: 'authorityName', children: 'children' }"
node-key="authorityId"
show-checkbox
check-strictly
default-expand-all
/>
</el-drawer>
</div>
</template>
@@ -410,11 +447,14 @@
syncApi,
getApiGroups,
ignoreApi,
enterSyncApi
enterSyncApi,
getApiRoles,
setApiRoles
} from '@/api/api'
import { getAuthorityList } from '@/api/authority'
import { toSQLLine } from '@/utils/stringFun'
import WarningBar from '@/components/warningBar/warningBar.vue'
import { ref } from 'vue'
import { ref, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import ExportExcel from '@/components/exportExcel/exportExcel.vue'
import ExportTemplate from '@/components/exportExcel/exportTemplate.vue'
@@ -823,6 +863,52 @@
}
}
}
// 分配给角色
const assignRoleDrawerVisible = ref(false)
const assignApiRow = ref({})
const authorityTreeData = ref([])
const assignRoleLoading = ref(false)
const assignRoleSubmitting = ref(false)
const roleTreeRef = ref(null)
const openAssignRoleDrawer = async (row) => {
assignApiRow.value = row
assignRoleDrawerVisible.value = true
assignRoleLoading.value = true
const [authRes, rolesRes] = await Promise.all([
getAuthorityList(),
getApiRoles(row.path, row.method)
])
if (authRes.code === 0) {
authorityTreeData.value = authRes.data
}
if (rolesRes.code === 0 && rolesRes.data) {
nextTick(() => {
roleTreeRef.value?.setCheckedKeys(rolesRes.data)
})
}
assignRoleLoading.value = false
}
const confirmAssignRole = async () => {
assignRoleSubmitting.value = true
try {
const checkedKeys = roleTreeRef.value?.getCheckedKeys(false) || []
const res = await setApiRoles({
path: assignApiRow.value.path,
method: assignApiRow.value.method,
authorityIds: checkedKeys
})
if (res.code === 0) {
ElMessage({ type: 'success', message: '分配成功!' })
assignRoleDrawerVisible.value = false
}
} catch {
ElMessage({ type: 'error', message: '分配失败,请重试' })
}
assignRoleSubmitting.value = false
}
</script>
<style scoped lang="scss">
+176 -3
View File
@@ -20,7 +20,7 @@
min-width="180"
prop="authorityName"
/>
<el-table-column align="left" label="操作" width="460">
<el-table-column align="left" label="操作" width="560">
<template #default="scope">
<el-button
icon="setting"
@@ -29,6 +29,13 @@
@click="openDrawer(scope.row)"
>设置权限</el-button
>
<el-button
icon="user"
type="primary"
link
@click="openAssignDrawer(scope.row)"
>分配给用户</el-button
>
<el-button
icon="plus"
type="primary"
@@ -134,6 +141,65 @@
</el-tab-pane>
</el-tabs>
</el-drawer>
<!-- 分配给用户抽屉 -->
<el-drawer
v-model="assignDrawerVisible"
:size="appStore.drawerSize"
:show-close="false"
destroy-on-close
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">分配用户 - {{ assignRow.authorityName }}</span>
<div>
<el-button @click="assignDrawerVisible = false">取 消</el-button>
<el-button type="primary" :loading="assignSubmitting" @click="confirmAssign">确 定</el-button>
</div>
</div>
</template>
<warning-bar title="注:保存时将全量覆盖该角色的用户关联关系;若用户仅剩此一个角色,移除后其主角色保持不变" />
<div class="gva-search-box">
<el-form :inline="true" :model="userSearchInfo">
<el-form-item label="用户名">
<el-input v-model="userSearchInfo.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="userSearchInfo.nickName" placeholder="请输入昵称" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="searchUserData">查 询</el-button>
<el-button icon="refresh" @click="resetUserSearch">重 置</el-button>
</el-form-item>
</el-form>
</div>
<el-table
ref="userTableRef"
v-loading="assignLoading"
:data="userTableData"
row-key="ID"
:default-sort="{ prop: 'ID', order: 'descending' }"
@sort-change="sortChange"
@select="handleSelect"
@select-all="handleSelectAll"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="ID" width="80" sortable="custom" />
<el-table-column label="用户名" prop="userName" min-width="120" />
<el-table-column label="昵称" prop="nickName" min-width="120" />
</el-table>
<div class="flex justify-center mt-4">
<el-pagination
:current-page="userSearchInfo.page"
:page-size="userSearchInfo.pageSize"
:page-sizes="[10, 20, 50]"
:total="userTotal"
layout="total, sizes, prev, pager, next"
@current-change="handleUserPageChange"
@size-change="handleUserSizeChange"
/>
</div>
</el-drawer>
</div>
</template>
@@ -143,17 +209,21 @@
deleteAuthority,
createAuthority,
updateAuthority,
copyAuthority
copyAuthority,
getUsersByAuthorityId,
setRoleUsers
} from '@/api/authority'
import { getUserList } from '@/api/user'
import Menus from '@/view/superAdmin/authority/components/menus.vue'
import Apis from '@/view/superAdmin/authority/components/apis.vue'
import Datas from '@/view/superAdmin/authority/components/datas.vue'
import WarningBar from '@/components/warningBar/warningBar.vue'
import { ref } from 'vue'
import { ref, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAppStore } from "@/pinia"
import { toSQLLine } from '@/utils/stringFun'
defineOptions({
name: 'Authority'
@@ -403,6 +473,109 @@
authorityForm.value && authorityForm.value.clearValidate()
authorityFormVisible.value = true
}
// 分配给用户
const assignDrawerVisible = ref(false)
const assignRow = ref({})
const userTableData = ref([])
const userTotal = ref(0)
const userSearchInfo = ref({ page: 1, pageSize: 10, username: '', nickName: '', orderKey: 'id', desc: true })
const assignLoading = ref(false)
const assignSubmitting = ref(false)
const userTableRef = ref(null)
const selectedUserIds = ref(new Set())
const openAssignDrawer = async (row) => {
assignRow.value = row
userSearchInfo.value = { page: 1, pageSize: 10, username: '', nickName: '' }
selectedUserIds.value = new Set()
assignDrawerVisible.value = true
const res = await getUsersByAuthorityId(row.authorityId)
if (res.code === 0 && res.data) {
selectedUserIds.value = new Set(res.data)
}
getUserData()
}
const getUserData = async () => {
assignLoading.value = true
const res = await getUserList(userSearchInfo.value)
if (res.code === 0) {
userTableData.value = res.data.list
userTotal.value = res.data.total
await nextTick()
userTableData.value.forEach((user) => {
userTableRef.value && userTableRef.value.toggleRowSelection(user, selectedUserIds.value.has(user.ID))
})
}
assignLoading.value = false
}
const handleSelect = (selection, row) => {
if (selection.some(u => u.ID === row.ID)) {
selectedUserIds.value.add(row.ID)
} else {
selectedUserIds.value.delete(row.ID)
}
}
const handleSelectAll = (selection) => {
const selectedIds = new Set(selection.map(u => u.ID))
userTableData.value.forEach((user) => {
if (selectedIds.has(user.ID)) {
selectedUserIds.value.add(user.ID)
} else {
selectedUserIds.value.delete(user.ID)
}
})
}
const sortChange = ({ prop, order }) => {
if (prop) {
userSearchInfo.value.orderKey = prop === 'ID' ? 'id' : toSQLLine(prop)
userSearchInfo.value.desc = order === 'descending'
}
getUserData()
}
const searchUserData = () => {
userSearchInfo.value.page = 1
getUserData()
}
const resetUserSearch = () => {
userSearchInfo.value = { page: 1, pageSize: 10, username: '', nickName: '' }
getUserData()
}
const handleUserPageChange = (page) => {
userSearchInfo.value.page = page
getUserData()
}
const handleUserSizeChange = (size) => {
userSearchInfo.value.pageSize = size
userSearchInfo.value.page = 1
getUserData()
}
const confirmAssign = async () => {
assignSubmitting.value = true
try {
const res = await setRoleUsers({
authorityId: assignRow.value.authorityId,
userIds: [...selectedUserIds.value]
})
if (res.code === 0) {
ElMessage({ type: 'success', message: '分配成功!' })
assignDrawerVisible.value = false
}
} catch {
ElMessage({ type: 'error', message: '分配失败,请重试' })
}
assignSubmitting.value = false
}
</script>
<style lang="scss">
+99 -2
View File
@@ -90,6 +90,14 @@
>
编辑
</el-button>
<el-button
type="primary"
link
icon="user"
@click="openAssignRoleDrawer(scope.row)"
>
分配角色
</el-button>
<el-button
type="primary"
link
@@ -508,6 +516,35 @@
</el-table>
</div>
</el-drawer>
<!-- 分配给角色抽屉 -->
<el-drawer
v-model="assignRoleDrawerVisible"
:size="appStore.drawerSize"
:show-close="false"
destroy-on-close
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">分配角色 - {{ assignMenuRow.meta?.title }}</span>
<div>
<el-button @click="assignRoleDrawerVisible = false">取 消</el-button>
<el-button type="primary" :loading="assignRoleSubmitting" @click="confirmAssignRole">确 定</el-button>
</div>
</div>
</template>
<warning-bar title="注:保存时将全量覆盖该菜单的角色关联关系;作为角色首页的菜单不可取消勾选" />
<el-tree
ref="roleTreeRef"
v-loading="assignRoleLoading"
:data="authorityTreeData"
:props="{ label: 'authorityName', children: 'children', disabled: isRoleDisabled }"
node-key="authorityId"
show-checkbox
check-strictly
default-expand-all
/>
</el-drawer>
</div>
</template>
@@ -517,12 +554,15 @@
getMenuList,
addBaseMenu,
deleteBaseMenu,
getBaseMenuById
getBaseMenuById,
getMenuRoles,
setMenuRoles
} from '@/api/menu'
import { getAuthorityList } from '@/api/authority'
import icon from '@/view/superAdmin/menu/icon.vue'
import WarningBar from '@/components/warningBar/warningBar.vue'
import { canRemoveAuthorityBtnApi } from '@/api/authorityBtn'
import { reactive, ref } from 'vue'
import { reactive, ref, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { QuestionFilled, InfoFilled, Delete } from '@element-plus/icons-vue'
import { toDoc } from '@/utils/doc'
@@ -771,6 +811,63 @@
setOptions()
dialogFormVisible.value = true
}
// 分配给角色
const assignRoleDrawerVisible = ref(false)
const assignMenuRow = ref({})
const authorityTreeData = ref([])
const assignRoleLoading = ref(false)
const assignRoleSubmitting = ref(false)
const roleTreeRef = ref(null)
const defaultRouterAuthorityIds = ref(new Set())
const isRoleDisabled = (data) => {
return defaultRouterAuthorityIds.value.has(data.authorityId)
}
const openAssignRoleDrawer = async (row) => {
assignMenuRow.value = row
defaultRouterAuthorityIds.value = new Set()
assignRoleDrawerVisible.value = true
assignRoleLoading.value = true
// 并行加载角色树和当前菜单已分配的角色
const [authRes, rolesRes] = await Promise.all([
getAuthorityList(),
getMenuRoles(row.ID)
])
if (authRes.code === 0) {
authorityTreeData.value = authRes.data
}
if (rolesRes.code === 0 && rolesRes.data) {
if (rolesRes.data.defaultRouterAuthorityIds) {
defaultRouterAuthorityIds.value = new Set(rolesRes.data.defaultRouterAuthorityIds)
}
nextTick(() => {
roleTreeRef.value?.setCheckedKeys(rolesRes.data.authorityIds || [])
})
}
assignRoleLoading.value = false
}
const confirmAssignRole = async () => {
assignRoleSubmitting.value = true
try {
const checkedKeys = roleTreeRef.value?.getCheckedKeys(false) || []
const halfCheckedKeys = roleTreeRef.value?.getHalfCheckedKeys() || []
const authorityIds = [...checkedKeys, ...halfCheckedKeys]
const res = await setMenuRoles({
menuId: assignMenuRow.value.ID,
authorityIds
})
if (res.code === 0) {
ElMessage({ type: 'success', message: '分配成功!' })
assignRoleDrawerVisible.value = false
}
} catch {
ElMessage({ type: 'error', message: '分配失败,请重试' })
}
assignRoleSubmitting.value = false
}
</script>
<style scoped lang="scss">
+17 -2
View File
@@ -29,13 +29,13 @@
>新增用户</el-button
>
</div>
<el-table :data="tableData" row-key="ID">
<el-table :data="tableData" row-key="ID" :default-sort="{ prop: 'ID', order: 'descending' }" @sort-change="sortChange">
<el-table-column align="left" label="头像" min-width="75">
<template #default="scope">
<CustomPic style="margin-top: 8px" :pic-src="scope.row.headerImg" />
</template>
</el-table-column>
<el-table-column align="left" label="ID" min-width="50" prop="ID" />
<el-table-column align="left" label="ID" min-width="50" prop="ID" sortable="custom" />
<el-table-column
align="left"
label="用户名"
@@ -267,6 +267,7 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import SelectImage from '@/components/selectImage/selectImage.vue'
import { useAppStore } from "@/pinia";
import { toSQLLine } from '@/utils/stringFun'
defineOptions({
name: 'User'
@@ -293,6 +294,8 @@
phone: '',
email: ''
}
orderKey.value = 'id'
desc.value = true
getTableData()
}
// 初始化相关
@@ -321,6 +324,16 @@
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const orderKey = ref('id')
const desc = ref(true)
const sortChange = ({ prop, order }) => {
if (prop) {
orderKey.value = prop === 'ID' ? 'id' : toSQLLine(prop)
desc.value = order === 'descending'
}
getTableData()
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
@@ -337,6 +350,8 @@
const table = await getUserList({
page: page.value,
pageSize: pageSize.value,
orderKey: orderKey.value,
desc: desc.value,
...searchInfo.value
})
if (table.code === 0) {
+192 -2
View File
@@ -1,9 +1,35 @@
<template>
<fc-designer ref="designer" :config="config" height="calc(100vh - 160px)" />
<div class="form-designer-container">
<fc-designer ref="designer" :config="config" height="calc(100vh - 160px)">
<template #handle>
<el-button type="primary" size="small" plain @click="exportVueTemplate">
解析为 Vue 原生标签
</el-button>
</template>
</fc-designer>
<el-dialog v-model="dialogVisible" title="生成的 Vue 模板代码" width="70%" top="5vh">
<el-input
type="textarea"
:rows="25"
v-model="vueCode"
readonly
class="code-input"
resize="none"
/>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" @click="copyCode">一键复制</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import FcDesigner from '@form-create/designer'
defineOptions({
@@ -11,8 +37,172 @@
})
const designer = ref(null)
const dialogVisible = ref(false)
const vueCode = ref('')
const config = {
fieldReadonly: false
fieldReadonly: false,
useTemplate: true
}
const kebabCase = (str) => {
return str.replace(/([A-Z])/g, '-$1').toLowerCase()
}
const generateVueCode = (rules, options) => {
let formDataInit = []
let formRules = []
const parseRule = (rule) => {
if (rule.type === 'row') {
const propsStr = rule.props ? Object.entries(rule.props).map(([k, v]) => `:${k}="${v}"`).join(' ') : ''
let childrenStr = rule.children ? rule.children.map(c => parseRule(c)).join('\n') : ''
return `\n <el-row ${propsStr}>${childrenStr}\n </el-row>`
}
if (rule.type === 'col') {
const propsStr = rule.props ? Object.entries(rule.props).map(([k, v]) => `:${k}="${v}"`).join(' ') : ''
let childrenStr = rule.children ? rule.children.map(c => parseRule(c)).join('\n') : ''
return `\n <el-col ${propsStr}>${childrenStr}\n </el-col>`
}
if (!rule.field) return ''
let tag = rule.type
const typeMap = {
input: 'el-input',
inputNumber: 'el-input-number',
select: 'el-select',
radio: 'el-radio-group',
checkbox: 'el-checkbox-group',
switch: 'el-switch',
timePicker: 'el-time-picker',
datePicker: 'el-date-picker',
slider: 'el-slider',
rate: 'el-rate',
colorPicker: 'el-color-picker',
cascader: 'el-cascader',
upload: 'el-upload'
}
const elTag = typeMap[tag] || (tag.startsWith('el-') ? tag : `el-${tag}`)
let propsStr = ''
if (rule.props) {
for (const [key, value] of Object.entries(rule.props)) {
if (value === null || value === undefined) continue
if (typeof value === 'boolean') {
propsStr += value ? ` ${kebabCase(key)}` : ` :${kebabCase(key)}="false"`
} else if (typeof value === 'string') {
propsStr += ` ${kebabCase(key)}="${value}"`
} else {
propsStr += ` :${kebabCase(key)}='${JSON.stringify(value)}'`
}
}
}
let innerContent = ''
if (rule.options && Array.isArray(rule.options)) {
if (tag === 'select') {
innerContent = rule.options.map(opt => `\n <el-option label="${opt.label}" value="${opt.value}" />`).join('') + '\n '
} else if (tag === 'radio') {
innerContent = rule.options.map(opt => `\n <el-radio label="${opt.value}">${opt.label}</el-radio>`).join('') + '\n '
} else if (tag === 'checkbox') {
innerContent = rule.options.map(opt => `\n <el-checkbox label="${opt.value}">${opt.label}</el-checkbox>`).join('') + '\n '
}
}
let initVal = rule.value !== undefined ? rule.value : (tag === 'checkbox' ? [] : null)
formDataInit.push(` ${rule.field}: ${JSON.stringify(initVal)}`)
if (rule.$required || (rule.effect && rule.effect.required)) {
formRules.push(` ${rule.field}: [{ required: true, message: '${rule.title}不能为空', trigger: 'blur' }]`)
} else if (rule.validate) {
formRules.push(` ${rule.field}: ${JSON.stringify(rule.validate)}`)
}
return `
<el-form-item label="${rule.title}" prop="${rule.field}">
<${elTag} v-model="formData.${rule.field}"${propsStr}>${innerContent}</${elTag}>
</el-form-item>`
}
const formItems = rules.map(parseRule).join('')
const formConfig = options.form || {}
let formPropsStr = []
if (formConfig.labelWidth) formPropsStr.push(`label-width="${formConfig.labelWidth}"`)
if (formConfig.size) formPropsStr.push(`size="${formConfig.size}"`)
if (formConfig.labelPosition) formPropsStr.push(`label-position="${formConfig.labelPosition}"`)
if (formConfig.hideRequiredAsterisk) formPropsStr.push(`hide-required-asterisk`)
// 8. 拼装成标准的 <template> 和 <script setup> 闭环代码
return `<template>
<div>
<el-form ref="formRef" :model="formData" :rules="rules" ${formPropsStr.join(' ')}>
${formItems}
<el-form-item>
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
const formRef = ref(null)
const formData = reactive({
${formDataInit.join(',\n')}
})
const rules = reactive({
${formRules.join(',\n')}
})
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success('表单校验通过,准备提交')
console.log('提交的数据: ', formData)
} else {
ElMessage.error('表单校验失败')
}
})
}
const resetForm = () => {
if (!formRef.value) return
formRef.value.resetFields()
}
<\/script>
`
}
const exportVueTemplate = () => {
const rules = designer.value.getRule()
const options = designer.value.getOption()
vueCode.value = generateVueCode(rules, options)
dialogVisible.value = true
}
const copyCode = async () => {
try {
await navigator.clipboard.writeText(vueCode.value)
ElMessage.success('代码已成功复制到剪贴板!')
dialogVisible.value = false
} catch (err) {
ElMessage.error('复制失败,请手动选择复制')
}
}
</script>
<style scoped>
</style>
+399 -6
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="h-full">
<warning-bar
href="https://plugin.gin-vue-admin.com/license"
@@ -34,7 +34,10 @@
<el-card shadow="never" class="!border-none flex-1 mt-2 flex flex-col min-h-0">
<div class="flex justify-between items-center mb-2">
<span class="font-bold">Skills</span>
<el-button type="primary" link icon="Plus" @click="openCreateDialog">新增</el-button>
<div class="flex gap-1">
<el-button type="primary" link icon="Download" @click="openOnlineDrawer">在线</el-button>
<el-button type="primary" link icon="Plus" @click="openCreateDialog">新增</el-button>
</div>
</div>
<el-input
v-model="skillFilter"
@@ -52,8 +55,20 @@
:index="skill"
class="!h-10 !leading-10 !my-1 !mx-1 !rounded-[4px]"
>
<el-icon><Document /></el-icon>
<span class="truncate" :title="skill">{{ skill }}</span>
<div class="w-full flex items-center justify-between min-w-0">
<div class="flex items-center min-w-0 gap-1">
<el-icon><Document /></el-icon>
<span class="truncate" :title="skill">{{ skill }}</span>
</div>
<el-button
type="danger"
link
icon="Delete"
@click.stop="handleDeleteSkill(skill)"
>
删除
</el-button>
</div>
</el-menu-item>
</el-menu>
</el-scrollbar>
@@ -73,7 +88,10 @@
<span>{{ activeSkill }}</span>
<el-tag size="small" type="info">Skill</el-tag>
</div>
<el-button type="primary" icon="Check" @click="saveCurrentSkill">保存配置</el-button>
<div class="flex items-center gap-2">
<el-button icon="Download" @click="packageCurrentSkill">打包</el-button>
<el-button type="primary" icon="Check" @click="saveCurrentSkill">保存配置</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="h-full">
@@ -375,6 +393,28 @@
</template>
</el-dialog>
<el-dialog v-model="downloadTargetDialogVisible" title="选择下载目标" width="420px">
<el-form label-width="90px">
<el-form-item label="下载到">
<el-select v-model="downloadTarget" placeholder="请选择工具" class="w-full">
<el-option
v-for="item in downloadTargetOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="text-xs text-gray-500">
可下载到单个 AI 工具,也可选择“全部工具”。
</div>
</el-form>
<template #footer>
<el-button @click="closeDownloadTargetDialog">取消</el-button>
<el-button type="primary" @click="confirmDownloadSkill">开始下载</el-button>
</template>
</el-dialog>
<el-drawer v-model="editorVisible" size="70%" destroy-on-close :with-header="false">
<div class="h-full flex flex-col p-4">
<div class="flex justify-between items-center mb-4">
@@ -398,6 +438,86 @@
</div>
</div>
</el-drawer>
<!-- 在线 Skills 抽屉 -->
<el-drawer
v-model="onlineDrawerVisible"
size="90%"
:show-close="false"
destroy-on-close
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">在线 Skills</span>
<el-button @click="onlineDrawerVisible = false">关 闭</el-button>
</div>
</template>
<div class="mb-4">
<el-form :inline="true" :model="onlineSearchInfo">
<el-form-item label="名称">
<el-input v-model="onlineSearchInfo.name" placeholder="搜索技能名称" clearable @keyup.enter="searchOnlineSkills" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="searchOnlineSkills">查 询</el-button>
<el-button icon="Refresh" @click="resetOnlineSearch">重 置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="onlineLoading" :data="onlineSkillList" stripe>
<el-table-column label="封面" width="80">
<template #default="{ row }">
<el-image
v-if="row.picture"
:src="row.picture"
style="width: 50px; height: 50px"
fit="cover"
class="rounded"
/>
</template>
</el-table-column>
<el-table-column label="名称" prop="name" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<a
class="text-blue-500 hover:text-blue-700 cursor-pointer"
:href="`https://plugin.gin-vue-admin.com/details/${row.ID}`"
target="_blank"
>{{ row.name }}</a>
</template>
</el-table-column>
<el-table-column label="简介" prop="resume" min-width="240" show-overflow-tooltip />
<el-table-column label="版本" prop="actVersion" width="100" />
<el-table-column label="下载量" prop="downloadCount" width="90" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.money === 0"
type="primary"
link
icon="Download"
:loading="downloadingIds.has(row.ID)"
@click="handleDownloadSkill(row)"
>下载</el-button>
<a
v-else
class="text-blue-500 hover:text-blue-700 text-sm"
:href="`https://plugin.gin-vue-admin.com/details/${row.ID}`"
target="_blank"
>去购买</a>
</template>
</el-table-column>
</el-table>
<div class="flex justify-center mt-4">
<el-pagination
:current-page="onlineSearchInfo.page"
:page-size="onlineSearchInfo.pageSize"
:page-sizes="[10, 20, 50]"
:total="onlineTotal"
layout="total, sizes, prev, pager, next"
@current-change="handleOnlinePageChange"
@size-change="handleOnlineSizeChange"
/>
</div>
</el-drawer>
</div>
</template>
@@ -411,6 +531,7 @@
getSkillList,
getSkillDetail,
saveSkill,
deleteSkill,
createSkillScript,
getSkillScript,
saveSkillScript,
@@ -424,8 +545,11 @@
getSkillTemplate,
saveSkillTemplate,
getGlobalConstraint,
saveGlobalConstraint
saveGlobalConstraint,
packageSkill,
downloadOnlineSkill
} from '@/api/skills'
import { getShopPluginList } from '@/api/plugin/api'
import { VAceEditor } from 'vue3-ace-editor'
import 'ace-builds/src-noconflict/mode-javascript'
import 'ace-builds/src-noconflict/mode-python'
@@ -650,6 +774,37 @@
loadSkillDetail(skillName)
}
async function handleDeleteSkill(skillName) {
if (!activeTool.value || !skillName) return
try {
await ElMessageBox.confirm(
`确认删除技能「${skillName}」吗?将同时删除其 scripts/resources/references/templates 文件。`,
'删除确认',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch (e) {
return
}
try {
const res = await deleteSkill({ tool: activeTool.value, skill: skillName })
if (res.code !== 0) {
return
}
if (activeSkill.value === skillName) {
resetDetail()
}
await loadSkills()
ElMessage.success('删除成功')
} catch (e) {
ElMessage.error('删除失败')
}
}
function openCreateDialog() {
newSkill.name = ''
newSkill.description = ''
@@ -733,6 +888,69 @@
}
}
function extractFileNameFromDisposition(disposition) {
if (!disposition) return ''
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (e) {
return utf8Match[1]
}
}
const normalMatch = disposition.match(/filename="?([^";]+)"?/i)
return normalMatch?.[1] || ''
}
async function packageCurrentSkill() {
if (!activeTool.value || !activeSkill.value) {
ElMessage.warning('请先选择技能')
return
}
try {
const res = await packageSkill({ tool: activeTool.value, skill: activeSkill.value })
const blob = res instanceof Blob ? res : (res?.data instanceof Blob ? res.data : null)
if (!blob) {
ElMessage.error('打包失败')
return
}
const contentType = String(res?.headers?.['content-type'] || blob.type || '').toLowerCase()
const disposition = String(res?.headers?.['content-disposition'] || '')
const isZipResponse = contentType.includes('application/zip') || disposition.toLowerCase().includes('filename=')
const isErrorBlob = contentType.includes('application/json') || contentType.includes('text/plain')
if (!isZipResponse || isErrorBlob) {
let msg = '打包失败'
try {
const text = await blob.text()
if (text) {
try {
const json = JSON.parse(text)
msg = json?.msg || msg
} catch (e) {
msg = text
}
}
} catch (e) {
// ignore parse error
}
ElMessage.error(msg)
return
}
const fileName = extractFileNameFromDisposition(disposition) || `${activeSkill.value}.zip`
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('打包成功')
} catch (e) {
ElMessage.error('打包失败')
}
}
function appendMarkdown(content) {
form.markdown = `${form.markdown || ''}${content}`
}
@@ -1064,4 +1282,179 @@
function skillsFilesToRows(list) {
return (list || []).map((name) => ({ name }))
}
// ===== 在线 Skills =====
const onlineDrawerVisible = ref(false)
const onlineSkillList = ref([])
const onlineTotal = ref(0)
const onlineSearchInfo = reactive({ page: 1, pageSize: 10, name: '' })
const onlineLoading = ref(false)
const downloadingIds = reactive(new Set())
const downloadTargetDialogVisible = ref(false)
const downloadTarget = ref('')
const downloadRow = ref(null)
const ALL_TOOLS_DOWNLOAD_TARGET = '__all__'
const downloadTargetOptions = computed(() => {
const options = tools.value.map((item) => ({
label: item.label || item.key,
value: item.key
}))
options.push({
label: '全部工具',
value: ALL_TOOLS_DOWNLOAD_TARGET
})
return options
})
const pluginMarketLoginURL = 'https://plugin.gin-vue-admin.com'
const isPluginMarketAuthError = (message) => {
const msg = (message || '').toString()
return msg.includes('插件市场登录') || msg.includes('401')
}
const promptPluginMarketLogin = async () => {
try {
await ElMessageBox.confirm('请先登录插件市场后再下载技能,是否现在前往登录?', '提示', {
confirmButtonText: '前往插件市场',
cancelButtonText: '取消',
type: 'warning'
})
window.open(pluginMarketLoginURL, '_blank')
} catch (e) {
// 用户取消时不需要额外提示
}
}
const openOnlineDrawer = () => {
onlineSearchInfo.page = 1
onlineSearchInfo.pageSize = 10
onlineSearchInfo.name = ''
onlineDrawerVisible.value = true
getOnlineSkills()
}
const getOnlineSkills = async () => {
onlineLoading.value = true
const res = await getShopPluginList({
page: onlineSearchInfo.page,
pageSize: onlineSearchInfo.pageSize,
category: 6,
name: onlineSearchInfo.name || undefined,
updateTime: 1
})
if (res.code === 0) {
onlineSkillList.value = res.data.list
onlineTotal.value = res.data.total
}
onlineLoading.value = false
}
const searchOnlineSkills = () => {
onlineSearchInfo.page = 1
getOnlineSkills()
}
const resetOnlineSearch = () => {
onlineSearchInfo.name = ''
onlineSearchInfo.page = 1
getOnlineSkills()
}
const handleOnlinePageChange = (page) => {
onlineSearchInfo.page = page
getOnlineSkills()
}
const handleOnlineSizeChange = (size) => {
onlineSearchInfo.pageSize = size
onlineSearchInfo.page = 1
getOnlineSkills()
}
const getToolLabel = (key) => {
return tools.value.find((item) => item.key === key)?.label || key
}
const closeDownloadTargetDialog = () => {
downloadTargetDialogVisible.value = false
downloadRow.value = null
}
const handleDownloadSkill = (row) => {
downloadRow.value = row
downloadTarget.value = activeTool.value || tools.value[0]?.key || ''
downloadTargetDialogVisible.value = true
}
const confirmDownloadSkill = async () => {
if (!downloadRow.value) {
ElMessage.warning('未找到待下载技能')
return
}
const targetTools = downloadTarget.value === ALL_TOOLS_DOWNLOAD_TARGET
? tools.value.map((item) => item.key).filter(Boolean)
: [downloadTarget.value].filter(Boolean)
if (!targetTools.length) {
ElMessage.warning('请选择下载目标')
return
}
const row = downloadRow.value
closeDownloadTargetDialog()
downloadingIds.add(row.ID)
const successTools = []
const failedTools = []
try {
for (const tool of targetTools) {
try {
const res = await downloadOnlineSkill({ tool, id: row.ID, version: row.actVersion })
if (res.code === 0) {
successTools.push(tool)
continue
}
if (isPluginMarketAuthError(res.msg)) {
await promptPluginMarketLogin()
return
}
failedTools.push(`${getToolLabel(tool)}: ${res.msg || '下载失败'}`)
} catch (e) {
const msg = e?.response?.data?.msg || e?.message || ''
if (e?.response?.status === 401 || isPluginMarketAuthError(msg)) {
await promptPluginMarketLogin()
return
}
failedTools.push(`${getToolLabel(tool)}: 下载失败`)
}
}
if (successTools.includes(activeTool.value)) {
await loadSkills()
}
if (failedTools.length === 0) {
const successLabels = successTools.map((tool) => getToolLabel(tool)).join('、')
ElMessage({
type: 'success',
message: targetTools.length > 1 ? `${row.name} 已下载到:${successLabels}` : `${row.name} 下载成功`
})
return
}
if (successTools.length === 0) {
ElMessage({ type: 'error', message: failedTools[0] || '下载失败,请重试' })
return
}
const successLabels = successTools.map((tool) => getToolLabel(tool)).join('、')
ElMessage({
type: 'warning',
message: `${row.name} 部分下载成功。成功:${successLabels};失败:${failedTools.join(';')}`
})
} finally {
downloadingIds.delete(row.ID)
}
}
</script>
+50 -6
View File
@@ -21,12 +21,13 @@
</el-form-item>
<el-form-item label="Oss类型">
<el-select v-model="config.system['oss-type']" class="w-full">
<el-option value="local">本地</el-option>
<el-option value="qiniu">七牛</el-option>
<el-option value="tencent-cos">腾讯云COS</el-option>
<el-option value="aliyun-oss">阿里云OSS</el-option>
<el-option value="huawei-obs">华为云OBS</el-option>
<el-option value="cloudflare-r2">cloudflare R2</el-option>
<el-option value="local" label="本地" />
<el-option value="qiniu" label="七牛" />
<el-option value="tencent-cos" label="腾讯云COS" />
<el-option value="aliyun-oss" label="阿里云OSS" />
<el-option value="huawei-obs" label="华为云OBS" />
<el-option value="cloudflare-r2" label="cloudflare R2" />
<el-option value="minio">MinIO</el-option>
</el-select>
</el-form-item>
<el-form-item label="多点登录拦截">
@@ -857,6 +858,48 @@
/>
</el-form-item>
</template>
<template v-if="config.system['oss-type'] === 'minio'">
<h2>MinIO上传配置</h2>
<el-form-item label="Endpoint">
<el-input
v-model.trim="config.minio.endpoint"
placeholder="请输入Endpoint,如 127.0.0.1:9000"
/>
</el-form-item>
<el-form-item label="Access Key ID">
<el-input
v-model.trim="config.minio['access-key-id']"
placeholder="请输入Access Key ID"
/>
</el-form-item>
<el-form-item label="Access Key Secret">
<el-input
v-model.trim="config.minio['access-key-secret']"
placeholder="请输入Access Key Secret"
/>
</el-form-item>
<el-form-item label="存储桶名称">
<el-input
v-model.trim="config.minio['bucket-name']"
placeholder="请输入存储桶名称"
/>
</el-form-item>
<el-form-item label="访问域名">
<el-input
v-model.trim="config.minio['bucket-url']"
placeholder="请输入访问域名"
/>
</el-form-item>
<el-form-item label="Base Path">
<el-input
v-model.trim="config.minio['base-path']"
placeholder="请输入Base Path"
/>
</el-form-item>
<el-form-item label="开启SSL">
<el-switch v-model="config.minio['use-ssl']" />
</el-form-item>
</template>
</el-tab-pane>
<el-tab-pane label="Excel上传配置" name="11" class="mt-3.5">
<el-form-item label="合成目标地址">
@@ -999,6 +1042,7 @@
'aliyun-oss': {},
'hua-wei-obs': {},
'cloudflare-r2': {},
minio: {},
captcha: {},
zap: {},
local: {},
+6 -10
View File
@@ -7,7 +7,7 @@ import vuePlugin from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import VueFilePathPlugin from './vitePlugin/componentName/index.js'
import { svgBuilder } from 'vite-auto-import-svg'
import vueRootValidator from 'vite-check-multiple-dom';
import vueRootValidator from 'vite-check-multiple-dom'
import { AddSecret } from './vitePlugin/secret'
import UnoCSS from '@unocss/vite'
@@ -15,7 +15,6 @@ import UnoCSS from '@unocss/vite'
export default ({ mode }) => {
AddSecret('')
const env = loadEnv(mode, process.cwd())
viteLogo(env)
const timestamp = Date.parse(new Date())
@@ -37,9 +36,9 @@ export default ({ mode }) => {
}
}
const base = "/"
const root = "./"
const outDir = "dist"
const base = '/'
const root = './'
const outDir = 'dist'
const config = {
base: base, // 编译后js导入的资源路径
@@ -48,9 +47,6 @@ export default ({ mode }) => {
resolve: {
alias
},
define: {
'process.env': {}
},
css: {
preprocessorOptions: {
scss: {
@@ -72,12 +68,12 @@ export default ({ mode }) => {
rewrite: (path) =>
path.replace(new RegExp('^' + env.VITE_BASE_API), '')
},
"/plugin": {
'/plugin': {
// 需要代理的路径 例如 '/api'
target: `https://plugin.gin-vue-admin.com/api/`, // 代理到 目标路径
changeOrigin: true,
rewrite: (path) =>
path.replace(new RegExp("^/plugin"), '')
path.replace(new RegExp('^/plugin'), '')
}
}
},
+5 -2
View File
@@ -34,7 +34,7 @@ const extractComponentName = (fileContent) => {
// Vite 插件定义
const vueFilePathPlugin = (outputFilePath) => {
let root
let isDev = false
const generatePathNameMap = () => {
const vueFiles = [
...getAllVueFiles(path.join(root, 'src/view')),
@@ -70,12 +70,15 @@ const vueFilePathPlugin = (outputFilePath) => {
name: 'vue-file-path-plugin',
configResolved(resolvedConfig) {
root = resolvedConfig.root
if (resolvedConfig.mode === 'development') {
isDev = true
}
},
buildStart() {
generatePathNameMap()
},
buildEnd() {
if (process.env.NODE_ENV === 'development') {
if (isDev) {
watchDirectoryChanges()
}
}