初始化

This commit is contained in:
2026-03-09 17:35:53 +08:00
commit 74f322b7c2
577 changed files with 57404 additions and 0 deletions

7
.env-example Normal file
View File

@@ -0,0 +1,7 @@
APP_DEBUG = true
[APP]
DEFAULT_TIMEZONE = Asia/Shanghai
[LANG]
default_lang = zh-cn

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text=auto eol=lf
# Windows
*.bat text eol=crlf

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# 通过 Git 部署项目至线上时建议删除的忽略规则
/vendor
/modules
/public/*.lock
/public/index.html
/public/assets
# 通过 Git 部署项目至线上时可以考虑删除的忽略规则
/public/storage/*
composer.lock
pnpm-lock.yaml
package-lock.json
yarn.lock
# common
/nbproject
/runtime/*
/install
node_modules
dist
dist-ssr
.DS_Store
/.env
Desktop.ini
# Log files
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
!/web/.vscode
# Other
*.css.map
*.local
!.gitkeep
.svn

986
CHANGELOG.md Normal file
View File

@@ -0,0 +1,986 @@
### [BuildAdmin 更新日志](https://gitee.com/wonderful-code/buildadmin)
🔥🔥基于 Vue3.x + ThinkPHP8 + TypeScript + Vite + Pinia + Element Plus 等流行技术栈的后台管理系统支持常驻内存运行、可视化CRUD代码生成、自带WEB终端、自适应多端、同时提供Web、WebNuxt、Server端、内置全局数据回收站和字段级数据修改保护、自动注册路由、无限子级权限管理等无需授权即可免费商用希望能帮助大家实现快速开发。
## v2.3.6-Release
### 新增
- 添加菜单规则时可快速生成下级权限节点(一键同时提交查看、编辑、添加、删除等权限节点)
- 增加公共搜索渲染为时间选择器和时间范围选择器的支持
- 时间日期类字段的公共搜索可按需渲染为单时间、单日期、时间日期,并支持等于、大小比较、范围查询等多种操作符
- CRUD增加自定义公共搜索输入组件属性的支持
- CRUD增加远程下拉字段的公共搜索渲染方式的自定义功能
### 重构/优化/修复
- 优化统计图表样式
- 完善表格列的 TS 类型定义
- CRUD设计器中的默认排序字段的设定逻辑优化
- 优化已安装模块详情弹窗中的按钮样式
- 优化CRUD设计页面部分输入框的提示文案
- 优化CRUD字段名称重复/命名规则错误时,提示信息的显示时机
- 使用新的官网接口地址以避免可能的本地 hosts 配置影响
- 修复本地自建模块的显示问题
- 修复CRUD设计器中对字段先改名再删除时提示找不到字段的问题
- 修复后台窗口大小改变重新布局时,可能丢失状态/自动重新布局的问题
- 修复在部分手机上后台菜单可能滑不到最底部的问题
- 修复常驻内存模式下不能连续安装模块的问题
- 修复访问CRUD云记录时可能提示请登录的问题
## v2.3.5-Release
### 新增
- 在后台模块详情弹窗中显示模块文档的链接
- 新增设定表格 getData 请求时的筛选条件的方法
### 重构/优化/修复
- 优化表格行按钮的样式细节
- 优化公共搜索数据的类型定义
- 优化表格顶部按钮的样式细节
- 优化模块详情弹窗中模块预览轮播图的样式细节
- 完善部分方法形参的类型声明 (#52)
- 完善表格管家类内部事件相关细节及类型定义
- 会员余额和积分管理表格刷新时同时刷新当前会员的账户信息
- 修改公共搜索显示动画为折叠展开,使得动画更平滑 !195
- 在设置和获取公共搜索表单数据前确定数据已经初始化
- 优化公共搜索初始化逻辑、优化 getData 筛选条件设定
- 修复格式化时间日期字符串时可能异常的问题 !192
- 修复开发环境中 prettier 命令的参数未严格限制可能导致命令注入的问题
- 修复第一个表格行按钮是确认按钮时存在视觉偏移的问题 !191
- 修复弃用自带公共搜索组件后远程下拉仍然会发起初始化请求的问题 !187
- 自定义 `voku/portable-utf8``voku/anti-xss` 依赖的仓库 URL 以兼容至 `PHP 8.4`
## v2.3.4-Beta
### 新增
- 表格行按钮的扩展属性支持以自定义函数定义
- 新增表格按钮的 `loading` 属性控制函数
- 后台安装模块时支持选择安装版本(**授权过期订单可继续安装授权过期之前发布的模块版本**
### 重构/优化/修复
- 优化远程下拉组件的分页器
- 优化上传组件的图片预览弹窗
- 可视化CRUD优化生成的控制器中的 `index` 方法内 `visible` 操作的代码
- 可视化CRUD生成的表单组件中无验证规则时不导入 `buildValidatorData` 函数
- 可视化CRUD存在富文本编辑器时增加表单 `dialog` 的宽度
- 余额管理和积分管理内的会员信息加载增加防抖
- 新注册会员未设密码时无需生成随机密码
- 优化导入部分自定义组件时的单词大小写问题
- 优化表格行按钮和其图标的默认样式
- 替换已废弃的 frameborder 属性
- 同步暗黑模式下 `--el-border-color``--ba-border-color` 的 CSS 变量值
- 修复暗黑模式下控制台页面休息片刻按钮的样式问题
- 修复会员 id 可能被修改的问题
- 修复会员余额管理部分字段筛选无效的问题
- 修复后台管理员的最后登录时间显示错误的问题
- 修复时间格式化工具函数错误的将 0 改为了当前时间
- 修复后台附件管理删除确认按钮 title 属性的 TS 类型错误
- 修复菜单规则管理中 Iframe 的 URL 可能被转义的问题
## v2.3.3-Release
### 修复
- 修复卸载模块的 WebBootstrap 时可能报错的问题(即模块卸载可能报错)
- 修复后台菜单规则管理可能意外要求链接URL字段必填的问题
## v2.3.2-Release
### 新增
- 增加预设表格单元格渲染器内部的组件的任意属性自定义功能
- 表格公共搜索中的范围输入框的 `placeholder` 支持以数组类型定义不同值
- 允许模块向 `modules` 目录写入文件,即操作其他模块,而不是局限于系统本身
- 模块系统增加 `nuxt` 工程的 模块启动引导代码 插入支持
- 对外导出前后台各种布局下顶栏的高度数据
### 重构/优化/修复
- 模板引用升级为 `vue 3.5` 新增的 `useTemplateRef`
- 缓存后台菜单规则管理中数据行的展开折叠等状态以便更好的对菜单进行管理
-`baTable.getIndex``baTable.requestEdit` 方法增加更适宜的别名
- 使用更易读的方式定义表格管家类的钩子
- 修复后台标签页的退出全屏按钮不能点击的问题
- 修复刷新页面后菜单栏滚动条不能自动滚动到激活菜单所在位置的问题
- 优化模块列表页面样式
- 优化 `baTableApi` 类细节
- 优化 `TableColumn` 类型定义
- 优化 `baTable` 注释和类型定义注释
- 优化后台菜单规则管理的验证规则和细节
- 优化右击菜单组件和图标选择器的事件监听
- 优化生成 `tableRenderer.d.ts` 文件的逻辑和其内容
## v2.3.1-Release
### 安全更新
- 升级 `axios` 以避免 `CVE-2025-27152` 带来的影响([axios请求可能通过绝对URL遭受SSRF和凭证泄露漏洞](https://www.cve.org/CVERecord?id=CVE-2025-27152)
- `BuildAdmin` 后台 `系统配置控制器` 内意外的设置了 `查看` 方法为免登录,可能导致 `配置信息泄露`,建议你立即进行更新或手动修复,[本安全更新详细文档](https://doc.buildadmin.com/guide/other/incompatibleUpdate/v231.html)。
### 优化
- 统一和调高系统级 `z-index` 配置值
## v2.3.0-Release
### 新增
- 使用更安全的密码 `hash` 算法
- 可视化 CRUD新增可选的历史记录云备份功能可跨设备使用 CRUD 设计
- 可视化 CRUD代码生成完毕后自动调用 `prettier` 格式化前端代码
- 增加 `cdn_url_params` 内容分发网络 `URL` 参数配置 !177
- 点选验证码组件支持自定义 `API``BaseURL`
### 重构/优化/修复
- 更新 `vite``vue-i18n`
- 优化多个数据表的结构
- 优化部分状态商店的数据填充方法
- 跨域 `methods``headers` 默认允许所有
- 可视化 CRUD优化空表和删表重建的提示信息
- 可视化 CRUD优化数字系列验证规则的注释和显示标题
- 可视化 CRUD修复解析表时 `float` 类型字段不设长度可能报错的问题
- WEB 终端:为可执行命令增加 `notes` 配置项,可于执行前对命令进行一次注释
- 为部分输入框添加 `placeholder` 以更好的融入整个表单
- 修改 `user` 模型中 `group` 关联方法的名称以避免方法名称冲突
- 修复同一文件多次上传可能重复保存的问题
- 修复附件表 `name` 字段长度可能不够的问题
- 修复 `createAxios` 取消重复请求的配置项大小写错误
- 修复上传组件 `showFileList=false` 时可能报错的问题
- 修复验证码类传递自定义随机字符串时可能验证失败的问题
- 其他细节
## v2.2.1-Release
### 重构/优化/修复
- 添加 `pnpm.onlyBuiltDependencies` 以避免编译时报错
- 修复微信 `PC` 版的截图无法上传的问题 (#50)
- 修复登录失败重试次数超限,隔天后仅能重试一次的问题
- 修复后台角色组管理中非超管不显示已禁用分组的问题 (#43)
- 修复添加敏感字段和数据回收规则时可能报错的问题
- 优化WEB终端交互式命令的检测和中断
- 优化公共权限类的会员注册方法
- 优化清理 `XSS` 的方案(不过滤富文本的 `style` 属性同时确保内容 `xss` 安全)
- 优化刷新 `token` 接口的逻辑
- 优化后台角色组管理
- 前端响应内容中增加 `API` 调试引导
- 不再使用新版 `el-pagination` 组件中已经废弃的 `small` 属性 (#49)
- 切换 `switch` 单元格的状态时更新表格原始数据 !172
## v2.2.0-Release
- 本版本包含一些不兼容更新,请在升级时查阅 [v2.2.0不兼容更新文档](https://doc.buildadmin.com/guide/other/incompatibleUpdate/v220.html)
### 新增
- 升级前后端依赖(`tp8.1` + `vue3.5` + `element plus2.9` 等)
- 可视化CRUD生成前检查是否已有同名菜单并提示
- 可视化CRUD生成远程下拉时支持可视化的自定义数据源
### 重构/优化/修复
- 优化 `element.scss` 细节
- 优化 `getDirFiles` 获取文件时支持不限后缀
- 优化后台菜单唯一标识的生成规则
- 优化公共搜索对嵌套关联预载入字段的支持
- 提交表单时不再自动过滤值 `null` 的字段
- 将权重字段自动赋值的条件由值为 `0` 改为值为 `null`
- 多种输入框对应的数据表字段设计允许 `null` 以提供更好的兼容
- 全局配置 `value-on-clear` 以避免 `el-select` 等组件清空输入时值为 `undefined`
- 优化时间选择器、远程下拉选择器、城市选择器、颜色选择器
- 单元格 `tag` 渲染器值为 `null``undefined` 时不渲染
- 后台会员、管理员表单禁止浏览器密码的自动填充
- 公共搜索时过滤 `length``0` 的数组数据
- 可视化CRUD勾选生成为公共模型时同时将验证器设为公共验证器
- 可视化CRUD字段名称重复检查移至修改前而不只是在修改后再统一检查
- 可视化CRUD优化字段名称重复时自动重命名的逻辑
- 可视化CRUD生成的 `number` 类型输入框绑定值不再需要修饰符
- 可视化CRUD优化 `float``time` 类型字段的值为 `null` 时的处理
- 可视化CRUD修复多层级菜单情况下生成的上级菜单有可能错误的问题
- 修复以 `ASC` 规则排序时,拖拽排序可能无效的问题
- 修复 `unixTime` 方法可能报错 `Invalid date` 的问题
- 修复模糊搜索关联字段时可能报错的问题
- 修复 `number` 类型输入框无法输入 `0.0` 的问题
- 修复对 `getArrayKey` 的返回值判断不严格的问题
- 修复关联表列的 `default``formatter` 定义无效的问题
- 修复 `number` 输入框值为 `null` 会自动被转为 `0` 的问题
- 修复格式化 `Unix` 时间戳时不支持未来时的问题
- 不再需要 `ext-calendar`
- 其他细节
## v2.1.3-Release
### 新增
- 后台控制器基类增加有序保证属性
- 存在热更新脏文件时,于后台顶栏显示需要重启 `Vite` 热更新服务的警告按钮
### 重构/优化/修复
- 表格拖拽排序由直接替换改为增量重排法
- 优化点选验证码组件的渲染与销毁
- 优化会员中心菜单点击时的处理函数逻辑
- 优化前台顶栏菜单被点击时无需激活的菜单项的处理
- 优化终端弹窗样式
- 优化小屏下的菜单抽屉
- 优化顶栏子级菜单的激活逻辑
- 优化系统配置项 `buildadmin.api_url` 的注释
- 优化 `CRUD` 无意义默认值的清理逻辑
- 优化 `CRUD` 浮点数类型字段的模型获取器生成
- 去除开发环境下跨域代理示例
- 前端的会员中心开关状态默认开启
- 会员前台菜单的路由路径支持 `query`
- 后台删除操作不再必需为 `Delete` 请求、统一输入变量接受方式
- 完善 `Request` 类的真实 `IP` 获取支持(可避免 `Nuxt` 工程服务端渲染时获取不到客户端真实 `IP`
- 修改 `request` 类全局过滤规则的设置时机
- 修复单元格 `tag` 渲染器值为 `0` 时不渲染的问题
- 修复控制器代码中未写入自定义的权重字段名称的问题
- 修复会员的分组无所有权限时上传文件会提示无权限的问题
- 修复模块安装过程中可能提示网络请求超时的问题
- 其他细节
## v2.1.2-Release
### 新增
- 增加前台会员登录验证码开关配置项
- `BaInput``FormItem` 组件增加插槽支持
- 可视化CRUD将字段默认值区分为多个类型进行设定
- 可视化CRUD修改字段的生成类型时询问是否重置为新类型的预设属性
- 终端设置窗口内增加修改 `NPM``Composer` 源的功能
### 重构/优化/修复
- 升级 `think-orm`
- 提高 `node` 版本要求
- 优化根标签和头像样式
- 优化通用搜索表单重置逻辑
- 优化前台会员登录注册接口
- 优化开发服务环境检测,去除开发服务端口配置功能
- 优化输入组件类型对应的数据表字段设计方案
- 后台菜单的路由路径支持 `query`
- `baTable` 的通用搜索初始化相关逻辑解耦
- 删除 `countup.js` 依赖,使用 `useTransition` 代替
- 设置通用搜索数据时对时间日期的识别优化
- 表格列的 `renderFormatter` 替换为 `formatter`
- 单元格渲染器拆分为独立组件并改用易于扩展的方式加载
- 使用 `v-memo` 指令缓存表格中的按钮组以提高表格性能
- 会员管理控制器的 `select` 方法过滤敏感字段
- 附件选择器关闭通过 `query` 自动触发通用搜索的功能
- 升级 `pinia``eslint``vue-tsc` 等多个前端依赖
- 使用 `qrcode.vue` 替代 `vue-qr` 以确保不存在已弃用的子依赖
- 安装程序增加对 `pdo_mysql` 的检测
- 修复控制台页面部分图表超出容器的问题
- 修复上传文件时入库文件路径使用了错误的斜杠造成图片可能无法显示的问题
- 修复在多数据库场景中远程下拉组件的 `pk` 属性可能错误的问题
- 修复系统配置中部分输入组件可能报警告的问题
- 其他细节
## v2.1.1-Release
### 新增
- 上传类重构为多驱动模式,同时云存储模块将为系统安装服务端上传云存储的驱动
### 重构/优化/修复
- 限定`think-orm`版本以修复该依赖新版本带来的问题
- 修复上传组件的文件上传状态可能错误的问题
- 修复自定义后台入口后`WEB`终端命令执行失败的问题
- 修复`v-drag`指令对`el-dialog`使用时会意外抖动的问题
- 修复会员的权限不是所有时,修改邮箱提示没有权限的问题
- 修复一处类型定义中的注释书写错误 !163
- 优化`full_url`函数的参数类型
- 当表格公共搜索字段渲染为`tag`且操作为`in`时,支持多选 !162
- 上传组件禁用状态相关优化
- 调高部分全局通知消息的`zIndex`
- 使用更合理的方案处理`OPTIONS`请求
## v2.1.0-Release
### 新增
- 升级所有前端依赖
- 增加`Writeable`工具类型,可将只读属性转为可写
- 增加`vue-tsc`依赖和`typecheck`命令
- 模块上传安装时对模块版本是否兼容当前系统版本进行检查
### 重构/优化/修复
- 修复`FormItem`组件属性失去了响应性的问题
- 修复`switch`组件使用了`activeValue`属性时无法工作的问题
- 修复连续安装模块时终端命令可能执行失败的问题
- 修复拥有所有权限的分组可能无法默认选中权限节点的问题
- 基于`Element plus`新版本优化远程下拉组件(分页样式、逻辑等)
- 基于`Element plus`新版本优化文件上传组件(钩子等)
- 基于`Element plus`新版本优化`FormItem`组件
- 优化`FormItem``props.tip``props.blockHelp`样式
- 优化`FormItem`组件的类型定义
- 合并`FormItem``props`本身和`props.attr`,可查阅[此提交](https://gitee.com/wonderful-code/buildadmin/commit/10527ebb760a10b329130e3194a6bbc929a52737)
- 合并`baInput`组件的`props.attr``props.data`,可查阅[此提交](https://gitee.com/wonderful-code/buildadmin/commit/10527ebb760a10b329130e3194a6bbc929a52737)
- 优化表格的`props`继承的类型的定义
- 优化富文本编辑器的默认宽度
- 优化可视化`CRUD`生成的模型的`onBeforeInsert`方法返回值类型定义
- 优化可视化`CRUD`生成的`FormItem`组件的属性代码
- 优化`debounce`的使用,无必要不使用全局`debounce`
- 去除菜单权限规则类的静态变量缓存以更好的兼容常驻内存运行
- 使用`PSR-12`编码风格规范格式化和检查所有`PHP`代码
- `Vite`热更新控制的相关功能整理为函数
- 其他细节
## v2.0.10-Release
### 新增
- 单复选框支持按钮模式
- 公共搜索增加渲染类名方便单独定位某字段
- 图片上传达到限制数时隐藏上传框的配置项
- 增加`AttachmentInsert`事件,开发者或模块可于附件入库后对新的附件做一些额外的操作
### 重构/优化/修复
- 优化文件上传,文件移动操作优先于文件数据入库
- 优化管理员管理和菜单规则管理的控制器代码
- 修复选择数据表、选择数据表字段接口非默认数据库返回空数据的问题
- 修复第一个菜单为 Iframe 时不能自动跳转的 BUG
- 修复由于`Gitee Pages`停止服务造成的文档站无法访问
- 修复可视化`CRUD`远程下拉选择数据表时不能关键词搜索的问题
- 修复右击菜单组件的菜单面板可能超出屏幕的问题
## v2.0.9-Release
### 新增
- 增加动态修改后台顶部`Tab`标题的方法
- 增加关闭掉全部或者指定`Tab`的方法
- 新增`refs`状态商店,全局提供了:引用(指向)一些对象(组件)的句柄
- 常驻内存支持,享受比传统框架快上数十倍的性能提升,目前[Workerman模块](https://modules.buildadmin.com/workerman)可提供框架的常驻内存HTTP服务
### 重构/优化/修复
- 优化内置滚动条样式
- 使用鼠标滚轮快速操作顶栏横向滚动条的支持(单双栏布局模式下)
- 在操作日志中管理登录失败时任然尽可能记录输入的管理员名称
- 内置开发服务(php think run)总是禁用输出压缩
- 自定义后台入口时,由禁止后台应用访问改为添加应用映射
- 优化Token门面类的类型定义、鉴权相关解耦、细节优化
- 上传文件时对文件名中不利于传输的字符进行过滤
- 修复WEB终端开始执行命令时可能刷新页面的问题
- 修复清理缓存操作的日志标题错误的问题
## v2.0.8-Beta
### 新增
- [Workerman模块](https://modules.buildadmin.com/workerman)上线(系统内置的`API`已完成常驻内存运行的兼容)
- 增加一个获取用户鉴权`token`的公共函数
- WEB终端对交互式命令进行提示并终止执行
### 重构/优化/修复
- 修复收集异常数据时可能出现循环引用导致难以排查的问题
- 公共函数`full_url`默认域名不再携带协议,由浏览器自动识别或手动指定
- 文件上传类兼容常驻内存模式运行
- 权限和权限规则类兼容常驻内存运行
- WEB终端兼容常驻内存运行
- WEB终端命令执行完毕后再输出退出码和状态信息
- 跨域中间件:使用更合适的办法设置跨域响应头
## v2.0.7-Release
### 重构/优化/修复
- 优化前台首页在短屏下的样式
- 可视化CRUD多数据库支持兼容历史生成记录
- 为前台登录页增加滚动条以兼容小屏设备
## v2.0.6-Release
### 新增
- 自定义后台入口支持
- CRUD、数据回收、敏感数据监控的多数据库支持
### 重构/优化/修复
- 修复编辑时`unique`验证问题
- 添加`symfony/http-foundation`依赖
- 修复非超管对于新增的子级菜单规则可能显示异常的问题
- 修复保存系统配置时可能刷新页面的问题
- 选择数据表、选择数据源接口增加快速搜索支持
- 优化`git`对模块文件的忽略规则
- 更换已经失效的`npm`
- 优化后台规则管理细节
- 安装器优化
## v2.0.5-Release
### 新增
- 更新所有前端依赖`Vite5+ElementPlus2.4`
- 模块更新`composer.json`中的`config`字段实现
### 重构/优化/修复
- 优化前台页脚样式
- 优化路由动态注册
- 优化获取第一个菜单的函数
- 优化通用搜索按钮样式 !142
- 优化系统配置数据模型
- 优化可视化`CRUD`设计器的样式
- 后台会员管理中的会员分组设为必填
- 默认不再内置`easywechat`依赖,添加`guzzlehttp`依赖(受益于模块可以调整`composer.json``config`
- 系统配置中的快捷配置入口使用路由`name`而不再是路由`path`
- 语言包按需加载映射表中的后台入口路径由字面量改为变量
- 管理员登录接口返回的路由路径使用的字面量改为变量
- `eslint``prettier``ESM`的兼容
- 修复系统配置变量值为`0`时可能无法回显的问题
- 修复可视化`CRUD`富文本字段默认值为`null`时表单打不开的问题
- 修复设置浏览器标题的函数可能失败的问题
- 修复初次打开前端时页面标题不完整的问题
- 修复双栏模式子级菜单跳转异常的问题 close #I7ZECR
- 其他细节
## v2.0.4-Release
### 新增
- 增加静态路由目录,自动加载其中所有文件并注册
- 表格快速搜索关键词可通过类实例访问
- 模块上传安装时对系统版本、模块互斥和依赖关系进行检测
- 模块纯净模式安装(移动模块文件到系统而不是复制)
- 模块可以通过上传安装来完成升级
- 自定义远程下拉初始值操作符号支持
### 重构/优化/修复
- 可视化CRUD:生成的菜单默认开启缓存
- 可视化CRUD:高级配置中显示的字段信息优化
- 可视化CRUD:非新建设计时,总是显示表设计变更预览的按钮
- 可视化CRUD:优化字段临时数据备份机制、优化字段重复检测
- 终端不再使用单独的控制器
- 终端优化命令执行日志缓冲区清理逻辑
- 终端执行`composer`相关命令时,关闭交互询问
- 表单弹窗在小屏设备上的样式优化
- 优化创建`zip`的方法
- 表格数据刷新优化
- 日志数据入库时的编码兼容性优化
- 基础静态路由路径使用的字面量改为变量
- 生成代码的`import`语句整理
- 去掉管理员登录成功时的通知提醒信息
- 优化模块上传安装时的提示信息
- WEB端环境变量加载优化
- 升级`topthink/think-migration`依赖
- 去除已经失效的`travis.yml`文件
- 删除`web`目录内多余的`README.md`文件
- 修复表格自动识别筛选条件功能中,`query`改变不能触发重新筛选的问题
- 修复`nuxt工程`新增依赖时没有备份`package.json`的问题
## v2.0.3-Release
### 新增
- 前台菜单支持无限层级嵌套
- 独立出表格内部组件自动调用的鉴权方法,便于开发者重写
- 前端公共函数`auth`可以通过传递菜单规则的 name 鉴权
- 删除 web 端中多余的默认头像文件
- 会员中心增加可选的 query 指定会员登录成功后自动跳转的URL
- 上传类增加一个`setTopic`方法
### 重构/优化/修复
- 优化控制台菜单规则(增加了查看权限节点)
- 公共函数`get_table_list`默认不再去除表注释中的:后缀`表`
- 管理员管理中的分组字样改为角色组
- 角色组管理增加权限说明的备注
- 取消前台用户头像必填
- 重置公共搜索表单时,自动刷新表格
- 内置的后台功能中默认ID字段搜索时不再使用模糊查询
- 不再需要清理`css charset`,所以删除多余代码
- 去除多余的 htmlspecialchars 参数(富文本入库可能被多次转义)
- 修复顶栏菜单在非激活菜单右击关闭全部标签会清空标签页的问题
- 修复头像保存时可能丢失的问题优化头像URL出入库逻辑
- 修复用户默认的头像URL可能被入库的问题
- 修复上传组件图片拖拽排序在添加时无效的问题
- 修复文件名为中文时可能上传失败的问题
- 可视化CRUD:修复生成单选框组件时报错的问题
## v2.0.2-Release
### 新增
- 上传图片组和文件组时支持拖拽排序
- 增加管理员和会员的登录态保持时间配置项
- 新增清理`XSS`代码的公共函数
### 重构/优化/修复
- 从服务端限制`WEB终端`仅限超管执行命令
- 表格公共搜索操作符不再使用不利于传输的符号形式
-`a`标签添加`rel="noopener noreferrer"`
- 优化请求输入变量的默认过滤规则
- 可视化CRUD:存在富文本组件时,默认对`XSS`代码进行清理
- 可视化CRUD:远程下拉参数预填弹窗增加滚动条,避免小屏显示异常
- 可视化CRUD:富文本字段默认值改为`empty string`
- 可视化CRUD:修复`php8.1`下从数据表开始可能报错的问题
- 修复远程下拉脱焦后会有个多余的请求的问题
- 修复远程下拉组件`row`事件可能失效的问题
- 修复会员登录态过期后不会触发重新登录的问题
- 修复小屏设备中后台最后一个菜单可能显示不全的问题
- 修复顶栏会员中心菜单的下拉项无法显示的问题
## v2.0.1-Release
### 新增
- 全局提供 mainScrollbarRef 以实现子组件操作滚动条
### 重构/优化/修复
- 前端初始化请求和会员中心初始化请求合并为一个
- 顶栏菜单在手机端的显示和交互优化
- 优化会员中心个人资料页面小屏自适应
- 会员余额和会员积分模型添加悲观锁
- 删除 user 表中可能造成意外错误的唯一索引
- 添加页面按钮时,隐藏链接地址的输入框
- 远程下拉组件内部 select 属性绑定
- 提交表单时操作方法名首字母小写
- 修复安装模块时可能报异常的问题
- 修复 symfony/var-dumper 依赖被锁定为 4.*
- 修复顶栏宽度在侧边栏折叠开启操作后宽度不正确
- 修复模块安装、卸载等操作时管理员日志中标题为未知的问题
- 修复管理员登录页不能响应暗黑模式开关的问题
- 修复后台侧边菜单在小屏设备可能意外被隐藏的问题
- 修复不能同时存在两个地区选择器的问题
- 修复 el-table 原有属性失效的问题
- 可视化CRUD修复远程下拉多选字段后缀不为 _ids 时会生成重复方法的问题
- 可视化CRUD修复 enum 默认值为 0 时报错的问题
- 可视化CRUD修复生成的前端代码中对象 key 以数字开头时报错的问题
- 可视化CRUD修复编辑关联字段时可能不会更新表字段的问题
- 可视化CRUDMySQL text 和 blob 类型不能有默认值
## v2.0.0-Release
此版本有一些不兼容更新,建议在更新前参考:[v2.0.0不兼容更新](https://doc.buildadmin.com/guide/other/incompatibleUpdate/v200.html)
### 新增
- 升级到`tp8.0.0`,升级所有后端依赖
- 升级到`vue3.3`,升级所有前端依赖
- 上传文件使用部分文件名作为前缀以便识别
- 多富文本编辑器共存支持
- 模糊搜索关键词可以含有百分号
- 表格,单元格和公共搜索自定义渲染支持`slot`方式
- 表格头组件增加多个插槽
- 使用`Phinx`管理项目数据表,增加数据表管理类
- 增加访问和操作文件系统的类
- 可视化CRUD修改设计且数据表内有数据时不再删表重建而是根据设计调整表结构
- 可视化CRUD单表多次关联支持、远程下拉字段名自动根据表名生成
- 可视化CRUD修复生成的远程下拉`pk`属性可能错误的问题
- 可视化CRUD实时的字段命名规则检查、字段名称重复检查
- 可视化CRUD增加当前不在开发环境提醒
- 可视化CRUD选择的表有成功生成的记录则建议从历史记录开始
- 其他细节
### 优化/修复
- 安装器优化
- 公共语言翻译`key`全面大写开头
- 内置`font-awesome`
- `WEB`终端执行日志显示样式优化,且输出支持换行
- 完善前端端类型定义
- 部分公共函数归纳为类
- `array`输入组件可以设置数组项的标题
- 可视化CRUD修复远程下拉多选字段的公共搜索失效的问题
- 云存储初始化时机优化
- 修改菜单规则默认图标
- 上传组件默认值为`null`时的处理
- 修复后台基类 del 方法的数据权限失效的问题
- 编程式删除系统配置分组时,该分组无配置项再删除
- 同类函数参数命名统一、字段命名规则统一、参数命名规则统一
- 默认折叠所有会员菜单规则
- 优化点选验证码汉字集
- 优化命令执行失败时的提示信息
- 去除不必要的 controllerUrls
- `timeFormat`函数归类到公共文件中
- `menu_rule`表名改为`admin_rule`,因为会员规则表名为`user_rule`
- 修复远程下拉组件在无数据时无提醒的问题
- 修复热更新后鉴权按钮消失的问题
- 其他细节
## v1.1.7-Release
### 新增
- 升级点选验证码
- 增加数据表命名规则的检查
- 增加模块安装时对`Nuxt`工程的版本检测
### 重构/修复
- 优化将字符串属性列表转为数组的函数
- 为模块安装请求设置更长的请求超时时间
- 修改高级配置文字颜色
- 优化输入组件逻辑
- `nuxt`模块改用标签进行筛选
- 系统配置中配置分组不存在时不显示该配置项
- 修复鼠标在时间选择组件上页面无法滚动的问题 !125
- 修复下拉面板滚动到视窗外隐藏时可能抛出错误的问题 !124
- 其他细节
## v1.1.6-Release
### 新增
- 上传组件增加实时上传进度的显示
- 上传组件增加强制上传到本地的`props`
- 上传文件方法增加`AxiosRequestConfig`参数
- 增加`remoteSelects`类型输入框
- 后台会员规则管理增加顶栏会员菜单下拉项类型
### 修复/重构
- 后台关闭`tab`,自动返回到上一个`tab`时不带`query`的问题
- 后台菜单规则管理中的组件路径字段自动转换分隔符号
- 会员规则无组件的不注册到菜单项
- 优化系统配置逻辑
- 优化可视化CRUD拖拽交互
-`web-nuxt`提高`API`节流阈值
- 限定`pinia`版本号
- 前台初始化请求防抖
- 优化会员中心跳转到第一个菜单的逻辑
- 前台动态路由注册时可根据`name`从已注册路由分析父级路由
- 不再额外向`element`安装器传递`i18n`选项
- 优化前台动态菜单样式
- 前台`link`类型的顶栏菜单打开失败的问题
- 添加表单默认值赋值改为深拷贝
- 优化鉴权指令
- 优化输入组件用于代码提示的类型定义
- 修复上传组件`onChange`重复触发的问题
- 修复查询条件 [NOT] NULL 报错的问题
- 修复系统配置的远程下拉多选值不能正常选中的问题
## v1.1.5-Release
### 新增
- 全面使用`文字点选验证码`,配合服务端二次验证,为您的重要资源保驾护航
- 增加前台普通路由、顶部导航栏、权限节点的可视化管理
- 远程下拉增加获取被选中项完整对象的事件
- `可视化CRUD`常用字段增加`雪花ID`类型
- `可视化CRUD`生成公共模型代码的支持
- `可视化CRUD`增加快速设定代码相对位置的功能
- `可视化CRUD`根据字段字典自动重新生成字段的数据类型,避免部分情况需要手动拼接的问题
- `可视化CRUD`的字段设计数据导出以便开发者使用
- `baTableApi`当操作不存在时,创建自定义操作
- 增加创建表单项数据的组件
- 前端增加身份证号验证器
- 增加快速搜索前置插槽
- 增加将字符串属性列表转为数组的公共函数
- 增加通过`Git`部署项目至线上时的忽略规则建议
- 增加备用的`font-awesome CDN`和完善注释
### 修复/重构
- 更新所有前端依赖
- 优化类型定义
- 不再加载`lang/pages`中的语言包
- 优化系统配置保存时的代码逻辑
- 从数据表开始时不读取表前缀错误的数据表
- 远程下拉菜单超出视窗时自动隐藏
- 修复会员分组管理回车保存会刷新页面的问题
- 菜单折叠后菜单图标垂直对齐
- 公共搜索查询操作符 FIND_IN_SET 支持传递数组
- 将渲染为 tags 的字段的默认搜索操作符设定为 FIND_IN_SET
- 禁止管理员添加拥有自己全部权限的分组
- 上传函数请求超时时间修改为无限制避免超时
- CRUD下拉框默认的字段数据类型由`tinyint`改为`enum`
- 修复单元格自定义渲染时,改变 data 不重新渲染的问题
- 修复`Tree类`子节点组装方法漏传`pk`的问题
- 修复CRUD远程下拉的公共搜索无数据的问题
- 修复CRUD从数据表加载的字段默认值为null却被识别为空字符串的问题
- 修复CRUD数字输入组件的默认值无法通过验证的问题
- 修复CRUD中支持多选的表单元素名称错误的问题
- 修复CRUD富文本字段生成失败的问题
- 修复会员管理建立模型验证后密码验证不通过的问题
- 修复php8.1+mysql8.0兼容性问题
- 修复一处 php8.2 不兼容
- 修复公共搜索未传递值时任然拼装查询条件的问题
- 其他细节优化
## v1.1.4-Release
### 新增
- 模块安装增加依赖模块检测
- 新的依赖管理类
- 通过模块市场为`WebNuxt工程`安装模块的实现
## v1.1.3-Release
### 新增
- `WebNuxt`工程发布,可通过模块市场安装,亦可直接访问[代码仓库](https://gitee.com/wonderful-code/build-admin-nuxt)
- 增加可选的管理员和会员单点登录功能
- 增加直接登录会员账号的方法
- 新增双栏布局效果,顶部栏加左侧栏同时存在
- 确保无需登录的接口不会抛出token过期的异常
- 增加表格普通侧边按钮类型
- 增加根据当前路由路径快捷获取语言翻译的函数
- 后台模块管理增加我的模块按钮
### 修复/重构
- 远程下拉增加信息提示框
- 文件上传失败则不在上传列表显示
- 调整composer依赖
- 可视化CRUD生成的语言包代码按需加载实现
- 优化数据行拖拽排序的逻辑
- 优化数据行侧边按钮的类型定义
- 模块封面图片开启懒加载
- 修改管理员日志的data字段类型为longtext
- 修复添加窗口中存在富文本字段时可能无法关闭的问题
- 修复管理员无权限时跳回首页或被注销的问题
- 修复表格行侧边 confirmButton 按钮 disabled 无效的问题
- 修复从历史记录开始时,远程下拉参数无法选择的问题
- 修复菜单规则只添加为菜单时无法打开的问题
- 修复从数据表开始时字段分析可能出错的问题
- 修复行侧边按钮 disabledTip 属性无效的问题
- 修复前台iframe菜单无法打开的问题
- 修复远程下拉监听值为`null、undefined`时报错的问题
- 修复后台因为管理员模型登录时间获取器导致登录判断报错问题
## v1.1.2-Release
- 此版本有一些不兼容更新,建议在更新前参考:[v1.1.2不兼容更新](https://doc.buildadmin.com/guide/other/incompatibleUpdate/v112.html)
- 页面组件与页面语言包全部**按需加载**,大幅减少首屏加载大小
- 更新系统前端的所有可更新依赖到最新稳定版本
- 可视化CRUD增加字段名称检查
- 禁止管理员自己删除自己
- `isAdminApp`方法支持传递`path`进行判断
- `mixins`代码移入到新建的组件内统一管理
- 修复可视化CRUD生成的代码中`-1`没有加引号的问题
- 修复后台单栏布局只有一个菜单时菜单不显示的问题
- 修复模块发布新版本不能减少旧版本模块文件的问题
- 修复模块更新脚本因未加载而不能执行的问题
## v1.1.1-Release
### 新增
- 增加前台会员中心埋点(配合模块为会员中心增加功能)
- 编程式添加会员菜单规则支持
### 优化
- 默认关闭监听SQL
- 服务端返回302时自动删除前端的用户token
- 系统配置保存时只效验和提交当前页的表单数据
- 优化用户信息显示
- 优化`getTableFieldList`接口
- 统一接口响应数据`key`的命名规范
- 默认不再允许上传pdf格式的文件
- `Token::check`方法增加过期不抛出异常时的逻辑
### 修复/重构
- 修复模块下载安装时解压目录名可能错误的问题
- 文件后缀名大写时无法上传的问题
- 修复关联表名带下划线生成的代码出错
- 修复上传组件一处类型检查错误
- 会员中心的用户名默认不再禁止修改
- 会员修改绑定信息时账户验证通过的token在使用后立即删除
- 自定义排序字段模型onAfterInsert方法生成错误
- 修复生成三级以上的菜单规则时,无法为非超管分配权限的问题
- 修复可视化CRUD删除字段时可能出现报错的问题
- 去除多余的会员菜单规则
- 模块市场中与官网相关的URL修改
- 修复预览图片宽高较大时超出对话框的问题
- 修复公共搜索只有一个输入框时会触发表单的默认行为的问题
- 其他细节
## v1.1.0-Release
### 新增
- **可视化CRUD新增多种快捷组件并修复已知问题**
- 模块可以在启用和禁用脚本内备份配置数据和运行文件
- 模块支持向main.ts和App.vue添加代码
- 新增会员修改绑定信息(手机号、邮箱)支持
- 文件图片上传增加隐藏附件选择器的选项
- 远程下拉组件增加 label 格式化函数的属性
- 增加颜色选择器baInput
- 完善上传组件的onChange等事件
### 优化
- 优化后台登录页面自适应效果
- 优化首页和会员中心菜单样式
- 优化终端警告信息显示效果
- 优化账户名验证错误时的提示消息
- 详情弹窗可以点击弹窗外部进行关闭
- 禁止管理员向自己的角色组添加其他管理员
- 其他细节...
### 修复/重构
- 修复后台编辑弹窗缩放后显示异常的问题
- 修复在第一个tab右击菜单中关闭全部tab时报错的问题 #10
- 修复远程下拉可能出现已聚焦却无选项的问题
- 修复添加管理员和会员时可能出现表单验证信息的问题
- 修复模块管理中会员登录态过期后不自动注销的问题
- 修复系统配置中的数字输入框编辑可能无法保存的问题
- 修复系统配置中的上传组件从附件选择器中选择附件保存无效的问题
- 增加vue-qr依赖
- 增加忽略Desktop.ini
## v1.0.9-Release
- **新增可视化CRUD**
- 去除原命令行CRUD代码生成功能已打包为模块按需下载
- 添加表单颜色选择器和表格内的颜色渲染方式
- 侧边按钮增加 disabled 判定方法和按钮额外属性
- 增加获取数据表字段的辅助函数
- 增加获取一个目录所有文件的辅助函数
- 后台手机端自适应优化
- 公共搜索输入框可一键清空
- 远程下拉默认值优化
- 优化版本类/扩展类
- 优化树状表格
- `DELETE`请求的body改为query以兼容域名CNAME解析
- 在main.ts导入display.css而不是分散导入
- 修复url带参跳转时表格可能报错的问题
- 修复只添加为路由的菜单规则不能刷新的问题
- 修复验权时可能出现错误的问题
- 修复Linux下删除空文件夹可能失败的问题
- 修复自建模块处于未安装状态时显示异常的问题
- 会员切换登录注册时重置表单项 !70
- 会员切换到注册表单时清理用户名
- 管理员分组的上级分组禁止为自身
- 模块管理用户信息弹窗数据更新
- 本地模块更新日志显示异常的问题
## v1.0.8-Release
- **ThinkPHP发布6.1.0版本安全更新**,修正了序列化漏洞问题和优化多语言判断机制。
- 去除`lodash`依赖改用`lodash-es`(后者同时为`Element plus`的依赖,与框架更契合,包体积更小)
- 修复跨域代理示例的规则错误的问题
- 合并打包css文件、增加分包配置示例
- 完善工具函数注释、优化相关代码
- 模块详情展示效果优化
PS: 框架对`TP`的版本限定为`^6.0.0`针对tp本次安全更新git包的开发者可以直接`composer update`,若没更新到`v6.1.0`请更换`composer`源,`BuildAdmin`发新版本主要是为了更新完整包和资源包。
## v1.0.7-Release
- 富文本编辑器通过模块市场按需安装(框架不再内置),以方便选择不同的编辑器
- **增加附件资源库**
- 前台用户登录状态检测优化
- 事件监听优化
- 附件管理优化
- 单元格图片预览弹窗可以通过点击遮罩层关闭
- 自定义表格页码相关优化
- 搜索事件Data的类型定义优化
- 修复特殊类型文件上传时可能被限制的问题
- 优化敏感数据修改监听的逻辑
- 修复 typescript-eslint 依赖可能安装失败的问题
- 优化表单密码验证规则
## v1.0.6-Release
- Table组件增加多个插槽位提供`el-table-column`支持
- 增加WEB端文件上传扩展文件
- 增加文件上传前的类型与大小检查
- 增加文件单位转字节的函数
- 增加系统配置管理类
- 新增以编程的方式删除依赖的功能
- 新增模块安装时对互斥模块的检测
- 增加多个系统预置事件定义
- 增加发送邮件接口
- 增加发送短信接口
- 增加手机验证账户验证方式
- 增加responseType json 以外类型的处理逻辑
- 增加编程式添加系统配置中的快捷配置入口的方法
- 增加清理浏览器缓存的快捷按钮
- 升级element-plus版本到2.2.17
- 优化表单验证
- 优化表格的单元格渲染
- 优化多处类型定义
- 优化后端数据库字段读取函数
- 优化数据管理中数据表和控制器列表的加载
- 优化控制台页面暗黑模式下的文字颜色
- 优化模块安装时对互斥模块的检测
- 优化上传组件
- WEB端语言包文件无限层级读取
- 表格顶部菜单按钮图标在暗黑模式时的样式优化
- 禁用模块时可以选择保留一些由模块添加的依赖项
- 模块状态不为已安装时不定义AppInit事件
- 资源完整路径处理时加入上传文件cdnurl的判断
- Table组件不再使用事件巴士监听相关事件
- 删除文件不存在的附件记录前额外检查是否是本地存储
- 附件管理删除记录时同时删除文件,并提供友好提示信息
- 去除Table组件的action事件
- 去除TableHeader组件的action事件
- 输入组件帮助信息显示效果优化
- 修复对表格第三次排序时(取消排序时)失效的问题
- 修复部分后台功能缓存设置不生效的问题
- 修复多选远程下拉选择一次面板就收缩和无右侧箭头的问题
- 修复菜单规则管理中图标选择器在窗口关闭后残留的问题
- 修复图标选择器选取图标后无法再次显示的问题
- 模块安装器去除等待热更新步骤
- 修复预设表格页码或单页加载数量无效的问题
- 修复主动添加的系统配置不能删除的问题、格式化代码
- 修复模块依赖冲突检测可能异常的问题
- 修复安装云存储模块后,本地上传模块时被上传到云存储的问题
- 修复用户修改头像时顶栏和侧栏的头像图片可能404的问题
- 修复模块依赖冲突时,模块的启用脚本不执行的问题
- 修复模块安装完成后异常的显示了`模块已安装`的错误弹窗
- 管理员管理和会员管理接口中的敏感信息剔除
- 移除多余的IE相关判断
- 其他优化...
## v1.0.5-Release Preview
- 新增**模块市场**,一键安装某个功能、单页或是纯前端技术栈的学习案例项目等等,随时随地为系统添砖加瓦,系统能够自动维护`package.json``composer.json`并通过内置终端自动完成模块所需依赖的安装。
- 新增前后台**暗黑模式**支持
- 安装器不再要求数据表前缀必填、安装验证逻辑优化
- 终端原`popen`实现改为`proc_open`
- 重新实现图片文件上传组件
- 单元格渲染为 tags 时支持effect、size等属性
- url的点击事件增加当前行数据的参数
- 为管理员管理功能开启数据限制
- 后台Iframe相关多个细节完善
- 生成代码文件中的缩进改为空格而不是tab
- 访问后端接口时不再必须通过index.php入口文件
- 放行所有options请求
- 修复顶部菜单columnDisplay和comSearch同时不存在时仍然会残留一个div边框的问题
- 修复菜单规则管理中无法直接开关规则的问题
- 修复单选远程下拉清理输入框值后无法再读取全部远程数据的问题
- 修复axios封装在showCodeMessage=false时请求无后续处理的问题
- 修复表字段名称为length时CRUD生成语言包报错
- 修复删除菜单规则时未同时删除子级菜单的问题
- 修复角色组的资料可被越权修改的问题
- 修复触发到API请求节流时报错为跨域的问题
- 修复表格顶部下拉菜单复选框和按钮组占位
- 修复已上传文件丢失后,无法再次上传的问题
- 修复有默认值的情况多文件同时上传时文件列表错乱的问题
- 修复隐藏菜单情况刷新页面再展开菜单会导致顶部tab异常的问题
- 修复后台菜单折叠状态刷新后丢失的问题
- 修复管理员昵称过长时首次登录昵称被换行的问题
- 修复登录页面管理员头像位置自适应异常的问题
- 其他细节...
## v1.0.3-Release
- 完善英文语言包
- 公共搜索增加远程下拉组件支持
- 增加数据权限控制支持:不同管理员只可以查看有权数据行 的权限控制功能
- 自动识别表主键并添加到生成的模型属性
- 后台终端按钮只为超级管理员显示
- 关联表指定远程select下拉字段
- 增加表格快速搜索字段是否存在的检测
- 增加以type为后缀的enum等类型字段可被生成为单选框
- 站点系统配置缓存支持
- 增加会员中心开关
- 会员注册时通过API获取可用的验证方式、会员注册验证邮件实现
- 完善会员规则管理
- 表格公共搜索->对开关组件状态的搜索优化
- 公共搜索显示状态可通过baTable实例控制
- 验证码类支持到php8.1
- 去除file_list后缀的字段生成为多文件上传组件(与下拉组件后缀存在冲突)
- 优化角色组权限分配
- 优化默认管理员分组拥有的权限节点
- 数据回收和敏感数据规则中,不再使用带前缀的表全名
- 安装器`npm install`失败自动重试一次
- 安装器增加检测当前端口是否是8000
- 安装器完成页面增加重新安装按钮 (只清理缓存,不会删除install.lock)
- 修复敏感数据规则管理中删除敏感字段时的显示异常问题
- 修复表格时间字段未提供值时显示为当前时间的问题
- 修复管理员个人资料表单中的签名无法被重置的问题
- 修复后端默认应用不存在的问题
- 修复字段类型为char(1)时,生成的单选框无字典数据
- 修复数据表主键不为ID时编辑表单无法保存、表格无法排序等问题
- 修复顶栏标签全屏时,取消全屏的按钮会遮挡表格顶部操作按钮的问题
- 修复前后台路由规则名称重复时可能导致错误跳转问题
- 修复手机号验证正则无法识别部分已知号码的问题
- 修复系统配置中的禁止访问IP和时区配置项无效的问题
- 修复系统配置中富文本编辑器层级过高和无法编辑的问题
- 修复系统配置中时间和城市类型的输入组件无法正常录入值的问题
- 修复数据表没有注释时不生成菜单规则的问题
- 修复表格右侧无buttons且要初始化排序时会报错的问题
- 修复单元格渲染为tag时值为0等无法显示的问题
- 修复images字段名称后缀不能生成为图片上传组件的问题
- 修复管理员日志权限控制不完善的问题
- 修复管理员可通过后台使自己部分权限丢失的问题
- 修复管理员分组被禁用后还可以被远程select选择的问题
- 修复删除管理员时没有同时删除管理员的分组数据的问题
- 修复远程下拉搜索结果无法选中的问题、同时优化下拉选项面板显示逻辑
- 修复菜单规则和会员分组被禁用后在远程select中依然可以选择的问题
- 修复重复安装系统时.env-example被多次写入数据库资料的问题
- 修复数据安全监听中表不存在时的日志记录异常
- 其他细节优化
## v1.0.2-Release
- **增加前台会员中心**
- 安装器增加NPM源自动设置选项
- CRUD增加tinyint(1)类型的字段在符合条件下自动生成为单选框
- baInput单选/复选框/下拉框默认值传递数字支持
- baInput优化年份选择器
- baInput文件上传组件增加预览响应
- web端布局(layouts)内的目录结构调整
- 增加跨域代理配置示例,提供给有需要的小伙伴(感谢@ttdms
- 增加邮件发送类、增加phpMailer依赖、系统邮件配置增加测试邮件发送功能
- 后台右侧菜单增加清理缓存按钮
- 会员余额以分为单位保存到数据库,并在模型层做转换处理
- 附件管理增加上传会员字段
- 优化富文本编辑器滚动条样式、通用弹窗表单增加圆角
- 更新wangeditor依赖版本到5.1.1
- 增加会员资料的状态商店、优化后台登录状态判断逻辑
- 表格开关类型字段的公共搜索使用下拉框渲染
- 重构了站点首页
- 更新font-awesome的资源地址到国内CDN
- 去除build:online命令使用build代替
- 修复关闭管理员登录验证码后,登录任然报错验证码不存在的问题
- 修复富文本编辑器上传文件时提示未配置上传URL的问题
- 修复表格中的tag和url在无值时任然显示组件的问题
- 修复侧边菜单栏的非激活菜单项的图标颜色不符合直觉的问题
- 修复CRUD生成的代码在添加数据时权重字段无效的问题
- 修复部分日志记录没有标题的问题
- 修复已在后台或会员中心再跳转到模块首页时会卡在loading页面的问题
- 修复系统配置编辑时提示变量名不能为空的问题
- 修复后台表格右侧字段下拉没有高度限定的问题、修复一处样式缺失
- 修复管理员注销时偶尔需要权限的问题
- 修复默认的数据回收规则配置不完整的问题
- 修复表格顶部的批量操作按钮在未选择数据时依然可点击的问题
- 修复表格内tag在公共搜索中被渲染为下拉框的问题
- 修复管理员登录页面编译后可能存在的username未定义报错
## v1.0.1-Release
- 增加终端配置功能
- 终端增加是否运行于安装服务下的检测
- FormItem增加额外的块级输入提示选项
- 优化管理分组权限节点选择时的样式
- 语言包整理
- 额外暴露i18n实例实现在非setup中使用语言翻译
- 新增站点配置状态store
- 修复bug、完善README
## v1.0.0-beta
**公共测试版本**
- 内置WEB终端
- 一键CRUD
- Pinia
- 可视化配置+动态加载路由
- 细粒度权限控制
- 数据修改保护、数据全局回收
- ...

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 妙码生花
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

148
README.md Normal file
View File

@@ -0,0 +1,148 @@
<br />
<div align="center">
<img src="https://doc.buildadmin.com/images/logo.png" alt="" />
<h1 style="font-size: 36px;color: #2c3e50;font-weight: 600;margin: 0 0 6px 0;">BuildAdmin</h1>
<p style="font-size: 17px;color: #6a8bad;margin-bottom: 10px;">使用流行技术栈快速创建商业级后台管理系统</p>
<a href="https://uni.buildadmin.com" target="_blank">官网</a> |
<a href="https://demo.buildadmin.com" target="_blank">演示</a> |
<a href="https://ask.buildadmin.com" target="_blank">社区</a> |
<a href="https://doc.buildadmin.com/" target="_blank">文档</a> |
<a href="http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=paVQA1dlpsVNHTla-ZAts6e4pPK4va9R&authKey=Eto0dq9DOuYldJPl6URFAXXHlG2AFQtPUBxNHEByEiuSg9OraxMniXIaWFt46OKi&noverify=0&group_code=1039646575" target="_blank">加群</a> |
<a href="https://doc.buildadmin.com/guide/" target="_blank">视频介绍</a> |
<a href="https://gitee.com/wonderful-code/buildadmin" target="_blank">Gitee仓库</a> |
<a href="https://github.com/build-admin/BuildAdmin" target="_blank">GitHub仓库</a>
</div>
<br />
<p align="center">
<a href="https://www.thinkphp.cn/" target="_blank">
<img src="https://img.shields.io/badge/ThinkPHP-%3E8.1-brightgreen?color=91aac3&labelColor=439EFD" alt="vue">
</a>
<a href="https://v3.vuejs.org/" target="_blank">
<img src="https://img.shields.io/badge/Vue-%3E3.5-brightgreen?color=91aac3&labelColor=439EFD" alt="vue">
</a>
<a href="https://element-plus.org/zh-CN/guide/changelog.html" target="_blank">
<img src="https://img.shields.io/badge/Element--Plus-%3E2.9-brightgreen?color=91aac3&labelColor=439EFD" alt="element plus">
</a>
<a href="https://www.tslang.cn/" target="_blank">
<img src="https://img.shields.io/badge/TypeScript-%3E5.7-blue?color=91aac3&labelColor=439EFD" alt="typescript">
</a>
<a href="https://vitejs.dev/" target="_blank">
<img src="https://img.shields.io/badge/Vite-%3E6.0-blue?color=91aac3&labelColor=439EFD" alt="vite">
</a>
<a href="https://pinia.vuejs.org/" target="_blank">
<img src="https://img.shields.io/badge/Pinia-%3E2.3-blue?color=91aac3&labelColor=439EFD" alt="vite">
</a>
<a href="https://gitee.com/wonderful-code/buildadmin/blob/master/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/Apache2.0-license-blue?color=91aac3&labelColor=439EFD" alt="license">
</a>
</p>
<br>
<div align="center">
<img src="https://doc.buildadmin.com/images/readme/dashboard-radius.png" />
</div>
<br>
### 介绍
🌈 基于 Vue3.x + ThinkPHP8 + TypeScript + Vite + Pinia + Element Plus 等流行技术栈的后台管理系统,支持常驻内存运行、可视化 CRUD 代码生成、自带 WEB 终端、自适应多端、同时提供 Web、WebNuxt、Server 端,内置全局数据回收站和字段级数据修改保护、自动注册路由、无限子级权限管理等,无需授权即可免费商用,希望能帮助大家实现快速开发。
✨ 关于 `Star` 的小小期待 ✨
~~文档和演示站的「入场券」是点亮 Star~~ 哈哈哈,开个玩笑,实际上**您不需要任何「门槛」即可访问源码、文档和演示站**,在您丝滑体验文档与强大功能的同时,我们有个温暖的请求 —— 若 `BuildAdmin` 让您眼前一亮,请为我们点亮一颗 `Star`,这将是一次开发者间最浪漫的「确认过眼神」,亦可助我们向本应「自由开放」的开源界证明:优秀的项目我会发自内心的点亮 Star ~(而不是像某些同类产品哪样,将 Star 作为文档或演示站的「强制交换」条件)
### 主要特性
**🚀 CRUD 代码生成:**
图形化拖拽生成后台增删改查代码,自动创建数据表;大气且实用的表格,多达 24 种表单组件支持,行拖拽排序,受权限控制的编辑和删除等等,并支持关联表,可为您节省大量开发时间。
**💥 内置 WEB 终端:**
在后台管理系统领域,我们率先将终端深度集成于系统的 `本地开发环境` 中,它能实现很多理想中的功能,比如:虽然是基于 Vue3 的系统,但在安装时并不需要手动的执行 `npm install` 命令CRUD 代码生成完毕后,自动调用 `prettier` 格式化代码等。本终端设计上能够调用环境变量中的任意命令,天花板极高,后续将为您提供更多方便、快捷的服务。
**👍 流行且稳定的技术栈:**
除了基于 `ThinkPHP8` 前后端分离架构外,我们的 `Vue3` 使用了 `setup、useTemplateRef` 等,状态管理使用 `Pinia`,并使用了 `TypeScript、Vite` 等可以为你的知识面添砖加瓦的技术栈。使用流行技术栈自然代表本框架兼容相关Vue3+TP8+PHP8.x生态生态系统内数不清的库、包、组件能够使您的开发事半功倍。
**🎨 模块市场:**
一键安装数据导入导出、短信发送、支付、云存储、富文本编辑器,甚至 CMS、商城、社区、纯前端技术栈的学习案例项目等随时随地为系统添砖加瓦系统能够自动维护 `package.json``composer.json` 并通过内置终端自动完成模块所需依赖的安装。
**🔀 前后端分离:**
项目的 `web` 文件夹内包含: `干净`(不含后端代码)、`完整`(所有前端代码文件均在此内)的前端代码文件,代码和部署均可前后分离,对前端开发者友好,作为纯前端开发者,您可以将 BAdmin 当做学习与资源的社群,本系统可为您准备好案例和模板等所需要的环境,而您只需专注于学习或工作,不需要会任何后端代码!(邀您:[和我们一起](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=paVQA1dlpsVNHTla-ZAts6e4pPK4va9R&authKey=Eto0dq9DOuYldJPl6URFAXXHlG2AFQtPUBxNHEByEiuSg9OraxMniXIaWFt46OKi&noverify=0&group_code=1039646575)
**⚡️ 常驻内存:**
系统内置的功能均可常驻内存运行,享受比传统框架快上数十倍的性能提升!目前 [Workerman 模块](https://modules.buildadmin.com/workerman) 可提供框架的常驻内存 `HTTP` 服务,同时该模块还提供了开箱即用的 `WebSocket` 服务。
**🚚 按需加载:**
前端的页面组件和语言包均是在使用到它们时,才从网络异步加载,服务端则是基于 `TP8``PSR` 规范,天生拥有真正的按需加载能力,所以,您无需考虑 `我并不需要多语言、我并不需要某个后台功能` 这类的问题,不需要不使用或隐藏即可。
**🌴 数据回收与反悔:**
内置全局数据回收站,并且提供字段级数据修改记录和修改对比,随时回滚和还原,安全且无感。
**✨ 高颜值:**
提供三种布局模式,其中默认布局使用无边框设计风格,它并没有强行填满屏幕的每一个缝然后使用边框线进行分隔,所有的功能版块,都像是悬浮在屏幕上的,同时又将屏幕空间及其合理的利用了。
**🔐 权限验证:**
可视化的管理权限,然后根据权限动态的注册路由、菜单、页面、按钮(权限节点)、支持无限父子级权限分组、前后端搭配鉴权,自由分派页面和按钮权限。
**📝 未来可期:**
我们正在持续维护系统,并着手开发更多基础设施模块,按需一键安装,甚至提供开箱即用的各行业完整应用。
**🧱 一举多得:**
后台自适应 PC、平板、手机 等多种场景的支持,轻松应对各种需求。
**💖 其他杂项:**
角色组/管理员/管理员日志、 会员/会员组/会员余额、积分日志、系统配置/控制台/附件管理/个人资料管理等等、更多特性等你探索...
### 安装使用
💫 我们提供了完善的文档,对于熟悉 `ThinkPHP``Vue` 的用户,请使用大佬版:[快速上手](https://doc.buildadmin.com/guide/install/start.html) ,对于新人朋友,我们额外准备了各个操作系统的从零开始套餐:[Windows从零到一](https://doc.buildadmin.com/guide/install/windows.html) | [Linux从零到一](https://doc.buildadmin.com/guide/install/linux-bt.html) | [MacBook安装引导](https://doc.buildadmin.com/guide/install/macBook.html)
### 联系我们
- [演示站](https://demo.buildadmin.com) 账户:`admin`,密码:`123456`(演示站数据无法修改,请下载源码安装体验全部功能)
- [问答社区ask.buildadmin.com](https://ask.buildadmin.com)
- [官方网站uni.buildadmin.com](https://uni.buildadmin.com)
- [文档doc.buildadmin.com](https://doc.buildadmin.com/)
- 加群:[687903819>960/1000](https://jq.qq.com/?_wv=1027&k=QwtXa14c)、[751852082>1990/2000](https://jq.qq.com/?_wv=1027&k=c8a7iSk8)、[1039646575](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=paVQA1dlpsVNHTla-ZAts6e4pPK4va9R&authKey=Eto0dq9DOuYldJPl6URFAXXHlG2AFQtPUBxNHEByEiuSg9OraxMniXIaWFt46OKi&noverify=0&group_code=1039646575)
- [Gitee仓库](https://gitee.com/wonderful-code/buildadmin)、[GitHub仓库](https://github.com/build-admin/BuildAdmin)
- [备用文档docs.buildadmin.net](https://docs.buildadmin.net/)
- [官方邮箱 hi@buildadmin.com](mailto:hi@buildadmin.com)
### 项目预览
| | |
|---------------------|---------------------|
|![登录](https://doc.buildadmin.com/images/readme/login.gif)|![控制台](https://doc.buildadmin.com/images/readme/dashboard.png)|
|![布局配置](https://doc.buildadmin.com/images/readme/layout.png)|![表格](https://doc.buildadmin.com/images/readme/admin.png)|
|![表单](https://doc.buildadmin.com/images/readme/user.png)|![系统配置](https://doc.buildadmin.com/images/readme/config.png)|
|![数据回收规则](https://doc.buildadmin.com/images/readme/data-recycle.png)|![数据回收日志](https://doc.buildadmin.com/images/readme/data-recycle-log.png)|
|![敏感数据](https://doc.buildadmin.com/images/readme/sensitive-data.png)|![菜单](https://doc.buildadmin.com/images/readme/menu.png)|
|![单栏布局](https://doc.buildadmin.com/images/readme/layout-3.png)|![经典布局](https://doc.buildadmin.com/images/readme/layout-2.png)|
### 特别鸣谢
💕 感谢巨人提供肩膀,排名不分先后
- [Thinkphp](http://www.thinkphp.cn/)
- [FastAdmin](https://gitee.com/karson/fastadmin)
- [Vue](https://github.com/vuejs/core)
- [vue-next-admin](https://gitee.com/lyt-top/vue-next-admin)
- [Element Plus](https://github.com/element-plus/element-plus)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [vue-router](https://github.com/vuejs/vue-router-next)
- [vite](https://github.com/vitejs/vite)
- [Pinia](https://github.com/vuejs/pinia)
- [Axios](https://github.com/axios/axios)
- [nprogress](https://github.com/rstacruz/nprogress)
- [screenfull](https://github.com/sindresorhus/screenfull.js)
- [mitt](https://github.com/developit/mitt)
- [sass](https://github.com/sass/sass)
- [echarts](https://github.com/apache/echarts)
- [vueuse](https://github.com/vueuse/vueuse)
- [lodash](https://github.com/lodash/lodash)
- [eslint](https://github.com/eslint/eslint)
- [prettier](https://github.com/prettier/prettier)
- [Sortable](https://github.com/SortableJS/Sortable)
- [v-code-diff](https://github.com/Shimada666/v-code-diff)
- [clicaptcha](https://github.com/hooray/clicaptcha)
- [phinx](https://github.com/cakephp/phinx)
- [jetbrains](https://www.jetbrains.com/)
### 版权信息
🔐 BuildAdmin 遵循 `Apache2.0` 开源协议发布,提供无需授权的免费使用。\
本项目包含的第三方源码和二进制文件之版权信息另行标注。
### 支持项目
💕 无需捐赠,如果觉得项目不错,或者已经在使用了,希望你可以去 [Github](https://github.com/build-admin/BuildAdmin) 或者 [Gitee](https://gitee.com/wonderful-code/buildadmin) 帮我们点个 ⭐ Star这将是对我们极大的鼓励与支持。

1
app/.htaccess Normal file
View File

@@ -0,0 +1 @@
deny from all

22
app/AppService.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare (strict_types=1);
namespace app;
use think\Service;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
// 服务注册
}
public function boot()
{
// 服务启动
}
}

93
app/BaseController.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
declare (strict_types=1);
namespace app;
use think\App;
use think\Request;
use think\Validate;
use think\exception\ValidateException;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var Request
*/
protected Request $request;
/**
* 是否批量验证
* @var bool
*/
protected bool $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected array $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(protected App $app)
{
$this->request = $this->app->request;
$this->request->controllerPath = str_replace('.', '/', $this->request->controller(true));
// 控制器初始化
$this->initialize();
}
/**
* 初始化
* @access protected
*/
protected function initialize(): void
{
}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param array|string $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, array|string $validate, array $message = [], bool $batch = false): bool|array|string
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = str_contains($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch();
}
return $v->failException()->check($data);
}
}

119
app/ExceptionHandle.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace app;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Response;
use think\Request;
use Throwable;
/**
* 应用异常处理类
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录)
*
* @access public
* @param Throwable $exception
* @return void
*/
public function report(Throwable $exception): void
{
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @access public
* @param Request $request
* @param Throwable $e
* @return Response
*/
public function render(Request $request, Throwable $e): Response
{
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
/**
* 收集异常数据
*/
protected function convertExceptionToArray(Throwable $exception): array
{
if ($this->app->isDebug()) {
// 调试模式,获取详细的错误信息
$traces = [];
$nextException = $exception;
do {
$traces[] = [
'name' => $nextException::class,
'file' => $nextException->getFile(),
'line' => $nextException->getLine(),
'code' => $this->getCode($nextException),
'message' => $this->getMessage($nextException),
'trace' => $nextException->getTrace(),
'source' => $this->getSourceCode($nextException),
];
} while ($nextException = $nextException->getPrevious());
// 循环引用检测并直接置空 traces比起循环引用导致的报错置空后开发者能得到更多真实的错误信息
if ($this->app->request->isJson()) {
$json = json_encode($traces, JSON_UNESCAPED_UNICODE);
if (false === $json && in_array(json_last_error(), [JSON_ERROR_DEPTH, JSON_ERROR_RECURSION])) {
$traces = [];
}
}
$data = [
'code' => $this->getCode($exception),
'message' => $this->getMessage($exception),
'traces' => $traces,
'datas' => $this->getExtendData($exception),
'tables' => [
'GET Data' => $this->app->request->get(),
'POST Data' => $this->app->request->post(),
'Files' => $this->app->request->file(),
'Cookies' => $this->app->request->cookie(),
'Session' => $this->app->exists('session') ? $this->app->session->all() : [],
'Server/Request Data' => $this->app->request->server(),
],
];
} else {
// 部署模式仅显示 Code 和 Message
$data = [
'code' => $this->getCode($exception),
'message' => $this->getMessage($exception),
];
if (!$this->app->config->get('app.show_error_msg')) {
// 不显示详细错误信息
$data['message'] = $this->app->config->get('app.error_message');
}
}
return $data;
}
}

26
app/Request.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace app;
/**
* 应用请求对象类
*/
class Request extends \think\Request
{
/**
* 全局过滤规则
* app/common.php 的 filter 函数
*/
protected $filter = 'filter';
public function __construct()
{
parent::__construct();
// 从配置文件读取代理服务器ip并设置给 \think\Request
$proxyServerIp = config('buildadmin.proxy_server_ip');
if (is_array($proxyServerIp) && $proxyServerIp) {
$this->proxyServerIp = $proxyServerIp;
}
}
}

34
app/admin/common.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
use ba\Filesystem;
use GuzzleHttp\Client;
if (!function_exists('get_controller_list')) {
function get_controller_list($app = 'admin'): array
{
$controllerDir = root_path() . 'app' . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR . 'controller' . DIRECTORY_SEPARATOR;
return Filesystem::getDirFiles($controllerDir);
}
}
if (!function_exists('get_ba_client')) {
/**
* 获取一个请求 BuildAdmin 开源社区的 Client
* @throws Throwable
*/
function get_ba_client(): Client
{
return new Client([
'base_uri' => config('buildadmin.api_url'),
'timeout' => 30,
'connect_timeout' => 30,
'verify' => false,
'http_errors' => false,
'headers' => [
'X-REQUESTED-WITH' => 'XMLHttpRequest',
'Referer' => dirname(request()->root(true)),
'User-Agent' => 'BuildAdminClient',
]
]);
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace app\admin\controller;
use Throwable;
use ba\Terminal;
use think\Response;
use ba\TableManager;
use think\facade\Db;
use think\facade\Cache;
use think\facade\Event;
use app\admin\model\AdminLog;
use app\common\library\Upload;
use app\common\controller\Backend;
class Ajax extends Backend
{
protected array $noNeedPermission = ['*'];
/**
* 无需登录的方法
* terminal 内部自带验权
*/
protected array $noNeedLogin = ['terminal'];
public function initialize(): void
{
parent::initialize();
}
public function upload(): void
{
AdminLog::instance()->setTitle(__('upload'));
$file = $this->request->file('file');
$driver = $this->request->param('driver', 'local');
$topic = $this->request->param('topic', 'default');
try {
$upload = new Upload();
$attachment = $upload
->setFile($file)
->setDriver($driver)
->setTopic($topic)
->upload(null, $this->auth->id);
unset($attachment['create_time'], $attachment['quote']);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
/**
* 获取省市区数据
* @throws Throwable
*/
public function area(): void
{
$this->success('', get_area());
}
public function buildSuffixSvg(): Response
{
$suffix = $this->request->param('suffix', 'file');
$background = $this->request->param('background');
$content = build_suffix_svg((string)$suffix, (string)$background);
return response($content, 200, ['Content-Length' => strlen($content)])->contentType('image/svg+xml');
}
/**
* 获取已脱敏的数据库连接配置列表
* @throws Throwable
*/
public function getDatabaseConnectionList(): void
{
$quickSearch = $this->request->get("quickSearch/s", '');
$connections = config('database.connections');
$desensitization = [];
foreach ($connections as $key => $connection) {
$connection = TableManager::getConnectionConfig($key);
$desensitization[] = [
'type' => $connection['type'],
'database' => substr_replace($connection['database'], '****', 1, strlen($connection['database']) > 4 ? 2 : 1),
'key' => $key,
];
}
if ($quickSearch) {
$desensitization = array_filter($desensitization, function ($item) use ($quickSearch) {
return preg_match("/$quickSearch/i", $item['key']);
});
$desensitization = array_values($desensitization);
}
$this->success('', [
'list' => $desensitization,
]);
}
/**
* 获取表主键字段
* @param ?string $table
* @param ?string $connection
* @throws Throwable
*/
public function getTablePk(?string $table = null, ?string $connection = null): void
{
if (!$table) {
$this->error(__('Parameter error'));
}
$table = TableManager::tableName($table, true, $connection);
if (!TableManager::phinxAdapter(false, $connection)->hasTable($table)) {
$this->error(__('Data table does not exist'));
}
$tablePk = Db::connect(TableManager::getConnection($connection))
->table($table)
->getPk();
$this->success('', ['pk' => $tablePk]);
}
/**
* 获取数据表列表
* @throws Throwable
*/
public function getTableList(): void
{
$quickSearch = $this->request->get("quickSearch/s", '');
$connection = $this->request->request('connection');// 数据库连接配置标识
$samePrefix = $this->request->request('samePrefix/b', true);// 是否仅返回项目数据表(前缀同项目一致的)
$excludeTable = $this->request->request('excludeTable/a', []);// 要排除的数据表数组(表名无需带前缀)
$outTables = [];
$dbConfig = TableManager::getConnectionConfig($connection);
$tables = TableManager::getTableList($connection);
if ($quickSearch) {
$tables = array_filter($tables, function ($comment) use ($quickSearch) {
return preg_match("/$quickSearch/i", $comment);
});
}
$pattern = '/^' . $dbConfig['prefix'] . '/i';
foreach ($tables as $table => $comment) {
if ($samePrefix && !preg_match($pattern, $table)) continue;
$table = preg_replace($pattern, '', $table);
if (!in_array($table, $excludeTable)) {
$outTables[] = [
'table' => $table,
'comment' => $comment,
'connection' => $connection,
'prefix' => $dbConfig['prefix'],
];
}
}
$this->success('', [
'list' => $outTables,
]);
}
/**
* 获取数据表字段列表
* @throws Throwable
*/
public function getTableFieldList(): void
{
$table = $this->request->param('table');
$clean = $this->request->param('clean', true);
$connection = $this->request->request('connection');
if (!$table) {
$this->error(__('Parameter error'));
}
$connection = TableManager::getConnection($connection);
$tablePk = Db::connect($connection)->name($table)->getPk();
$this->success('', [
'pk' => $tablePk,
'fieldList' => TableManager::getTableColumns($table, $clean, $connection),
]);
}
public function changeTerminalConfig(): void
{
AdminLog::instance()->setTitle(__('Change terminal config'));
if (Terminal::changeTerminalConfig()) {
$this->success();
} else {
$this->error(__('Failed to modify the terminal configuration. Please modify the configuration file manually:%s', ['/config/buildadmin.php']));
}
}
public function clearCache(): void
{
AdminLog::instance()->setTitle(__('Clear cache'));
$type = $this->request->post('type');
if ($type == 'tp' || $type == 'all') {
Cache::clear();
} else {
$this->error(__('Parameter error'));
}
Event::trigger('cacheClearAfter', $this->app);
$this->success(__('Cache cleaned~'));
}
/**
* 终端
* @throws Throwable
*/
public function terminal(): void
{
(new Terminal())->exec();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace app\admin\controller;
use app\common\controller\Backend;
class Dashboard extends Backend
{
public function initialize(): void
{
parent::initialize();
}
public function index(): void
{
$this->success('', [
'remark' => get_route_remark()
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare (strict_types=1);
namespace app\admin\controller;
use Throwable;
use ba\ClickCaptcha;
use think\facade\Config;
use think\facade\Validate;
use app\common\facade\Token;
use app\admin\model\AdminLog;
use app\common\controller\Backend;
class Index extends Backend
{
protected array $noNeedLogin = ['logout', 'login'];
protected array $noNeedPermission = ['index'];
/**
* 后台初始化请求
* @return void
* @throws Throwable
*/
public function index(): void
{
$adminInfo = $this->auth->getInfo();
$adminInfo['super'] = $this->auth->isSuperAdmin();
unset($adminInfo['token'], $adminInfo['refresh_token']);
$menus = $this->auth->getMenus();
if (!$menus) {
$this->error(__('No background menu, please contact super administrator!'));
}
$this->success('', [
'adminInfo' => $adminInfo,
'menus' => $menus,
'siteConfig' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'apiUrl' => Config::get('buildadmin.api_url'),
'upload' => keys_to_camel_case(get_upload_config(), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'cdnUrl' => full_url(),
'cdnUrlParams' => Config::get('buildadmin.cdn_url_params'),
],
'terminal' => [
'phpDevelopmentServer' => str_contains($_SERVER['SERVER_SOFTWARE'], 'Development Server'),
'npmPackageManager' => Config::get('terminal.npm_package_manager'),
]
]);
}
/**
* 管理员登录
* @return void
* @throws Throwable
*/
public function login(): void
{
// 检查登录态
if ($this->auth->isLogin()) {
$this->success(__('You have already logged in. There is no need to log in again~'), [
'type' => $this->auth::LOGGED_IN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$captchaSwitch = Config::get('buildadmin.admin_login_captcha');
// 检查提交
if ($this->request->isPost()) {
$username = $this->request->post('username');
$password = $this->request->post('password');
$keep = $this->request->post('keep');
$rule = [
'username|' . __('Username') => 'require|length:3,30',
'password|' . __('Password') => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
];
$data = [
'username' => $username,
'password' => $password,
];
if ($captchaSwitch) {
$rule['captchaId|' . __('CaptchaId')] = 'require';
$rule['captchaInfo|' . __('Captcha')] = 'require';
$data['captchaId'] = $this->request->post('captchaId');
$data['captchaInfo'] = $this->request->post('captchaInfo');
}
$validate = Validate::rule($rule);
if (!$validate->check($data)) {
$this->error($validate->getError());
}
if ($captchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($data['captchaId'], $data['captchaInfo'])) {
$this->error(__('Captcha error'));
}
}
AdminLog::instance()->setTitle(__('Login'));
$res = $this->auth->login($username, $password, (bool)$keep);
if ($res === true) {
$this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getInfo()
]);
} else {
$msg = $this->auth->getError();
$msg = $msg ?: __('Incorrect user name or password!');
$this->error($msg);
}
}
$this->success('', [
'captcha' => $captchaSwitch
]);
}
/**
* 管理员注销
* @return void
*/
public function logout(): void
{
if ($this->request->isPost()) {
$refreshToken = $this->request->post('refreshToken', '');
if ($refreshToken) Token::delete((string)$refreshToken);
$this->auth->logout();
$this->success();
}
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace app\admin\controller;
use Throwable;
use ba\Exception;
use think\facade\Config;
use app\admin\model\AdminLog;
use app\admin\library\module\Server;
use app\admin\library\module\Manage;
use app\common\controller\Backend;
class Module extends Backend
{
protected array $noNeedPermission = ['state', 'dependentInstallComplete'];
public function initialize(): void
{
parent::initialize();
}
public function index(): void
{
$this->success('', [
'installed' => Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR),
'sysVersion' => Config::get('buildadmin.version'),
'nuxtVersion' => Server::getNuxtVersion(),
]);
}
public function state(): void
{
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
$this->success('', [
'state' => Manage::instance($uid)->getInstallState()
]);
}
public function install(): void
{
AdminLog::instance()->setTitle(__('Install module'));
$uid = $this->request->param("uid/s", '');
$update = $this->request->param("update/b", false);
if (!$uid) {
$this->error(__('Parameter error'));
}
$res = [];
try {
$res = Manage::instance($uid)->install($update);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'data' => $res,
]);
}
public function dependentInstallComplete(): void
{
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->dependentInstallComplete('all');
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success();
}
public function changeState(): void
{
AdminLog::instance()->setTitle(__('Change module state'));
$uid = $this->request->post("uid/s", '');
$state = $this->request->post("state/b", false);
if (!$uid) {
$this->error(__('Parameter error'));
}
$info = [];
try {
$info = Manage::instance($uid)->changeState($state);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'info' => $info,
]);
}
public function uninstall(): void
{
AdminLog::instance()->setTitle(__('Unload module'));
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->uninstall();
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success();
}
public function upload(): void
{
AdminLog::instance()->setTitle(__('Upload install module'));
$file = $this->request->get("file/s", '');
$token = $this->request->get("token/s", '');
if (!$file) $this->error(__('Parameter error'));
if (!$token) $this->error(__('Please login to the official website account first'));
$info = [];
try {
$info = Manage::instance()->upload($token, $file);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'info' => $info
]);
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace app\admin\controller\auth;
use Throwable;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\Admin as AdminModel;
class Admin extends Backend
{
/**
* 模型
* @var object
* @phpstan-var AdminModel
*/
protected object $model;
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip'];
protected array|string $quickSearchField = ['username', 'nickname'];
/**
* 开启数据限制
*/
protected string|int|bool $dataLimit = 'allAuthAndOthers';
protected string $dataLimitField = 'id';
public function initialize(): void
{
parent::initialize();
$this->model = new AdminModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('add')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
}
$passwd = $data['password']; // 密码将被排除不直接入库
$data = $this->excludeFields($data);
$result = false;
if ($data['group_arr']) $this->checkGroupAuth($data['group_arr']);
$this->model->startTrans();
try {
$result = $this->model->save($data);
if ($data['group_arr']) {
$groupAccess = [];
foreach ($data['group_arr'] as $datum) {
$groupAccess[] = [
'uid' => $this->model->id,
'group_id' => $datum,
];
}
Db::name('admin_group_access')->insertAll($groupAccess);
}
$this->model->commit();
if (!empty($passwd)) {
$this->model->resetPassword($this->model->id, $passwd);
}
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('edit')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
}
if ($this->auth->id == $data['id'] && $data['status'] == 'disable') {
$this->error(__('Please use another administrator account to disable the current account!'));
}
if (!empty($data['password'])) {
$this->model->resetPassword($row->id, $data['password']);
}
$groupAccess = [];
if ($data['group_arr']) {
$checkGroups = [];
foreach ($data['group_arr'] as $datum) {
if (!in_array($datum, $row->group_arr)) {
$checkGroups[] = $datum;
}
$groupAccess[] = [
'uid' => $id,
'group_id' => $datum,
];
}
$this->checkGroupAuth($checkGroups);
}
Db::name('admin_group_access')
->where('uid', $id)
->delete();
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
if ($groupAccess) Db::name('admin_group_access')->insertAll($groupAccess);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
unset($row['salt'], $row['login_failure']);
$row['password'] = '';
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
if ($v->id != $this->auth->id) {
$count += $v->delete();
Db::name('admin_group_access')
->where('uid', $v['id'])
->delete();
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 检查分组权限
* @throws Throwable
*/
private function checkGroupAuth(array $groups): void
{
if ($this->auth->isSuperAdmin()) {
return;
}
$authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers');
foreach ($groups as $group) {
if (!in_array($group, $authGroups)) {
$this->error(__('You have no permission to add an administrator to this group!'));
}
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace app\admin\controller\auth;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\AdminLog as AdminLogModel;
class AdminLog extends Backend
{
/**
* @var object
* @phpstan-var AdminLogModel
*/
protected object $model;
protected string|array $preExcludeFields = ['create_time', 'admin_id', 'username'];
protected string|array $quickSearchField = ['title'];
public function initialize(): void
{
parent::initialize();
$this->model = new AdminLogModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = ['admin_id', '=', $this->auth->id];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,379 @@
<?php
namespace app\admin\controller\auth;
use ba\Tree;
use Throwable;
use think\facade\Db;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\common\controller\Backend;
class Group extends Backend
{
/**
* 修改、删除分组时对操作管理员进行鉴权
* 本管理功能部分场景对数据权限有要求,修改此值请额外确定以下的 absoluteAuth 实现的功能
* allAuthAndOthers=管理员拥有该分组所有权限并拥有额外权限时允许
*/
protected string $authMethod = 'allAuthAndOthers';
/**
* 数据模型
* @var object
* @phpstan-var AdminGroup
*/
protected object $model;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $quickSearchField = 'name';
/**
* @var Tree
*/
protected Tree $tree;
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 登录管理员的角色组
* @var array
*/
protected array $adminGroups = [];
public function initialize(): void
{
parent::initialize();
$this->model = new AdminGroup();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get("initValue/a", []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request("quickSearch", '');
// 有初始化值时不组装树状(初始化出来的值更好看)
$this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getGroups(),
'group' => $this->adminGroups,
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$this->checkAuth($id);
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$adminGroup = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
if (in_array($data['id'], $adminGroup)) {
$this->error(__('You cannot modify your own management group!'));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('edit')->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
// 读取所有pid全部从节点数组移除父级选择状态由子级决定
$pidArr = AdminRule::field('pid')
->distinct()
->where('id', 'in', $row->rules)
->select()
->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$row->rules = array_values($rules);
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where($this->model->getPk(), 'in', $ids)->select();
foreach ($data as $v) {
$this->checkAuth($v->id);
}
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$this->error(__('Please delete the child element first, or use batch deletion'));
}
}
$adminGroup = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
if (!in_array($v['id'], $adminGroup)) {
$count += $v->delete();
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 远程下拉
* @return void
* @throws Throwable
*/
public function select(): void
{
$data = $this->getGroups([['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data));
}
$this->success('', [
'options' => $data
]);
}
/**
* 权限节点入库前处理
* @throws Throwable
*/
private function handleRules(array &$data): array
{
if (!empty($data['rules']) && is_array($data['rules'])) {
$superAdmin = true;
$checkedRules = [];
$allRuleIds = AdminRule::column('id');
// 遍历检查权限ID是否存在以免传递了可预测的未来权限ID号
foreach ($data['rules'] as $postRuleId) {
if (in_array($postRuleId, $allRuleIds)) {
$checkedRules[] = $postRuleId;
}
}
// 正在建立超管级分组?
foreach ($allRuleIds as $ruleId) {
if (!in_array($ruleId, $checkedRules)) {
$superAdmin = false;
}
}
if ($superAdmin && $this->auth->isSuperAdmin()) {
// 允许超管建立超管级分组
$data['rules'] = '*';
} else {
// 当前管理员所拥有的权限节点
$ownedRuleIds = $this->auth->getRuleIds();
// 禁止添加`拥有自己全部权限`的分组
if (!array_diff($ownedRuleIds, $checkedRules)) {
$this->error(__('Role group has all your rights, please contact the upper administrator to add or do not need to add!'));
}
// 检查分组权限是否超出了自己的权限(超管的 $ownedRuleIds 为 ['*'],不便且可以不做此项检查)
if (array_diff($checkedRules, $ownedRuleIds) && !$this->auth->isSuperAdmin()) {
$this->error(__('The group permission node exceeds the range that can be allocated'));
}
$data['rules'] = implode(',', $checkedRules);
}
} else {
unset($data['rules']);
}
return $data;
}
/**
* 获取分组
* @param array $where
* @return array
* @throws Throwable
*/
private function getGroups(array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $pk);
// 下拉选择时只获取:拥有所有权限并且有额外权限的分组
$absoluteAuth = $this->request->get('absoluteAuth/b', false);
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
if (!$this->auth->isSuperAdmin()) {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, $where);
if (!$absoluteAuth) $authGroups = array_merge($this->adminGroups, $authGroups);
$where[] = ['id', 'in', $authGroups];
}
$data = $this->model->where($where)->select()->toArray();
// 获取第一个权限的名称供列表显示-s
foreach ($data as &$datum) {
if ($datum['rules']) {
if ($datum['rules'] == '*') {
$datum['rules'] = __('Super administrator');
} else {
$rules = explode(',', $datum['rules']);
if ($rules) {
$rulesFirstTitle = AdminRule::where('id', $rules[0])->value('title');
$datum['rules'] = count($rules) == 1 ? $rulesFirstTitle : $rulesFirstTitle . '等 ' . count($rules) . ' 项';
}
}
} else {
$datum['rules'] = __('No permission');
}
}
// 获取第一个权限的名称供列表显示-e
// 如果要求树状,此处先组装好 children
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
/**
* 检查权限
* @param $groupId
* @return void
* @throws Throwable
*/
private function checkAuth($groupId): void
{
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, []);
if (!$this->auth->isSuperAdmin() && !in_array($groupId, $authGroups)) {
$this->error(__($this->authMethod == 'allAuth' ? 'You need to have all permissions of this group to operate this group~' : 'You need to have all the permissions of the group and have additional permissions before you can operate the group~'));
}
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace app\admin\controller\auth;
use ba\Tree;
use Throwable;
use app\common\library\Menu;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\common\controller\Backend;
use app\admin\library\crud\Helper;
class Rule extends Backend
{
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $defaultSortField = ['weigh' => 'desc'];
protected string|array $quickSearchField = 'title';
/**
* @var object
* @phpstan-var AdminRule
*/
protected object $model;
/**
* @var Tree
*/
protected Tree $tree;
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 开启模型验证
* @var bool
*/
protected bool $modelValidate = false;
public function initialize(): void
{
parent::initialize();
// 防止 URL 中的特殊符号被默认的 filter 函数转义
$this->request->filter('clean_xss');
$this->model = new AdminRule();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get('initValue/a', []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request('quickSearch', '');
$this->assembleTree = $isTree && !$this->initValue; // 有初始化值时不组装树状(初始化出来的值更好看)
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getMenus(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
// 检查有那些分组可以拥有新增菜单的权限
if (!empty($data['pid'])) {
$this->autoAssignPermission($this->model->id, $data['pid']);
}
// 创建子级权限节点
if ($data['type'] == 'menu' && !empty($data['buttons'])) {
$newButtons = [];
foreach ($data['buttons'] as $button) {
foreach (Helper::$menuChildren as $menuChild) {
if ($menuChild['name'] == '/' . $button) {
$menuChild['name'] = $data['name'] . $menuChild['name'];
$newButtons[] = $menuChild;
}
}
}
if (!empty($newButtons)) {
// 创建子级权限节点
Menu::create($newButtons, $this->model->id, 'ignore');
// 检查有那些分组可以拥有新增的子级权限
$children = AdminRule::where('pid', $this->model->id)->select();
foreach ($children as $child) {
$this->autoAssignPermission($child['id'], $this->model->id);
}
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$id = $this->request->param($this->model->getPk());
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$validate->check($data);
}
}
if (isset($data['pid']) && $data['pid'] > 0) {
// 满足意图并消除副作用
$parent = $this->model->where('id', $data['pid'])->find();
if ($parent['pid'] == $row['id']) {
$parent->pid = 0;
$parent->save();
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
// 子级元素检查
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$this->error(__('Please delete the child element first, or use batch deletion'));
}
}
parent::del();
}
/**
* 重写select方法
* @throws Throwable
*/
public function select(): void
{
$data = $this->getMenus([['type', 'in', ['menu_dir', 'menu']], ['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
}
$this->success('', [
'options' => $data
]);
}
/**
* 获取菜单列表
* @throws Throwable
*/
protected function getMenus($where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $pk);
$ids = $this->auth->getRuleIds();
// 如果没有 * 则只获取用户拥有的规则
if (!in_array('*', $ids)) {
$where[] = ['id', 'in', $ids];
}
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
// 读取用户组所有权限规则
$rules = $this->model
->where($where)
->order($this->queryOrderBuilder())
->select()
->toArray();
// 如果要求树状,此处先组装好 children
return $this->assembleTree ? $this->tree->assembleChild($rules) : $rules;
}
/**
* 检查所有非超管的分组是否应该拥有某个权限
*/
private function autoAssignPermission(int $id, int $pid): void
{
$groups = AdminGroup::where('rules', '<>', '*')->select();
foreach ($groups as $group) {
$rules = explode(',', $group->rules);
if (in_array($pid, $rules) && !in_array($id, $rules)) {
$rules[] = $id;
$group->rules = implode(',', $rules);
$group->save();
}
}
}
}

View File

@@ -0,0 +1,1002 @@
<?php
namespace app\admin\controller\crud;
use Throwable;
use ba\Exception;
use ba\Filesystem;
use think\facade\Db;
use ba\TableManager;
use app\admin\model\CrudLog;
use app\common\library\Menu;
use app\admin\model\AdminLog;
use app\admin\model\AdminRule;
use app\common\controller\Backend;
use app\admin\library\crud\Helper;
class Crud extends Backend
{
/**
* 模型文件数据
* @var array
*/
protected array $modelData = [];
/**
* 控制器文件数据
* @var array
*/
protected array $controllerData = [];
/**
* index.vue文件数据
* @var array
*/
protected array $indexVueData = [];
/**
* form.vue文件数据
* @var array
*/
protected array $formVueData = [];
/**
* 语言翻译前缀
* @var string
*/
protected string $webTranslate = '';
/**
* 语言包数据
* @var array
*/
protected array $langTsData = [];
/**
* 当designType为以下值时:
* 1. 出入库字符串到数组转换
* 2. 默认值转数组
* @var array
*/
protected array $dtStringToArray = ['checkbox', 'selects', 'remoteSelects', 'city', 'images', 'files'];
protected array $noNeedPermission = ['logStart', 'getFileData', 'parseFieldData', 'generateCheck', 'uploadCompleted'];
public function initialize(): void
{
parent::initialize();
}
/**
* 开始生成
* @throws Throwable
*/
public function generate(): void
{
$type = $this->request->post('type', '');
$table = $this->request->post('table', []);
$fields = $this->request->post('fields', [], 'clean_xss,htmlspecialchars_decode_improve');
if (!$table || !$fields || !isset($table['name']) || !$table['name']) {
$this->error(__('Parameter error'));
}
try {
// 记录日志
$crudLogId = Helper::recordCrudStatus([
'table' => $table,
'fields' => $fields,
'status' => 'start',
]);
// 表名称
$tableName = TableManager::tableName($table['name'], false, $table['databaseConnection']);
if ($type == 'create' || $table['rebuild'] == 'Yes') {
// 数据表存在则删除
TableManager::phinxTable($tableName, [], true, $table['databaseConnection'])->drop()->save();
}
// 处理表设计
[$tablePk] = Helper::handleTableDesign($table, $fields);
// 表注释
$tableComment = mb_substr($table['comment'], -1) == '表' ? mb_substr($table['comment'], 0, -1) . '管理' : $table['comment'];
// 生成文件信息解析
$modelFile = Helper::parseNameData($table['isCommonModel'] ? 'common' : 'admin', $tableName, 'model', $table['modelFile']);
$validateFile = Helper::parseNameData($table['isCommonModel'] ? 'common' : 'admin', $tableName, 'validate', $table['validateFile']);
$controllerFile = Helper::parseNameData('admin', $tableName, 'controller', $table['controllerFile']);
$webViewsDir = Helper::parseWebDirNameData($tableName, 'views', $table['webViewsDir']);
$webLangDir = Helper::parseWebDirNameData($tableName, 'lang', $table['webViewsDir']);
// 语言翻译前缀
$this->webTranslate = implode('.', $webLangDir['lang']) . '.';
// 快速搜索字段
if (!in_array($tablePk, $table['quickSearchField'])) {
$table['quickSearchField'][] = $tablePk;
}
$quickSearchFieldZhCnTitle = [];
// 模型数据
$this->modelData['append'] = [];
$this->modelData['methods'] = [];
$this->modelData['fieldType'] = [];
$this->modelData['createTime'] = '';
$this->modelData['updateTime'] = '';
$this->modelData['beforeInsertMixins'] = [];
$this->modelData['beforeInsert'] = '';
$this->modelData['afterInsert'] = '';
$this->modelData['connection'] = $table['databaseConnection'];
$this->modelData['name'] = $tableName;
$this->modelData['className'] = $modelFile['lastName'];
$this->modelData['namespace'] = $modelFile['namespace'];
$this->modelData['relationMethodList'] = [];
// 控制器数据
$this->controllerData['use'] = [];
$this->controllerData['attr'] = [];
$this->controllerData['methods'] = [];
$this->controllerData['filterRule'] = '';
$this->controllerData['className'] = $controllerFile['lastName'];
$this->controllerData['namespace'] = $controllerFile['namespace'];
$this->controllerData['tableComment'] = $tableComment;
$this->controllerData['modelName'] = $modelFile['lastName'];
$this->controllerData['modelNamespace'] = $modelFile['namespace'];
// index.vue数据
$this->indexVueData['enableDragSort'] = false;
$this->indexVueData['defaultItems'] = [];
$this->indexVueData['tableColumn'] = [
[
'type' => 'selection',
'align' => 'center',
'operator' => 'false',
],
];
$this->indexVueData['dblClickNotEditColumn'] = ['undefined'];
$this->indexVueData['optButtons'] = ['edit', 'delete'];
$this->indexVueData['defaultOrder'] = '';
// form.vue数据
$this->formVueData['bigDialog'] = false;
$this->formVueData['formFields'] = [];
$this->formVueData['formValidatorRules'] = [];
$this->formVueData['imports'] = [];
// 语言包数据
$this->langTsData = [
'en' => [],
'zh-cn' => [],
];
// 简化的字段数据
$fieldsMap = [];
foreach ($fields as $key => $field) {
$fieldsMap[$field['name']] = $field['designType'];
// 分析字段
Helper::analyseField($field);
Helper::getDictData($this->langTsData['en'], $field, 'en');
Helper::getDictData($this->langTsData['zh-cn'], $field, 'zh-cn');
// 快速搜索字段
if (in_array($field['name'], $table['quickSearchField'])) {
$quickSearchFieldZhCnTitle[] = $this->langTsData['zh-cn'][$field['name']] ?? $field['name'];
}
// 不允许双击编辑的字段
if ($field['designType'] == 'switch') {
$this->indexVueData['dblClickNotEditColumn'][] = $field['name'];
}
// 列字典数据
$columnDict = $this->getColumnDict($field);
// 表单项
if (in_array($field['name'], $table['formFields'])) {
$this->formVueData['formFields'][] = $this->getFormField($field, $columnDict, $table['databaseConnection']);
}
// 表格列
if (in_array($field['name'], $table['columnFields'])) {
$this->indexVueData['tableColumn'][] = $this->getTableColumn($field, $columnDict);
}
// 关联表数据解析
if (in_array($field['designType'], ['remoteSelect', 'remoteSelects'])) {
$this->parseJoinData($field, $table);
}
// 模型方法
$this->parseModelMethods($field, $this->modelData);
// 控制器/模型等文件的一些杂项属性解析
$this->parseSundryData($field, $table);
if (!in_array($field['name'], $table['formFields'])) {
$this->controllerData['attr']['preExcludeFields'][] = $field['name'];
}
}
// 快速搜索提示
$this->langTsData['en']['quick Search Fields'] = implode(',', $table['quickSearchField']);
$this->langTsData['zh-cn']['quick Search Fields'] = implode('、', $quickSearchFieldZhCnTitle);
$this->controllerData['attr']['quickSearchField'] = $table['quickSearchField'];
// 开启字段排序
$weighKey = array_search('weigh', $fieldsMap);
if ($weighKey !== false) {
$this->indexVueData['enableDragSort'] = true;
$this->modelData['afterInsert'] = Helper::assembleStub('mixins/model/afterInsert', [
'field' => $weighKey
]);
}
// 表格的操作列
$this->indexVueData['tableColumn'][] = [
'label' => "t('Operate')",
'align' => 'center',
'width' => $this->indexVueData['enableDragSort'] ? 140 : 100,
'render' => 'buttons',
'buttons' => 'optButtons',
'operator' => 'false',
];
if ($this->indexVueData['enableDragSort']) {
array_unshift($this->indexVueData['optButtons'], 'weigh-sort');
}
// 写入语言包代码
Helper::writeWebLangFile($this->langTsData, $webLangDir);
// 写入模型代码
Helper::writeModelFile($tablePk, $fieldsMap, $this->modelData, $modelFile);
// 写入控制器代码
Helper::writeControllerFile($this->controllerData, $controllerFile);
// 写入验证器代码
$validateContent = Helper::assembleStub('mixins/validate/validate', [
'namespace' => $validateFile['namespace'],
'className' => $validateFile['lastName'],
]);
Helper::writeFile($validateFile['parseFile'], $validateContent);
// 写入index.vue代码
$this->indexVueData['tablePk'] = $tablePk;
$this->indexVueData['webTranslate'] = $this->webTranslate;
Helper::writeIndexFile($this->indexVueData, $webViewsDir, $controllerFile);
// 写入form.vue代码
Helper::writeFormFile($this->formVueData, $webViewsDir, $fields, $this->webTranslate);
// 生成菜单
Helper::createMenu($webViewsDir, $tableComment);
Helper::recordCrudStatus([
'id' => $crudLogId,
'status' => 'success',
]);
} catch (Exception $e) {
Helper::recordCrudStatus([
'id' => $crudLogId ?? 0,
'status' => 'error',
]);
$this->error($e->getMessage());
} catch (Throwable $e) {
Helper::recordCrudStatus([
'id' => $crudLogId ?? 0,
'status' => 'error',
]);
if (env('app_debug', false)) throw $e;
$this->error($e->getMessage());
}
$this->success('', [
'crudLog' => CrudLog::find($crudLogId),
]);
}
/**
* 从log开始
* @throws Throwable
*/
public function logStart(): void
{
$id = $this->request->post('id');
$type = $this->request->post('type', '');
if ($type == 'Cloud history') {
// 云端 历史记录
$client = get_ba_client();
$response = $client->request('GET', '/api/v6.Crud/info', [
'query' => [
'id' => $id,
'server' => 1,
'ba-user-token' => $this->request->post('token', ''),
]
]);
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
$this->error(__('Failed to load cloud data'));
}
$json = json_decode($content, true);
if (json_last_error() != JSON_ERROR_NONE) {
$this->error(__('Failed to load cloud data'));
}
if (is_array($json)) {
if ($json['code'] != 1) {
$this->error($json['msg']);
}
$info = $json['data']['info'];
}
} else {
// 本地记录
$info = CrudLog::find($id)->toArray();
}
if (!isset($info) || !$info) {
$this->error(__('Record not found'));
}
// 数据表是否有数据
$connection = TableManager::getConnection($info['table']['databaseConnection'] ?? '');
$tableName = TableManager::tableName($info['table']['name'], false, $connection);
$adapter = TableManager::phinxAdapter(true, $connection);
if ($adapter->hasTable($tableName)) {
$info['table']['empty'] = Db::connect($connection)
->name($tableName)
->limit(1)
->select()
->isEmpty();
} else {
$info['table']['empty'] = true;
}
AdminLog::instance()->setTitle(__('Log start'));
$this->success('', [
'table' => $info['table'],
'fields' => $info['fields'],
'sync' => $info['sync'],
]);
}
/**
* 删除CRUD记录和生成的文件
* @throws Throwable
*/
public function delete(): void
{
$id = $this->request->post('id');
$info = CrudLog::find($id)->toArray();
if (!$info) {
$this->error(__('Record not found'));
}
$webLangDir = Helper::parseWebDirNameData($info['table']['name'], 'lang', $info['table']['webViewsDir']);
$files = [
$webLangDir['en'] . '.ts',
$webLangDir['zh-cn'] . '.ts',
$info['table']['webViewsDir'] . '/' . 'index.vue',
$info['table']['webViewsDir'] . '/' . 'popupForm.vue',
$info['table']['controllerFile'],
$info['table']['modelFile'],
$info['table']['validateFile'],
];
try {
foreach ($files as &$file) {
$file = Filesystem::fsFit(root_path() . $file);
if (file_exists($file)) {
unlink($file);
}
Filesystem::delEmptyDir(dirname($file));
}
// 删除菜单
Menu::delete(Helper::getMenuName($webLangDir), true);
Helper::recordCrudStatus([
'id' => $id,
'status' => 'delete',
]);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$this->success(__('Deleted successfully'));
}
/**
* 获取文件路径数据
* @throws Throwable
*/
public function getFileData(): void
{
$table = $this->request->get('table');
$commonModel = $this->request->get('commonModel/b');
if (!$table) {
$this->error(__('Parameter error'));
}
try {
$modelFile = Helper::parseNameData($commonModel ? 'common' : 'admin', $table, 'model');
$validateFile = Helper::parseNameData($commonModel ? 'common' : 'admin', $table, 'validate');
$controllerFile = Helper::parseNameData('admin', $table, 'controller');
$webViewsDir = Helper::parseWebDirNameData($table, 'views');
} catch (Throwable $e) {
$this->error($e->getMessage());
}
// 模型和控制器文件和文件列表
$adminModelFiles = Filesystem::getDirFiles(root_path() . 'app' . DIRECTORY_SEPARATOR . 'admin' . DIRECTORY_SEPARATOR . 'model' . DIRECTORY_SEPARATOR);
$commonModelFiles = Filesystem::getDirFiles(root_path() . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'model' . DIRECTORY_SEPARATOR);
$adminControllerFiles = get_controller_list();
$modelFileList = [];
$controllerFiles = [];
foreach ($adminModelFiles as $item) {
$item = Filesystem::fsFit('app/admin/model/' . $item);
$modelFileList[$item] = $item;
}
foreach ($commonModelFiles as $item) {
$item = Filesystem::fsFit('app/common/model/' . $item);
$modelFileList[$item] = $item;
}
$outExcludeController = [
'Addon.php',
'Ajax.php',
'Dashboard.php',
'Index.php',
'Module.php',
'Terminal.php',
'routine/AdminInfo.php',
'routine/Config.php',
];
foreach ($adminControllerFiles as $item) {
if (in_array($item, $outExcludeController)) {
continue;
}
$item = Filesystem::fsFit('app/admin/controller/' . $item);
$controllerFiles[$item] = $item;
}
$this->success('', [
'modelFile' => $modelFile['rootFileName'],
'controllerFile' => $controllerFile['rootFileName'],
'validateFile' => $validateFile['rootFileName'],
'controllerFileList' => $controllerFiles,
'modelFileList' => $modelFileList,
'webViewsDir' => $webViewsDir['views'],
]);
}
/**
* 检查是否已有CRUD记录
* @throws Throwable
*/
public function checkCrudLog(): void
{
$table = $this->request->get('table');
$connection = $this->request->get('connection');
$connection = $connection ?: config('database.default');
$crudLog = Db::name('crud_log')
->where('table_name', $table)
->where('connection', $connection)
->order('create_time desc')
->find();
$this->success('', [
'id' => ($crudLog && $crudLog['status'] == 'success') ? $crudLog['id'] : 0,
]);
}
/**
* 解析字段数据
* @throws Throwable
*/
public function parseFieldData(): void
{
AdminLog::instance()->setTitle(__('Parse field data'));
$type = $this->request->post('type');
$table = $this->request->post('table');
$connection = $this->request->post('connection');
$connection = TableManager::getConnection($connection);
$table = TableManager::tableName($table, true, $connection);
$connectionConfig = TableManager::getConnectionConfig($connection);
if ($type == 'db') {
$sql = 'SELECT * FROM `information_schema`.`tables` '
. 'WHERE TABLE_SCHEMA = ? AND table_name = ?';
$tableInfo = Db::connect($connection)->query($sql, [$connectionConfig['database'], $table]);
if (!$tableInfo) {
$this->error(__('Record not found'));
}
// 数据表是否有数据
$adapter = TableManager::phinxAdapter(false, $connection);
if ($adapter->hasTable($table)) {
$empty = Db::connect($connection)
->table($table)
->limit(1)
->select()
->isEmpty();
} else {
$empty = true;
}
$this->success('', [
'columns' => Helper::parseTableColumns($table, false, $connection),
'comment' => $tableInfo[0]['TABLE_COMMENT'] ?? '',
'empty' => $empty,
]);
}
}
/**
* 生成前检查
* @throws Throwable
*/
public function generateCheck(): void
{
$table = $this->request->post('table');
$connection = $this->request->post('connection');
$webViewsDir = $this->request->post('webViewsDir', '');
$controllerFile = $this->request->post('controllerFile', '');
if (!$table) {
$this->error(__('Parameter error'));
}
AdminLog::instance()->setTitle(__('Generate check'));
try {
$webViewsDir = Helper::parseWebDirNameData($table, 'views', $webViewsDir);
$controllerFile = Helper::parseNameData('admin', $table, 'controller', $controllerFile)['rootFileName'];
} catch (Throwable $e) {
$this->error($e->getMessage());
}
// 数据表是否存在
$tableList = TableManager::getTableList($connection);
$tableExist = array_key_exists(TableManager::tableName($table, true, $connection), $tableList);
// 控制器是否存在
$controllerExist = file_exists(root_path() . $controllerFile);
// 菜单规则是否存在
$menuName = Helper::getMenuName($webViewsDir);
$menuExist = AdminRule::where('name', $menuName)->value('id');
if ($controllerExist || $tableExist || $menuExist) {
$this->error('', [
'menu' => $menuExist,
'table' => $tableExist,
'controller' => $controllerExist,
], -1);
}
$this->success();
}
/**
* CRUD 设计记录上传成功标记
* @throws Throwable
*/
public function uploadCompleted(): void
{
$syncIds = $this->request->post('syncIds/a', []);
$cancelSync = $this->request->post('cancelSync/b', false);
$crudLogModel = new CrudLog();
if ($cancelSync) {
$logData = $crudLogModel->where('id', 'in', array_keys($syncIds))->select();
foreach ($logData as $logDatum) {
if ($logDatum->sync == $syncIds[$logDatum->id]) {
$logDatum->sync = 0;
$logDatum->save();
}
}
$this->success();
}
$saveData = [];
foreach ($syncIds as $key => $syncId) {
$saveData[] = [
'id' => $key,
'sync' => $syncId,
];
}
$crudLogModel->saveAll($saveData);
$this->success();
}
/**
* 关联表数据解析
* @param $field
* @param $table
* @throws Throwable
*/
private function parseJoinData($field, $table): void
{
$dictEn = [];
$dictZhCn = [];
if ($field['form']['relation-fields'] && $field['form']['remote-table']) {
$columns = Helper::parseTableColumns($field['form']['remote-table'], true, $table['databaseConnection']);
$relationFields = explode(',', $field['form']['relation-fields']);
$tableName = TableManager::tableName($field['form']['remote-table'], false, $table['databaseConnection']);
$rnPattern = '/(.*)(_ids|_id)$/';
if (preg_match($rnPattern, $field['name'])) {
$relationName = parse_name(preg_replace($rnPattern, '$1', $field['name']), 1, false);
} else {
$relationName = parse_name($field['name'] . '_table', 1, false);
}
// 建立关联模型代码文件
if (!$field['form']['remote-model'] || !file_exists(root_path() . $field['form']['remote-model'])) {
$joinModelFile = Helper::parseNameData('admin', $tableName, 'model', $field['form']['remote-model']);
if (!file_exists(root_path() . $joinModelFile['rootFileName'])) {
$joinModelData['append'] = [];
$joinModelData['methods'] = [];
$joinModelData['fieldType'] = [];
$joinModelData['createTime'] = '';
$joinModelData['updateTime'] = '';
$joinModelData['beforeInsertMixins'] = [];
$joinModelData['beforeInsert'] = '';
$joinModelData['afterInsert'] = '';
$joinModelData['connection'] = $table['databaseConnection'];
$joinModelData['name'] = $tableName;
$joinModelData['className'] = $joinModelFile['lastName'];
$joinModelData['namespace'] = $joinModelFile['namespace'];
$joinTablePk = 'id';
$joinFieldsMap = [];
foreach ($columns as $column) {
$joinFieldsMap[$column['name']] = $column['designType'];
$this->parseModelMethods($column, $joinModelData);
if ($column['primaryKey']) $joinTablePk = $column['name'];
}
$weighKey = array_search('weigh', $joinFieldsMap);
if ($weighKey !== false) {
$joinModelData['afterInsert'] = Helper::assembleStub('mixins/model/afterInsert', [
'field' => $joinFieldsMap[$weighKey]
]);
}
Helper::writeModelFile($joinTablePk, $joinFieldsMap, $joinModelData, $joinModelFile);
}
$field['form']['remote-model'] = $joinModelFile['rootFileName'];
}
if ($field['designType'] == 'remoteSelect') {
// 关联预载入方法
$this->controllerData['attr']['withJoinTable'][$relationName] = $relationName;
// 模型方法代码
$relationData = [
'relationMethod' => $relationName,
'relationMode' => 'belongsTo',
'relationPrimaryKey' => $field['form']['remote-pk'] ?? 'id',
'relationForeignKey' => $field['name'],
'relationClassName' => str_replace(['.php', '/'], ['', '\\'], '\\' . $field['form']['remote-model']) . "::class",
];
$this->modelData['relationMethodList'][$relationName] = Helper::assembleStub('mixins/model/belongsTo', $relationData);
// 查询时显示的字段
if ($relationFields) {
$this->controllerData['relationVisibleFieldList'][$relationData['relationMethod']] = $relationFields;
}
} elseif ($field['designType'] == 'remoteSelects') {
$this->modelData['append'][] = $relationName;
$this->modelData['methods'][] = Helper::assembleStub('mixins/model/getters/remoteSelectLabels', [
'field' => parse_name($relationName, 1),
'className' => str_replace(['.php', '/'], ['', '\\'], '\\' . $field['form']['remote-model']),
'primaryKey' => $field['form']['remote-pk'] ?? 'id',
'foreignKey' => $field['name'],
'labelFieldName' => $field['form']['remote-field'] ?? 'name',
]);
}
foreach ($relationFields as $relationField) {
if (!array_key_exists($relationField, $columns)) continue;
$relationFieldPrefix = $relationName . '.';
$relationFieldLangPrefix = strtolower($relationName) . '__';
Helper::getDictData($dictEn, $columns[$relationField], 'en', $relationFieldLangPrefix);
Helper::getDictData($dictZhCn, $columns[$relationField], 'zh-cn', $relationFieldLangPrefix);
// 不允许双击编辑的字段
if ($columns[$relationField]['designType'] == 'switch') {
$this->indexVueData['dblClickNotEditColumn'][] = $field['name'];
}
// 列字典数据
$columnDict = $this->getColumnDict($columns[$relationField], $relationFieldLangPrefix);
// 表格列
$columns[$relationField]['designType'] = $field['designType'];
$columns[$relationField]['form'] = $field['form'] + $columns[$relationField]['form'];
$columns[$relationField]['table'] = $field['table'] + $columns[$relationField]['table'];
// 公共搜索渲染为远程下拉时,远程下拉组件的必填属性
$remoteAttr = [
'pk' => $this->getRemoteSelectPk($field),
'field' => $field['form']['remote-field'] ?? 'name',
'remoteUrl' => $this->getRemoteSelectUrl($field),
];
if ($columns[$relationField]['table']['comSearchRender'] == 'remoteSelect') {
// 生成为已关闭公共搜索的表格列
$renderColumn = $columns[$relationField];
$renderColumn['table']['operator'] = 'false';
unset($renderColumn['table']['comSearchRender']);
$this->indexVueData['tableColumn'][] = $this->getTableColumn($renderColumn, $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
// 额外生成一个公共搜索渲染为远程下拉的列,关闭表格列表显示
$columns[$relationField]['table']['show'] = 'false';
$columns[$relationField]['table']['label'] = "t('" . $this->webTranslate . $relationFieldLangPrefix . $columns[$relationField]['name'] . "')";
$columns[$relationField]['name'] = $field['name'];
// 标记多选
if ($field['designType'] == 'remoteSelects') {
$remoteAttr['multiple'] = 'true';
}
$columnData = $this->getTableColumn($columns[$relationField], $columnDict, '', $relationFieldLangPrefix);
$columnData['comSearchInputAttr'] = array_merge($remoteAttr, $columnData['comSearchInputAttr'] ?? []);
} else {
$columnData = $this->getTableColumn($columns[$relationField], $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
}
$this->indexVueData['tableColumn'][] = $columnData;
}
}
$this->langTsData['en'] = array_merge($this->langTsData['en'], $dictEn);
$this->langTsData['zh-cn'] = array_merge($this->langTsData['zh-cn'], $dictZhCn);
}
/**
* 解析模型方法(设置器、获取器等)
*/
private function parseModelMethods($field, &$modelData): void
{
// fieldType
if ($field['designType'] == 'array') {
$modelData['fieldType'][$field['name']] = 'json';
} elseif (!in_array($field['name'], ['create_time', 'update_time', 'updatetime', 'createtime']) && $field['designType'] == 'datetime' && (in_array($field['type'], ['int', 'bigint']))) {
$modelData['fieldType'][$field['name']] = 'timestamp:Y-m-d H:i:s';
}
// beforeInsertMixins
if ($field['designType'] == 'spk') {
$modelData['beforeInsertMixins']['snowflake'] = Helper::assembleStub('mixins/model/mixins/beforeInsertWithSnowflake', []);
}
// methods
$fieldName = parse_name($field['name'], 1);
if (in_array($field['designType'], $this->dtStringToArray)) {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/stringToArray', [
'field' => $fieldName
]);
$modelData['methods'][] = Helper::assembleStub('mixins/model/setters/arrayToString', [
'field' => $fieldName
]);
} elseif ($field['designType'] == 'array') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/jsonDecode', [
'field' => $fieldName
]);
} elseif ($field['designType'] == 'time') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/setters/time', [
'field' => $fieldName
]);
} elseif ($field['designType'] == 'editor') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/htmlDecode', [
'field' => $fieldName
]);
} elseif ($field['designType'] == 'spk') {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/string', [
'field' => $fieldName
]);
} elseif (in_array($field['type'], ['float', 'decimal', 'double'])) {
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/float', [
'field' => $fieldName
]);
}
if ($field['designType'] == 'city') {
$modelData['append'][] = $field['name'] . '_text';
$modelData['methods'][] = Helper::assembleStub('mixins/model/getters/cityNames', [
'field' => $fieldName . 'Text',
'originalFieldName' => $field['name'],
]);
}
}
/**
* 控制器/模型等文件的一些杂项属性解析
*/
private function parseSundryData($field, $table): void
{
if ($field['designType'] == 'editor') {
$this->formVueData['bigDialog'] = true; // 加宽 dialog
$this->controllerData['filterRule'] = "\n" . Helper::tab(2) . '$this->request->filter(\'clean_xss\');'; // 修改变量过滤规则
}
// 默认排序字段
if ($table['defaultSortField'] && $table['defaultSortType']) {
$defaultSortField = "{$table['defaultSortField']},{$table['defaultSortType']}";
if ($defaultSortField == 'id,desc') {
$this->controllerData['attr']['defaultSortField'] = '';
} else {
$this->controllerData['attr']['defaultSortField'] = $defaultSortField;
$this->indexVueData['defaultOrder'] = Helper::buildDefaultOrder($table['defaultSortField'], $table['defaultSortType']);
}
}
// 自定义了权重字段名称
if ($field['originalDesignType'] == 'weigh' && $field['name'] != 'weigh') {
$this->controllerData['attr']['weighField'] = $field['name'];
}
}
/**
* 组装前台表单的数据
* @throws Throwable
*/
private function getFormField($field, $columnDict, ?string $dbConnection = null): array
{
// 表单项属性
$formField = [
':label' => 't(\'' . $this->webTranslate . $field['name'] . '\')',
'type' => $field['designType'],
'v-model' => 'baTable.form.items!.' . $field['name'],
'prop' => $field['name'],
];
// 不同输入框的属性处理
if ($columnDict || in_array($field['designType'], ['radio', 'checkbox', 'select', 'selects'])) {
$formField[':input-attr']['content'] = $columnDict;
} elseif ($field['designType'] == 'textarea') {
$formField[':input-attr']['rows'] = (int)($field['form']['rows'] ?? 3);
$formField['@keyup.enter.stop'] = '';
$formField['@keyup.ctrl.enter'] = 'baTable.onSubmit(formRef)';
} elseif ($field['designType'] == 'remoteSelect' || $field['designType'] == 'remoteSelects') {
$formField[':input-attr']['pk'] = $this->getRemoteSelectPk($field);
$formField[':input-attr']['field'] = $field['form']['remote-field'] ?? 'name';
$formField[':input-attr']['remoteUrl'] = $this->getRemoteSelectUrl($field);
} elseif ($field['designType'] == 'number') {
$formField[':input-attr']['step'] = (int)($field['form']['step'] ?? 1);
} elseif ($field['designType'] == 'icon') {
$formField[':input-attr']['placement'] = 'top';
} elseif ($field['designType'] == 'editor') {
$formField['@keyup.enter.stop'] = '';
$formField['@keyup.ctrl.enter'] = 'baTable.onSubmit(formRef)';
}
// placeholder
if (!in_array($field['designType'], ['image', 'images', 'file', 'files', 'switch'])) {
if (in_array($field['designType'], ['radio', 'checkbox', 'datetime', 'year', 'date', 'time', 'select', 'selects', 'remoteSelect', 'remoteSelects', 'city', 'icon'])) {
$formField[':placeholder'] = "t('Please select field', { field: t('" . $this->webTranslate . $field['name'] . "') })";
} else {
$formField[':placeholder'] = "t('Please input field', { field: t('" . $this->webTranslate . $field['name'] . "') })";
}
}
// 默认值
if ($field['defaultType'] == 'INPUT') {
$this->indexVueData['defaultItems'][$field['name']] = $field['default'];
}
// 部分生成类型的默认值需要额外处理
if ($field['designType'] == 'editor') {
$this->indexVueData['defaultItems'][$field['name']] = ($field['defaultType'] == 'INPUT' && $field['default']) ? $field['default'] : '';
} elseif ($field['designType'] == 'array') {
$this->indexVueData['defaultItems'][$field['name']] = "[]";
} elseif ($field['defaultType'] == 'INPUT' && in_array($field['designType'], $this->dtStringToArray) && str_contains($field['default'], ',')) {
$this->indexVueData['defaultItems'][$field['name']] = Helper::buildSimpleArray(explode(',', $field['default']));
} elseif ($field['defaultType'] == 'INPUT' && in_array($field['designType'], ['number', 'float'])) {
$this->indexVueData['defaultItems'][$field['name']] = (float)$field['default'];
}
// 无意义的默认值
if (isset($field['default']) && in_array($field['designType'], ['switch', 'number', 'float', 'remoteSelect']) && $field['default'] == 0) {
unset($this->indexVueData['defaultItems'][$field['name']]);
}
return $formField;
}
private function getRemoteSelectPk($field): string
{
$pk = $field['form']['remote-pk'] ?? 'id';
if (!str_contains($pk, '.')) {
if ($field['form']['remote-source-config-type'] == 'crud' && $field['form']['remote-model']) {
$alias = parse_name(basename(str_replace('\\', '/', $field['form']['remote-model']), '.php'));
} else {
$alias = $field['form']['remote-primary-table-alias'] ?? '';
}
}
return !empty($alias) ? "$alias.$pk" : $pk;
}
private function getRemoteSelectUrl($field): string
{
if ($field['form']['remote-source-config-type'] == 'crud' && $field['form']['remote-controller']) {
$pathArr = [];
$controller = explode(DIRECTORY_SEPARATOR, $field['form']['remote-controller']);
$controller = str_replace('.php', '', $controller);
$redundantDir = [
'app' => 0,
'admin' => 1,
'controller' => 2,
];
foreach ($controller as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
$url = count($pathArr) > 1 ? implode('.', $pathArr) : $pathArr[0];
return '/admin/' . $url . '/index';
}
return $field['form']['remote-url'];
}
private function getTableColumn($field, $columnDict, $fieldNamePrefix = '', $translationPrefix = ''): array
{
$column = [
'label' => "t('" . $this->webTranslate . $translationPrefix . $field['name'] . "')",
'prop' => $fieldNamePrefix . $field['name'] . ($field['designType'] == 'city' ? '_text' : ''),
'align' => 'center',
];
// 模糊搜索增加一个placeholder
if (isset($field['table']['operator']) && $field['table']['operator'] == 'LIKE') {
$column['operatorPlaceholder'] = "t('Fuzzy query')";
}
// 合并前端预设的字段表格属性
if (!empty($field['table'])) {
$column = array_merge($column, $field['table']);
$column['comSearchInputAttr'] = str_attr_to_array($column['comSearchInputAttr'] ?? '');
}
// 需要值替换的渲染类型
$columnReplaceValue = ['tag', 'tags', 'switch'];
if (!in_array($field['designType'], ['remoteSelect', 'remoteSelects']) && ($columnDict || (isset($field['table']['render']) && in_array($field['table']['render'], $columnReplaceValue)))) {
$column['replaceValue'] = $columnDict;
}
if (isset($column['render']) && $column['render'] == 'none') {
unset($column['render']);
}
return $column;
}
private function getColumnDict($column, $translationPrefix = ''): array
{
$dict = [];
// 确保字典中无翻译也可以识别到该值
if (in_array($column['type'], ['enum', 'set'])) {
$dataType = str_replace(' ', '', $column['dataType']);
$columnData = substr($dataType, stripos($dataType, '(') + 1, -1);
$columnData = explode(',', str_replace(["'", '"'], '', $columnData));
foreach ($columnData as $columnDatum) {
$dict[$columnDatum] = $column['name'] . ' ' . $columnDatum;
}
}
$dictData = [];
Helper::getDictData($dictData, $column, 'zh-cn', $translationPrefix);
if ($dictData) {
unset($dictData[$translationPrefix . $column['name']]);
foreach ($dictData as $key => $item) {
$keyName = str_replace($translationPrefix . $column['name'] . ' ', '', $key);
$dict[$keyName] = "t('" . $this->webTranslate . $key . "')";
}
}
return $dict;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace app\admin\controller\crud;
use app\admin\model\CrudLog;
use app\common\controller\Backend;
/**
* crud记录
*
*/
class Log extends Backend
{
/**
* Log模型对象
* @var object
* @phpstan-var CrudLog
*/
protected object $model;
protected string|array $preExcludeFields = ['id', 'create_time'];
protected string|array $quickSearchField = ['id', 'table_name', 'comment'];
protected array $noNeedPermission = ['index'];
public function initialize(): void
{
parent::initialize();
$this->model = new CrudLog();
if (!$this->auth->check('crud/crud/index')) {
$this->error(__('You have no permission'), [], 401);
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
use app\admin\model\Admin;
use app\common\controller\Backend;
class AdminInfo extends Backend
{
/**
* @var object
* @phpstan-var Admin
*/
protected object $model;
protected string|array $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status'];
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
public function initialize(): void
{
parent::initialize();
$this->auth->setAllowFields($this->authAllowFields);
$this->model = $this->auth->getAdmin();
}
public function index(): void
{
$info = $this->auth->getInfo();
$this->success('', [
'info' => $info
]);
}
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['avatar'])) {
$row->avatar = $data['avatar'];
if ($row->save()) {
$this->success(__('Avatar modified successfully!'));
}
}
// 数据验证
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('info')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
}
if (!empty($data['password'])) {
$this->model->resetPassword($this->auth->id, $data['password']);
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
use app\common\controller\Backend;
use app\common\model\Attachment as AttachmentModel;
class Attachment extends Backend
{
/**
* @var object
* @phpstan-var AttachmentModel
*/
protected object $model;
protected string|array $quickSearchField = 'name';
protected array $withJoinTable = ['admin', 'user'];
protected string|array $defaultSortField = 'last_upload_time,desc';
public function initialize(): void
{
parent::initialize();
$this->model = new AttachmentModel();
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
try {
foreach ($data as $v) {
$count += $v->delete();
}
} catch (Throwable $e) {
$this->error(__('%d records and files have been deleted', [$count]) . $e->getMessage());
}
if ($count) {
$this->success(__('%d records and files have been deleted', [$count]));
} else {
$this->error(__('No rows were deleted'));
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
use ba\Filesystem;
use app\common\library\Email;
use PHPMailer\PHPMailer\PHPMailer;
use app\common\controller\Backend;
use app\admin\model\Config as ConfigModel;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
class Config extends Backend
{
/**
* @var object
* @phpstan-var ConfigModel
*/
protected object $model;
protected array $filePath = [
'appConfig' => 'config/app.php',
'webAdminBase' => 'web/src/router/static/adminBase.ts',
'backendEntranceStub' => 'app/admin/library/stubs/backendEntrance.stub',
];
public function initialize(): void
{
parent::initialize();
$this->model = new ConfigModel();
}
public function index(): void
{
$configGroup = get_sys_config('config_group');
$config = $this->model->order('weigh desc')->select()->toArray();
$list = [];
$newConfigGroup = [];
foreach ($configGroup as $item) {
$list[$item['key']]['name'] = $item['key'];
$list[$item['key']]['title'] = __($item['value']);
$newConfigGroup[$item['key']] = $list[$item['key']]['title'];
}
foreach ($config as $item) {
if (array_key_exists($item['group'], $newConfigGroup)) {
$item['title'] = __($item['title']);
$list[$item['group']]['list'][] = $item;
}
}
$this->success('', [
'list' => $list,
'remark' => get_route_remark(),
'configGroup' => $newConfigGroup ?? [],
'quickEntrance' => get_sys_config('config_quick_entrance'),
]);
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$all = $this->model->select();
foreach ($all as $item) {
if ($item['type'] == 'editor') {
$this->request->filter('clean_xss');
break;
}
}
if ($this->request->isPost()) {
$this->modelValidate = false;
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$configValue = [];
foreach ($all as $item) {
if (array_key_exists($item->name, $data)) {
$configValue[] = [
'id' => $item->id,
'type' => $item->getData('type'),
'value' => $data[$item->name]
];
// 自定义后台入口
if ($item->name == 'backend_entrance') {
$backendEntrance = get_sys_config('backend_entrance');
if ($backendEntrance == $data[$item->name]) continue;
if (!preg_match("/^\/[a-zA-Z0-9]+$/", $data[$item->name])) {
$this->error(__('Backend entrance rule'));
}
// 修改 adminBaseRoutePath
$adminBaseFilePath = Filesystem::fsFit(root_path() . $this->filePath['webAdminBase']);
$adminBaseContent = @file_get_contents($adminBaseFilePath);
if (!$adminBaseContent) $this->error(__('Configuration write failed: %s', [$this->filePath['webAdminBase']]));
$adminBaseContent = str_replace("export const adminBaseRoutePath = '$backendEntrance'", "export const adminBaseRoutePath = '{$data[$item->name]}'", $adminBaseContent);
$result = @file_put_contents($adminBaseFilePath, $adminBaseContent);
if (!$result) $this->error(__('Configuration write failed: %s', [$this->filePath['webAdminBase']]));
// 去除后台入口开头的斜杠
$oldBackendEntrance = ltrim($backendEntrance, '/');
$newBackendEntrance = ltrim($data[$item->name], '/');
// 设置应用别名映射
$appMap = config('app.app_map');
$adminMapKey = array_search('admin', $appMap);
if ($adminMapKey !== false) {
unset($appMap[$adminMapKey]);
}
if ($newBackendEntrance != 'admin') {
$appMap[$newBackendEntrance] = 'admin';
}
$appConfigFilePath = Filesystem::fsFit(root_path() . $this->filePath['appConfig']);
$appConfigContent = @file_get_contents($appConfigFilePath);
if (!$appConfigContent) $this->error(__('Configuration write failed: %s', [$this->filePath['appConfig']]));
$appMapStr = '';
foreach ($appMap as $newAppName => $oldAppName) {
$appMapStr .= "'$newAppName' => '$oldAppName', ";
}
$appMapStr = rtrim($appMapStr, ', ');
$appMapStr = "[$appMapStr]";
$appConfigContent = preg_replace("/'app_map'(\s+)=>(\s+)(.*)\/\/ 域名/s", "'app_map'\$1=>\$2$appMapStr,\n // 域名", $appConfigContent);
$result = @file_put_contents($appConfigFilePath, $appConfigContent);
if (!$result) $this->error(__('Configuration write failed: %s', [$this->filePath['appConfig']]));
// 建立API入口文件
$oldBackendEntranceFile = Filesystem::fsFit(public_path() . $oldBackendEntrance . '.php');
$newBackendEntranceFile = Filesystem::fsFit(public_path() . $newBackendEntrance . '.php');
if (file_exists($oldBackendEntranceFile)) @unlink($oldBackendEntranceFile);
if ($newBackendEntrance != 'admin') {
$backendEntranceStub = @file_get_contents(Filesystem::fsFit(root_path() . $this->filePath['backendEntranceStub']));
if (!$backendEntranceStub) $this->error(__('Configuration write failed: %s', [$this->filePath['backendEntranceStub']]));
$result = @file_put_contents($newBackendEntranceFile, $backendEntranceStub);
if (!$result) $this->error(__('Configuration write failed: %s', [$newBackendEntranceFile]));
}
}
}
}
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$validate->check($data);
}
}
$result = $this->model->saveAll($configValue);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('The current page configuration item was updated successfully'));
} else {
$this->error(__('No rows updated'));
}
}
}
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 发送邮件测试
* @throws Throwable
*/
public function sendTestMail(): void
{
$data = $this->request->post();
$mail = new Email();
try {
$mail->Host = $data['smtp_server'];
$mail->SMTPAuth = true;
$mail->Username = $data['smtp_user'];
$mail->Password = $data['smtp_pass'];
$mail->SMTPSecure = $data['smtp_verification'] == 'SSL' ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $data['smtp_port'];
$mail->setFrom($data['smtp_sender_mail'], $data['smtp_user']);
$mail->isSMTP();
$mail->addAddress($data['testMail']);
$mail->isHTML();
$mail->setSubject(__('This is a test email') . '-' . get_sys_config('site_name'));
$mail->Body = __('Congratulations, receiving this email means that your email service has been configured correctly');
$mail->send();
} catch (PHPMailerException) {
$this->error($mail->ErrorInfo);
}
$this->success(__('Test mail sent successfully~'));
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\DataRecycle as DataRecycleModel;
class DataRecycle extends Backend
{
/**
* @var object
* @phpstan-var DataRecycleModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'name';
public function initialize(): void
{
parent::initialize();
$this->model = new DataRecycleModel();
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
// 放在add方法内就不需要额外添加权限节点了
$this->success('', [
'controllers' => $this->getControllerList(),
]);
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
protected function getControllerList(): array
{
$outExcludeController = [
'Addon.php',
'Ajax.php',
'Module.php',
'Terminal.php',
'Dashboard.php',
'Index.php',
'routine/AdminInfo.php',
'user/MoneyLog.php',
'user/ScoreLog.php',
];
$outControllers = [];
$controllers = get_controller_list();
foreach ($controllers as $key => $controller) {
if (!in_array($controller, $outExcludeController)) {
$outControllers[$key] = $controller;
}
}
return $outControllers;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use ba\TableManager;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\DataRecycleLog as DataRecycleLogModel;
class DataRecycleLog extends Backend
{
/**
* @var object
* @phpstan-var DataRecycleLogModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = [];
protected string|array $quickSearchField = 'recycle.name';
protected array $withJoinTable = ['recycle', 'admin'];
public function initialize(): void
{
parent::initialize();
$this->model = new DataRecycleLogModel();
}
/**
* 还原
* @throws Throwable
*/
public function restore(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
$this->error(__('Record not found'));
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $row) {
$recycleData = json_decode($row['data'], true);
if (is_array($recycleData) && Db::connect(TableManager::getConnection($row->connection))->name($row->data_table)->insert($recycleData)) {
$row->delete();
$count++;
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success();
} else {
$this->error(__('No rows were restore'));
}
}
/**
* 详情
* @throws Throwable
*/
public function info(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('data_recycle_log.id', $id)
->find();
if (!$row) {
$this->error(__('Record not found'));
}
$data = $this->jsonToArray($row['data']);
if (is_array($data)) {
foreach ($data as $key => $item) {
$data[$key] = $this->jsonToArray($item);
}
}
$row['data'] = $data;
$this->success('', [
'row' => $row
]);
}
protected function jsonToArray($value = '')
{
if (!is_string($value)) {
return $value;
}
$data = json_decode($value, true);
if (($data && is_object($data)) || (is_array($data) && !empty($data))) {
return $data;
}
return $value;
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\SensitiveData as SensitiveDataModel;
class SensitiveData extends Backend
{
/**
* @var object
* @phpstan-var SensitiveDataModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'controller';
public function initialize(): void
{
parent::initialize();
$this->model = new SensitiveDataModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
foreach ($res->items() as $item) {
if ($item->data_fields) {
$fields = [];
foreach ($item->data_fields as $key => $field) {
$fields[] = $field ?: $key;
}
$item->data_fields = $fields;
}
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加重写
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
if (is_array($data['fields'])) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
// 放在add方法内就不需要额外添加权限节点了
$this->success('', [
'controllers' => $this->getControllerList(),
]);
}
/**
* 编辑重写
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data['controller_as'] = str_ireplace('.php', '', $data['controller'] ?? '');
$data['controller_as'] = strtolower(str_ireplace(['\\', '.'], '/', $data['controller_as']));
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$validate->check($data);
}
}
if (is_array($data['fields'])) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row,
'controllers' => $this->getControllerList(),
]);
}
protected function getControllerList(): array
{
$outExcludeController = [
'Addon.php',
'Ajax.php',
'Dashboard.php',
'Index.php',
'Module.php',
'Terminal.php',
'auth/AdminLog.php',
'routine/AdminInfo.php',
'routine/Config.php',
'user/MoneyLog.php',
'user/ScoreLog.php',
];
$outControllers = [];
$controllers = get_controller_list();
foreach ($controllers as $key => $controller) {
if (!in_array($controller, $outExcludeController)) {
$outControllers[$key] = $controller;
}
}
return $outControllers;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use ba\TableManager;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\SensitiveDataLog as SensitiveDataLogModel;
class SensitiveDataLog extends Backend
{
/**
* @var object
* @phpstan-var SensitiveDataLogModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = [];
protected string|array $quickSearchField = 'sensitive.name';
protected array $withJoinTable = ['sensitive', 'admin'];
public function initialize(): void
{
parent::initialize();
$this->model = new SensitiveDataLogModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
foreach ($res->items() as $item) {
$item->id_value = $item['primary_key'] . '=' . $item->id_value;
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 详情
* @throws Throwable
*/
public function info(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('sensitive_data_log.id', $id)
->find();
if (!$row) {
$this->error(__('Record not found'));
}
$this->success('', [
'row' => $row
]);
}
/**
* 回滚
* @throws Throwable
*/
public function rollback(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
$this->error(__('Record not found'));
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $row) {
if (Db::connect(TableManager::getConnection($row->connection))->name($row->data_table)->where($row->primary_key, $row->id_value)->update([
$row->data_field => $row->before
])) {
$row->delete();
$count++;
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success();
} else {
$this->error(__('No rows were rollback'));
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
class Group extends Backend
{
/**
* @var object
* @phpstan-var UserGroup
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'name';
public function initialize(): void
{
parent::initialize();
$this->model = new UserGroup();
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('edit')->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
// 读取所有pid全部从节点数组移除父级选择状态由子级决定
$pidArr = UserRule::field('pid')
->distinct(true)
->where('id', 'in', $row->rules)
->select()
->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$row->rules = array_values($rules);
$this->success('', [
'row' => $row
]);
}
/**
* 权限规则入库前处理
* @param array $data 接受到的数据
* @return array
* @throws Throwable
*/
private function handleRules(array &$data): array
{
if (is_array($data['rules']) && $data['rules']) {
$rules = UserRule::select();
$super = true;
foreach ($rules as $rule) {
if (!in_array($rule['id'], $data['rules'])) {
$super = false;
}
}
if ($super) {
$data['rules'] = '*';
} else {
$data['rules'] = implode(',', $data['rules']);
}
} else {
unset($data['rules']);
}
return $data;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\User;
use app\admin\model\UserMoneyLog;
use app\common\controller\Backend;
class MoneyLog extends Backend
{
/**
* @var object
* @phpstan-var UserMoneyLog
*/
protected object $model;
protected array $withJoinTable = ['user'];
// 排除字段
protected string|array $preExcludeFields = ['create_time'];
protected string|array $quickSearchField = ['user.username', 'user.nickname'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserMoneyLog();
}
/**
* 添加
* @param int $userId
* @throws Throwable
*/
public function add(int $userId = 0): void
{
if ($this->request->isPost()) {
parent::add();
}
$user = User::where('id', $userId)->find();
if (!$user) {
$this->error(__("The user can't find it"));
}
$this->success('', [
'user' => $user
]);
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace app\admin\controller\user;
use ba\Tree;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
class Rule extends Backend
{
/**
* @var object
* @phpstan-var UserRule
*/
protected object $model;
/**
* @var Tree
*/
protected Tree $tree;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $defaultSortField = ['weigh' => 'desc'];
protected string|array $quickSearchField = 'title';
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
public function initialize(): void
{
parent::initialize();
// 防止 URL 中的特殊符号被默认的 filter 函数转义
$this->request->filter('clean_xss');
$this->model = new UserRule();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get("initValue/a", []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request('quickSearch', '');
$this->assembleTree = $isTree && !$this->initValue; // 有初始化值时不组装树状(初始化出来的值更好看)
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getRules(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
if (!empty($data['pid'])) {
$groups = UserGroup::where('rules', '<>', '*')->select();
foreach ($groups as $group) {
$rules = explode(',', $group->rules);
if (in_array($data['pid'], $rules) && !in_array($this->model->id, $rules)) {
$rules[] = $this->model->id;
$group->rules = implode(',', $rules);
$group->save();
}
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$id = $this->request->param($this->model->getPk());
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$validate->check($data);
}
}
if (isset($data['pid']) && $data['pid'] > 0) {
// 满足意图并消除副作用
$parent = $this->model->where('id', $data['pid'])->find();
if ($parent['pid'] == $row['id']) {
$parent->pid = 0;
$parent->save();
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
// 子级元素检查
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$this->error(__('Please delete the child element first, or use batch deletion'));
}
}
parent::del();
}
/**
* 远程下拉
* @throws Throwable
*/
public function select(): void
{
$data = $this->getRules([['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
}
$this->success('', [
'options' => $data
]);
}
/**
* 获取菜单规则
* @throws Throwable
*/
private function getRules(array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $pk);
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
$data = $this->model
->where($where)
->order($this->queryOrderBuilder())
->select()
->toArray();
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\User;
use app\admin\model\UserScoreLog;
use app\common\controller\Backend;
class ScoreLog extends Backend
{
/**
* @var object
* @phpstan-var UserScoreLog
*/
protected object $model;
protected array $withJoinTable = ['user'];
// 排除字段
protected string|array $preExcludeFields = ['create_time'];
protected string|array $quickSearchField = ['user.username', 'user.nickname'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserScoreLog();
}
/**
* 添加
* @param int $userId
* @throws Throwable
*/
public function add(int $userId = 0): void
{
if ($this->request->isPost()) {
parent::add();
}
$user = User::where('id', $userId)->find();
if (!$user) {
$this->error(__("The user can't find it"));
}
$this->success('', [
'user' => $user
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\User as UserModel;
class User extends Backend
{
/**
* @var object
* @phpstan-var UserModel
*/
protected object $model;
protected array $withJoinTable = ['userGroup'];
// 排除字段
protected string|array $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected string|array $quickSearchField = ['username', 'nickname', 'id'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$result = false;
$passwd = $data['password']; // 密码将被排除不直接入库
$data = $this->excludeFields($data);
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
if (!empty($passwd)) {
$this->model->resetPassword($this->model->id, $passwd);
}
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$password = $this->request->post('password', '');
if ($password) {
$this->model->resetPassword($id, $password);
}
parent::edit();
}
unset($row->salt);
$row->password = '';
$this->success('', [
'row' => $row
]);
}
/**
* 重写select
* @throws Throwable
*/
public function select(): void
{
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
foreach ($res as $re) {
$re->nickname_text = $re->username . '(ID:' . $re->id . ')';
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

16
app/admin/event.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
'backendInit' => [app\common\event\Security::class],
],
'subscribe' => [
],
];

38
app/admin/lang/en.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
'Please login first' => 'Please login first',
'You have no permission' => 'You have no permission to operate',
'Username' => 'Username',
'Password' => 'Password',
'Nickname' => 'Nickname',
'Email' => 'Email',
'Mobile' => 'Mobile Number',
'Captcha' => 'Captcha',
'CaptchaId' => 'Captcha Id',
'Please enter the correct verification code' => 'Please enter the correct Captcha!',
'Captcha error' => 'Captcha error!',
'Parameter %s can not be empty' => 'Parameter %s can not be empty',
'Record not found' => 'Record not found',
'No rows were added' => 'No rows were added',
'No rows were deleted' => 'No rows were deleted',
'No rows updated' => 'No rows updated',
'Update successful' => 'Update successful!',
'Added successfully' => 'Added successfully!',
'Deleted successfully' => 'Deleted successfully!',
'Parameter error' => 'Parameter error!',
'File uploaded successfully' => 'File uploaded successfully',
'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.',
'The uploaded image file is not a valid image' => 'The uploaded image file is not a valid image',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',
'Unknown' => 'Unknown',
'Account not exist' => 'Account does not exist',
'Account disabled' => 'Account is disabled',
'Token login failed' => 'Token login failed',
'Username is incorrect' => 'Incorrect username',
'Please try again after 1 day' => 'The number of login failures exceeds the limit, please try again after 24 hours',
'Password is incorrect' => 'Wrong password',
'You are not logged in' => 'You are not logged in',
'Cache cleaned~' => 'The cache has been cleaned up, please refresh the page.',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'Failed to switch package manager. Please modify the configuration file manually:%s' => 'Failed to switch package manager, please modify the configuration file manually:%s',
'Failed to modify the terminal configuration. Please modify the configuration file manually:%s' => 'Failed to modify the terminal configuration, please modify the configuration file manually:%s',
'upload' => 'Upload files',
'Change terminal config' => 'Modify terminal configuration',
'Clear cache' => 'Clear cache',
'Data table does not exist' => 'Data table does not exist',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'Group Name Arr' => 'Administrator Grouping ',
'Please use another administrator account to disable the current account!' => 'Disable the current account, please use another administrator account!',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Super administrator' => 'Super administrator',
'No permission' => 'No permission',
'You cannot modify your own management group!' => 'You cannot modify your own management group!',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'type' => 'Rule type',
'title' => 'Rule title',
'name' => 'Rule name',
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'change-field-name fail not exist' => 'Field %s failed to be renamed because the field does not exist in the data table',
'del-field fail not exist' => 'Failed to delete field %s because the field does not exist in the data table',
'change-field-attr fail not exist' => 'Description Failed to modify the properties of field %s because the field does not exist in the data table',
'add-field fail exist' => 'Failed to add field %s because the field already exists in the data table',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'Remark lang' => "Open source equals mutual assistance, and needs everyone's support. There are many ways to support it, such as using, recommending, writing tutorials, protecting the ecology, contributing code, answering questions, sharing experiences, donation, sponsorship and so on. Welcome to join us!",
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'No background menu, please contact super administrator!' => 'No background menu, please contact the super administrator!',
'You have already logged in. There is no need to log in again~' => 'You have already logged in, no need to log in again.',
'Login succeeded!' => 'Login successful!',
'Incorrect user name or password!' => 'Incorrect username or password!',
'Login' => 'Login',
'Logout' => 'Logout',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Please input correct username' => 'Please enter the correct username',
'Please input correct password' => 'Please enter the correct password',
'Avatar modified successfully!' => 'Profile picture modified successfully',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'%d records and files have been deleted' => '%d records and files have been deleted',
'remark_text' => 'When the same file is uploaded multiple times, only one copy will be saved to the disk and an attachment record will be added; Deleting an attachment record will automatically delete the corresponding file!',
];

View File

@@ -0,0 +1,23 @@
<?php
return [
'Basics' => 'Basic configuration',
'Mail' => 'Mail configuration',
'Config group' => 'Configure grouping',
'Site Name' => 'Site name',
'Config Quick entrance' => 'Quick configuration entrance',
'Record number' => 'Record Number',
'Version number' => 'Version Number',
'time zone' => 'Time zone',
'No access ip' => 'No access IP',
'smtp server' => 'SMTP server',
'smtp port' => 'SMTP port',
'smtp user' => 'SMTP username',
'smtp pass' => 'SMTP password',
'smtp verification' => 'SMTP verification mode',
'smtp sender mail' => 'SMTP sender mailbox',
'Variable name' => 'variable name',
'Test mail sent successfully~' => 'Test message sent successfully',
'This is a test email' => 'This is a test email',
'Congratulations, receiving this email means that your email service has been configured correctly' => "Congratulations, when you receive this email, it means that your mail service is configures correctly. This is the email subject, <b>you can use HtmlL!</b> in the main body.",
'Backend entrance rule' => 'The background entry must start with / and contain only numbers and letters.',
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'Name' => 'Rule Name',
'Controller' => 'Controller',
'Data Table' => 'Corresponding data table',
'Primary Key' => 'Data table primary key',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were restore' => 'No records have been restored',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'Name' => 'Rule name',
'Controller' => 'Controller',
'Data Table' => 'Corresponding data table',
'Primary Key' => 'Data table primary key',
'Data Fields' => 'Sensitive data fields',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were rollback' => 'No records have been roll-back',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => 'User',
'money' => 'Change amount',
'memo' => 'Change Notes',
"The user can't find it" => "User does not exist",
'Change note cannot be blank' => 'Change Notes cannot be empty',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => 'User',
'score' => 'Change points',
'memo' => 'Change Notes',
"The user can't find it" => 'User does not exist',
'Change note cannot be blank' => 'Change notes cannot be empty',
];

62
app/admin/lang/zh-cn.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
return [
'Please login first' => '请先登录!',
'You have no permission' => '没有权限操作!',
'Username' => '用户名',
'Password' => '密码',
'Nickname' => '昵称',
'Email' => '邮箱',
'Mobile' => '手机号',
'Captcha' => '验证码',
'CaptchaId' => '验证码ID',
'Please enter the correct verification code' => '请输入正确的验证码!',
'Captcha error' => '验证码错误!',
'Parameter %s can not be empty' => '参数%s不能为空',
'Record not found' => '记录未找到',
'No rows were added' => '未添加任何行',
'No rows were deleted' => '未删除任何行',
'No rows updated' => '未更新任何行',
'Update successful' => '更新成功!',
'Added successfully' => '添加成功!',
'Deleted successfully' => '删除成功!',
'Parameter error' => '参数错误!',
'Please use the %s field to sort before operating' => '请使用 %s 字段排序后再操作',
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
'Unknown' => '未知',
// 权限类语言包-s
'Account not exist' => '帐户不存在',
'Account disabled' => '帐户已禁用',
'Token login failed' => '令牌登录失败',
'Username is incorrect' => '用户名不正确',
'Please try again after 1 day' => '登录失败次数超限请在1天后再试',
'Password is incorrect' => '密码不正确',
'You are not logged in' => '你没有登录',
// 权限类语言包-e
// 时间格式化-s
'%d second%s ago' => '%d秒前',
'%d minute%s ago' => '%d分钟前',
'%d hour%s ago' => '%d小时前',
'%d day%s ago' => '%d天前',
'%d week%s ago' => '%d周前',
'%d month%s ago' => '%d月前',
'%d year%s ago' => '%d年前',
'%d second%s after' => '%d秒后',
'%d minute%s after' => '%d分钟后',
'%d hour%s after' => '%d小时后',
'%d day%s after' => '%d天后',
'%d week%s after' => '%d周后',
'%d month%s after' => '%d月后',
'%d year%s after' => '%d年后',
// 时间格式化-e
'Cache cleaned~' => '缓存已清理,请刷新后台~',
'Please delete the child element first, or use batch deletion' => '请首先删除子元素,或使用批量删除!',
'Configuration write failed: %s' => '配置写入失败:%s',
'Token expiration' => '登录态过期,请重新登录!',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'Start the database migration' => '开始进行数据库迁移',
'Start formatting the web project code' => '开始格式化前端代码(失败无影响,代码编辑器内按需的手动格式化即可)',
'Start installing the composer dependencies' => '开始安装服务端依赖',
'Start executing the build command of the web project' => '开始执行 web 工程的 build 命令,成功后会自动将构建产物移动至 根目录/public 目录下',
'Failed to modify the terminal configuration. Please modify the configuration file manually:%s' => '修改终端配置失败,请手动修改配置文件:%s',
'upload' => '上传文件',
'Change terminal config' => '修改终端配置',
'Clear cache' => '清理缓存',
'Data table does not exist' => '数据表不存在~',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Group Name Arr' => '管理员分组',
'Please use another administrator account to disable the current account!' => '请使用另外的管理员账户禁用当前账户!',
'You have no permission to add an administrator to this group!' => '您没有权限向此分组添加管理员!',
];

View File

@@ -0,0 +1,13 @@
<?php
return [
'name' => '组别名称',
'Please select rules' => '请选择权限',
'Super administrator' => '超级管理员',
'No permission' => '无权限',
'You cannot modify your own management group!' => '不能修改自己所在的管理组!',
'You need to have all permissions of this group to operate this group~' => '您需要拥有该分组的所有权限才可以操作该分组~',
'You need to have all the permissions of the group and have additional permissions before you can operate the group~' => '您需要拥有该分组的所有权限且还有额外权限时,才可以操作该分组~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => '角色组拥有您的全部权限,请联系上级管理员添加或无需添加!',
'The group permission node exceeds the range that can be allocated' => '分组权限节点超出可分配范围,请刷新重试~',
'Remark lang' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'type' => '规则类型',
'title' => '规则标题',
'name' => '规则名称',
];

View File

@@ -0,0 +1,11 @@
<?php
return [
'Parse field data' => 'CRUD代码生成-解析字段数据',
'Log start' => 'CRUD代码生成-从历史记录开始',
'Generate check' => 'CRUD代码生成-生成前预检',
'change-field-name fail not exist' => '字段 %s 改名失败,数据表内不存在该字段',
'del-field fail not exist' => '字段 %s 删除失败,数据表内不存在该字段',
'change-field-attr fail not exist' => '修改字段 %s 的属性失败,数据表内不存在该字段',
'add-field fail exist' => '添加字段 %s 失败,数据表内已经存在该字段',
'Failed to load cloud data' => '加载云端数据失败,请稍后重试!',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'Remark lang' => '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'No background menu, please contact super administrator!' => '无后台菜单,请联系超级管理员!',
'You have already logged in. There is no need to log in again~' => '您已经登录过了,无需重复登录~',
'Login succeeded!' => '登录成功!',
'Incorrect user name or password!' => '用户名或密码不正确!',
'Login' => '登录',
'Logout' => '注销登录',
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'Order not found' => '订单找不到啦!',
'Module already exists' => '模块已存在!',
'package download failed' => '包下载失败!',
'package check failed' => '包检查失败!',
'No permission to write temporary files' => '没有权限写入临时文件!',
'Zip file not found' => '找不到压缩包文件',
'Unable to open the zip file' => '无法打开压缩包文件',
'Unable to extract ZIP file' => '无法提取ZIP文件',
'Unable to package zip file' => '无法打包zip文件',
'Basic configuration of the Module is incomplete' => '模块基础配置不完整',
'Module package file does not exist' => '模块包文件不存在',
'Module file conflicts' => '模块文件存在冲突,请手动处理!',
'Configuration file has no write permission' => '配置文件无写入权限',
'The current state of the module cannot be set to disabled' => '模块当前状态无法设定为禁用',
'The current state of the module cannot be set to enabled' => '模块当前状态无法设定为启用',
'Module file updated' => '模块文件有更新',
'Please disable the module first' => '请先禁用模块',
'Please disable the module before updating' => '更新前请先禁用模块',
'The directory required by the module is occupied' => '模块所需目录已被占用',
'Install module' => '安装模块',
'Unload module' => '卸载模块',
'Update module' => '更新模块',
'Change module state' => '改变模块状态',
'Upload install module' => '上传安装模块',
'Please login to the official website account first' => '请先使用BuildAdmin官网账户登录到模块市场~',
'composer config %s conflict' => 'composer 配置项 %s 存在冲突',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Please input correct username' => '请输入正确的用户名',
'Please input correct password' => '请输入正确的密码',
'Avatar modified successfully!' => '头像修改成功!',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'Remark lang' => '同一文件被多次上传时,只会保存一份至磁盘和增加一条附件记录;删除附件记录,将自动删除对应文件!',
'%d records and files have been deleted' => '删除了%d条记录和文件',
];

View File

@@ -0,0 +1,25 @@
<?php
return [
'Basics' => '基础配置',
'Mail' => '邮件配置',
'Config group' => '配置分组',
'Site Name' => '站点名称',
'Backend entrance' => '自定义后台入口',
'Config Quick entrance' => '快捷配置入口',
'Record number' => '备案号',
'Version number' => '版本号',
'time zone' => '时区',
'No access ip' => '禁止访问IP',
'smtp server' => 'SMTP 服务器',
'smtp port' => 'SMTP 端口',
'smtp user' => 'SMTP 用户名',
'smtp pass' => 'SMTP 密码',
'smtp verification' => 'SMTP 验证方式',
'smtp sender mail' => 'SMTP 发件人邮箱',
'Variable name' => '变量名',
'Test mail sent successfully~' => '测试邮件发送成功~',
'This is a test email' => '这是一封测试邮件',
'Congratulations, receiving this email means that your email service has been configured correctly' => '恭喜您,收到此邮件代表您的邮件服务已配置正确;这是邮件主体 <b>在主体中可以使用Html!</b>',
'The current page configuration item was updated successfully' => '当前页配置项更新成功!',
'Backend entrance rule' => '后台入口请以 / 开头,且只包含数字和字母。',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'Name' => '规则名称',
'Controller' => '控制器',
'Data Table' => '对应数据表',
'Primary Key' => '数据表主键',
'Remark lang' => '在此定义需要回收的数据,实现数据自动统一回收',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were restore' => '没有记录被还原',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'Name' => '规则名称',
'Controller' => '控制器',
'Data Table' => '对应数据表',
'Primary Key' => '数据表主键',
'Data Fields' => '敏感数据字段',
'Remark lang' => '在此定义需要保护的敏感字段,随后系统将自动监听该字段的修改操作,并提供了敏感字段的修改回滚功能',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were rollback' => '没有记录被回滚',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => '用户',
'money' => '变更金额',
'memo' => '变更备注',
"The user can't find it" => '用户找不到啦',
'Change note cannot be blank' => '变更备注不能为空',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => '用户',
'score' => '变更积分',
'memo' => '变更备注',
"The user can't find it" => '用户找不到啦',
'Change note cannot be blank' => '变更备注不能为空',
];

525
app/admin/library/Auth.php Normal file
View File

@@ -0,0 +1,525 @@
<?php
namespace app\admin\library;
use Throwable;
use ba\Random;
use think\facade\Db;
use think\facade\Config;
use app\admin\model\Admin;
use app\common\facade\Token;
use app\admin\model\AdminGroup;
/**
* 管理员权限类
* @property int $id 管理员ID
* @property string $username 管理员用户名
* @property string $nickname 管理员昵称
* @property string $email 管理员邮箱
* @property string $mobile 管理员手机号
*/
class Auth extends \ba\Auth
{
/**
* 需要登录时/无需登录时的响应状态代码
*/
public const LOGIN_RESPONSE_CODE = 303;
/**
* 需要登录标记 - 前台应清理 token、记录当前路由 path、跳转到登录页
*/
public const NEED_LOGIN = 'need login';
/**
* 已经登录标记 - 前台应跳转到基础路由
*/
public const LOGGED_IN = 'logged in';
/**
* token 入库 type
*/
public const TOKEN_TYPE = 'admin';
/**
* 是否登录
* @var bool
*/
protected bool $loginEd = false;
/**
* 错误消息
* @var string
*/
protected string $error = '';
/**
* Model实例
* @var ?Admin
*/
protected ?Admin $model = null;
/**
* 令牌
* @var string
*/
protected string $token = '';
/**
* 刷新令牌
* @var string
*/
protected string $refreshToken = '';
/**
* 令牌默认有效期
* 可在 config/buildadmin.php 内修改默认值
* @var int
*/
protected int $keepTime = 86400;
/**
* 刷新令牌有效期
* @var int
*/
protected int $refreshTokenKeepTime = 2592000;
/**
* 允许输出的字段
* @var array
*/
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time'];
public function __construct(array $config = [])
{
parent::__construct($config);
$this->setKeepTime((int)Config::get('buildadmin.admin_token_keep_time'));
}
/**
* 魔术方法-管理员信息字段
* @param $name
* @return mixed 字段信息
*/
public function __get($name): mixed
{
return $this->model?->$name;
}
/**
* 初始化
* @access public
* @param array $options 传递到 /ba/Auth 的配置信息
* @return Auth
*/
public static function instance(array $options = []): Auth
{
$request = request();
if (!isset($request->adminAuth)) {
$request->adminAuth = new static($options);
}
return $request->adminAuth;
}
/**
* 根据Token初始化管理员登录态
* @param string $token
* @return bool
* @throws Throwable
*/
public function init(string $token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
/**
* 过期检查,过期则抛出 @see TokenExpirationException
*/
Token::tokenExpirationCheck($tokenData);
$userId = intval($tokenData['user_id']);
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
$this->model = Admin::where('id', $userId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model['status'] != 'enable') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
/**
* 管理员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
public function login(string $username, string $password, bool $keep = false): bool
{
$this->model = Admin::where('username', $username)->find();
if (!$this->model) {
$this->setError('Username is incorrect');
return false;
}
if ($this->model->status == 'disable') {
$this->setError('Account disabled');
return false;
}
// 登录失败重试检查
$lastLoginTime = $this->model->getData('last_login_time');
$adminLoginRetry = Config::get('buildadmin.admin_login_retry');
if ($adminLoginRetry && $lastLoginTime) {
// 重置失败次数
if ($this->model->login_failure > 0 && time() - $lastLoginTime >= 86400) {
$this->model->login_failure = 0;
$this->model->save();
// 重获模型实例,避免单实例多次更新
$this->model = Admin::where('username', $username)->find();
}
if ($this->model->login_failure >= $adminLoginRetry) {
$this->setError('Please try again after 1 day');
return false;
}
}
// 密码检查
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
}
// 清理 token
if (Config::get('buildadmin.admin_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
$this->loginSuccessful();
return true;
}
/**
* 设置刷新Token
* @param int $keepTime
*/
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
/**
* 管理员登录成功
* @return bool
*/
public function loginSuccessful(): bool
{
if (!$this->model) return false;
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->loginEd = true;
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
/**
* 管理员登录失败
* @return bool
*/
public function loginFailed(): bool
{
if (!$this->model) return false;
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return $this->reset();
}
/**
* 退出登录
* @return bool
*/
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
/**
* 是否登录
* @return bool
*/
public function isLogin(): bool
{
return $this->loginEd;
}
/**
* 获取管理员模型
* @return Admin
*/
public function getAdmin(): Admin
{
return $this->model;
}
/**
* 获取管理员Token
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* 获取管理员刷新Token
* @return string
*/
public function getRefreshToken(): string
{
return $this->refreshToken;
}
/**
* 获取管理员信息 - 只输出允许输出的字段
* @return array
*/
public function getInfo(): array
{
if (!$this->model) return [];
$info = $this->model->toArray();
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
/**
* 获取允许输出字段
* @return array
*/
public function getAllowFields(): array
{
return $this->allowFields;
}
/**
* 设置允许输出字段
* @param $fields
* @return void
*/
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
/**
* 设置Token有效期
* @param int $keepTime
* @return void
*/
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
public function check(string $name, int $uid = 0, string $relation = 'or', string $mode = 'url'): bool
{
return parent::check($name, $uid ?: $this->id, $relation, $mode);
}
public function getGroups(int $uid = 0): array
{
return parent::getGroups($uid ?: $this->id);
}
public function getRuleList(int $uid = 0): array
{
return parent::getRuleList($uid ?: $this->id);
}
public function getRuleIds(int $uid = 0): array
{
return parent::getRuleIds($uid ?: $this->id);
}
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
}
/**
* 是否是超级管理员
* @throws Throwable
*/
public function isSuperAdmin(): bool
{
return in_array('*', $this->getRuleIds());
}
/**
* 获取管理员所在分组的所有子级分组
* @return array
* @throws Throwable
*/
public function getAdminChildGroups(): array
{
$groupIds = Db::name('admin_group_access')
->where('uid', $this->id)
->select();
$children = [];
foreach ($groupIds as $group) {
$this->getGroupChildGroups($group['group_id'], $children);
}
return array_unique($children);
}
/**
* 获取一个分组下的子分组
* @param int $groupId 分组ID
* @param array $children 存放子分组的变量
* @return void
* @throws Throwable
*/
public function getGroupChildGroups(int $groupId, array &$children): void
{
$childrenTemp = AdminGroup::where('pid', $groupId)
->where('status', 1)
->select();
foreach ($childrenTemp as $item) {
$children[] = $item['id'];
$this->getGroupChildGroups($item['id'], $children);
}
}
/**
* 获取分组内的管理员
* @param array $groups
* @return array 管理员数组
*/
public function getGroupAdmins(array $groups): array
{
return Db::name('admin_group_access')
->where('group_id', 'in', $groups)
->column('uid');
}
/**
* 获取拥有 `所有权限` 的分组
* @param string $dataLimit 数据权限
* @param array $groupQueryWhere 分组查询条件(默认查询启用的分组:[['status','=',1]]
* @return array 分组数组
* @throws Throwable
*/
public function getAllAuthGroups(string $dataLimit, array $groupQueryWhere = [['status', '=', 1]]): array
{
// 当前管理员拥有的权限
$rules = $this->getRuleIds();
$allAuthGroups = [];
$groups = AdminGroup::where($groupQueryWhere)->select();
foreach ($groups as $group) {
if ($group['rules'] == '*') {
continue;
}
$groupRules = explode(',', $group['rules']);
// 及时break, array_diff 等没有 in_array 快
$all = true;
foreach ($groupRules as $groupRule) {
if (!in_array($groupRule, $rules)) {
$all = false;
break;
}
}
if ($all) {
if ($dataLimit == 'allAuth' || ($dataLimit == 'allAuthAndOthers' && array_diff($rules, $groupRules))) {
$allAuthGroups[] = $group['id'];
}
}
}
return $allAuthGroups;
}
/**
* 设置错误消息
* @param $error
* @return Auth
*/
public function setError($error): Auth
{
$this->error = $error;
return $this;
}
/**
* 获取错误消息
* @return string
*/
public function getError(): string
{
return $this->error ? __($this->error) : '';
}
/**
* 属性重置(注销、登录失败、重新初始化等将单例数据销毁)
*/
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
Token::delete($this->token);
}
$this->token = '';
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->setError('');
$this->setKeepTime((int)Config::get('buildadmin.admin_token_keep_time'));
return true;
}
}

View File

@@ -0,0 +1,1299 @@
<?php
namespace app\admin\library\crud;
use Throwable;
use ba\Filesystem;
use think\Exception;
use ba\TableManager;
use think\facade\Db;
use app\common\library\Menu;
use app\admin\model\AdminRule;
use app\admin\model\CrudLog;
use ba\Exception as BaException;
use Phinx\Db\Adapter\MysqlAdapter;
use Phinx\Db\Adapter\AdapterInterface;
class Helper
{
/**
* 内部保留词
* @var array
*/
protected static array $reservedKeywords = [
'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone',
'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty',
'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends',
'final', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once',
'instanceof', 'insteadof', 'interface', 'isset', 'list', 'namespace', 'new', 'or', 'print', 'private',
'protected', 'public', 'require', 'require_once', 'return', 'static', 'switch', 'throw', 'trait', 'try',
'unset', 'use', 'var', 'while', 'xor', 'yield', 'match', 'readonly', 'fn',
];
/**
* 预设控制器和模型文件位置
* @var array
*/
protected static array $parseNamePresets = [
'controller' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
'model' => [],
'validate' => [],
];
/**
* 子级菜单数组(权限节点)
* @var array
*/
public static array $menuChildren = [
['type' => 'button', 'title' => '查看', 'name' => '/index', 'status' => 1],
['type' => 'button', 'title' => '添加', 'name' => '/add', 'status' => 1],
['type' => 'button', 'title' => '编辑', 'name' => '/edit', 'status' => 1],
['type' => 'button', 'title' => '删除', 'name' => '/del', 'status' => 1],
['type' => 'button', 'title' => '快速排序', 'name' => '/sortable', 'status' => 1],
];
/**
* 输入框类型的识别规则
* @var array
*/
protected static array $inputTypeRule = [
// 开关组件
[
'type' => ['tinyint', 'int', 'enum'],
'suffix' => ['switch', 'toggle'],
'value' => 'switch',
],
[
'column_type' => ['tinyint(1)', 'char(1)', 'tinyint(1) unsigned'],
'suffix' => ['switch', 'toggle'],
'value' => 'switch',
],
// 富文本-识别规则和textarea重合,优先识别为富文本
[
'type' => ['longtext', 'text', 'mediumtext', 'smalltext', 'tinytext', 'bigtext'],
'suffix' => ['content', 'editor'],
'value' => 'editor',
],
// textarea
[
'type' => ['varchar'],
'suffix' => ['textarea', 'multiline', 'rows'],
'value' => 'textarea',
],
// Array
[
'suffix' => ['array'],
'value' => 'array',
],
// 时间选择器-字段类型为int同时以['time', 'datetime']结尾
[
'type' => ['int'],
'suffix' => ['time', 'datetime'],
'value' => 'timestamp',
],
[
'type' => ['datetime', 'timestamp'],
'value' => 'datetime',
],
[
'type' => ['date'],
'value' => 'date',
],
[
'type' => ['year'],
'value' => 'year',
],
[
'type' => ['time'],
'value' => 'time',
],
// 单选select
[
'suffix' => ['select', 'list', 'data'],
'value' => 'select',
],
// 多选select
[
'suffix' => ['selects', 'multi', 'lists'],
'value' => 'selects',
],
// 远程select
[
'suffix' => ['_id'],
'value' => 'remoteSelect',
],
// 远程selects
[
'suffix' => ['_ids'],
'value' => 'remoteSelects',
],
// 城市选择器
[
'suffix' => ['city'],
'value' => 'city',
],
// 单图上传
[
'suffix' => ['image', 'avatar'],
'value' => 'image',
],
// 多图上传
[
'suffix' => ['images', 'avatars'],
'value' => 'images',
],
// 文件上传
[
'suffix' => ['file'],
'value' => 'file',
],
// 多文件上传
[
'suffix' => ['files'],
'value' => 'files',
],
// icon选择器
[
'suffix' => ['icon'],
'value' => 'icon',
],
// 单选框
[
'column_type' => ['tinyint(1)', 'char(1)', 'tinyint(1) unsigned'],
'suffix' => ['status', 'state', 'type'],
'value' => 'radio',
],
// 数字输入框
[
'suffix' => ['number', 'int', 'num'],
'value' => 'number',
],
[
'type' => ['bigint', 'int', 'mediumint', 'smallint', 'tinyint', 'decimal', 'double', 'float'],
'value' => 'number',
],
// 富文本-低权重
[
'type' => ['longtext', 'text', 'mediumtext', 'smalltext', 'tinytext', 'bigtext'],
'value' => 'textarea',
],
// 单选框-低权重
[
'type' => ['enum'],
'value' => 'radio',
],
// 多选框
[
'type' => ['set'],
'value' => 'checkbox',
],
// 颜色选择器
[
'suffix' => ['color'],
'value' => 'color',
],
];
/**
* 预设WEB端文件位置
* @var array
*/
protected static array $parseWebDirPresets = [
'lang' => [],
'views' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
];
/**
* 添加时间字段
* @var string
*/
protected static string $createTimeField = 'create_time';
/**
* 更新时间字段
* @var string
*/
protected static string $updateTimeField = 'update_time';
/**
* 属性的类型对照表
* @var array
*/
protected static array $attrType = [
'controller' => [
'preExcludeFields' => 'array|string',
'quickSearchField' => 'string|array',
'withJoinTable' => 'array',
'defaultSortField' => 'string|array',
'weighField' => 'string',
],
];
/**
* 获取字段字典数据
* @param array $dict 存储字典数据的变量
* @param array $field 字段数据
* @param string $lang 语言
* @param string $translationPrefix 翻译前缀
*/
public static function getDictData(array &$dict, array $field, string $lang, string $translationPrefix = ''): array
{
if (!$field['comment']) return [];
$comment = str_replace(['', ''], [',', ':'], $field['comment']);
if (stripos($comment, ':') !== false && stripos($comment, ',') && stripos($comment, '=') !== false) {
[$fieldTitle, $item] = explode(':', $comment);
$dict[$translationPrefix . $field['name']] = $lang == 'en' ? $field['name'] : $fieldTitle;
foreach (explode(',', $item) as $v) {
$valArr = explode('=', $v);
if (count($valArr) == 2) {
[$key, $value] = $valArr;
$dict[$translationPrefix . $field['name'] . ' ' . $key] = $lang == 'en' ? $field['name'] . ' ' . $key : $value;
}
}
} else {
$dict[$translationPrefix . $field['name']] = $lang == 'en' ? $field['name'] : $comment;
}
return $dict;
}
/**
* 记录CRUD状态
* @param array $data CRUD记录数据
* @return int 记录ID
*/
public static function recordCrudStatus(array $data): int
{
if (isset($data['id'])) {
CrudLog::where('id', $data['id'])
->update([
'status' => $data['status'],
]);
return $data['id'];
}
$connection = $data['table']['databaseConnection'] ?: config('database.default');
$log = CrudLog::create([
'table_name' => $data['table']['name'],
'comment' => $data['table']['comment'],
'table' => $data['table'],
'fields' => $data['fields'],
'connection' => $connection,
'status' => $data['status'],
]);
return $log->id;
}
/**
* 获取 Phinx 的字段类型数据
* @param string $type 字段类型
* @param array $field 字段数据
* @return array
*/
public static function getPhinxFieldType(string $type, array $field): array
{
if ($type == 'tinyint') {
if (
(isset($field['dataType']) && $field['dataType'] == 'tinyint(1)') ||
($field['default'] == '1' && $field['defaultType'] == 'INPUT')
) {
$type = 'boolean';
}
}
$phinxFieldTypeMap = [
// 数字
'tinyint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_TINY],
'smallint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_SMALL],
'mediumint' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => MysqlAdapter::INT_MEDIUM],
'int' => ['type' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => null],
'bigint' => ['type' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => null],
'boolean' => ['type' => AdapterInterface::PHINX_TYPE_BOOLEAN, 'limit' => null],
// 文本
'varchar' => ['type' => AdapterInterface::PHINX_TYPE_STRING, 'limit' => null],
'tinytext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_TINY],
'mediumtext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_MEDIUM],
'longtext' => ['type' => AdapterInterface::PHINX_TYPE_TEXT, 'limit' => MysqlAdapter::TEXT_LONG],
'tinyblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_TINY],
'mediumblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_MEDIUM],
'longblob' => ['type' => AdapterInterface::PHINX_TYPE_BLOB, 'limit' => MysqlAdapter::BLOB_LONG],
];
return array_key_exists($type, $phinxFieldTypeMap) ? $phinxFieldTypeMap[$type] : ['type' => $type, 'limit' => null];
}
/**
* 分析字段limit和精度
* @param string $type 字段类型
* @param array $field 字段数据
* @return array ['limit' => 10, 'precision' => null, 'scale' => null]
*/
public static function analyseFieldLimit(string $type, array $field): array
{
$fieldType = [
'decimal' => ['decimal', 'double', 'float'],
'values' => ['enum', 'set'],
];
$dataTypeLimit = self::dataTypeLimit($field['dataType'] ?? '');
if (in_array($type, $fieldType['decimal'])) {
if ($dataTypeLimit) {
return ['precision' => $dataTypeLimit[0], 'scale' => $dataTypeLimit[1] ?? 0];
}
$scale = isset($field['precision']) ? intval($field['precision']) : 0;
return ['precision' => $field['length'] ?? 10, 'scale' => $scale];
} elseif (in_array($type, $fieldType['values'])) {
foreach ($dataTypeLimit as &$item) {
$item = str_replace(['"', "'"], '', $item);
}
return ['values' => $dataTypeLimit];
} elseif ($dataTypeLimit && $dataTypeLimit[0]) {
return ['limit' => $dataTypeLimit[0]];
} elseif (isset($field['length'])) {
return ['limit' => $field['length']];
}
return [];
}
public static function dataTypeLimit(string $dataType): array
{
preg_match("/\((.*?)\)/", $dataType, $matches);
if (isset($matches[1]) && $matches[1]) {
return explode(',', trim($matches[1], ','));
}
return [];
}
public static function analyseFieldDefault(array $field): mixed
{
return match ($field['defaultType']) {
'EMPTY STRING' => '',
'NULL' => null,
default => $field['default'],
};
}
public static function searchArray($fields, callable $myFunction): array|bool
{
foreach ($fields as $key => $field) {
if (call_user_func($myFunction, $field, $key)) {
return $field;
}
}
return false;
}
/**
* 获取 Phinx 格式的字段数据
* @param array $field
* @return array
*/
public static function getPhinxFieldData(array $field): array
{
$conciseType = self::analyseFieldType($field);
$phinxTypeData = self::getPhinxFieldType($conciseType, $field);
$phinxColumnOptions = self::analyseFieldLimit($conciseType, $field);
if (!is_null($phinxTypeData['limit'])) {
$phinxColumnOptions['limit'] = $phinxTypeData['limit'];
}
// 无默认值字段
$noDefaultValueFields = [
'text', 'blob', 'geometry', 'geometrycollection', 'json', 'linestring', 'longblob', 'longtext', 'mediumblob',
'mediumtext', 'multilinestring', 'multipoint', 'multipolygon', 'point', 'polygon', 'tinyblob',
];
if ($field['defaultType'] != 'NONE' && !in_array($conciseType, $noDefaultValueFields)) {
$phinxColumnOptions['default'] = self::analyseFieldDefault($field);
}
$phinxColumnOptions['null'] = (bool)$field['null'];
$phinxColumnOptions['comment'] = $field['comment'];
$phinxColumnOptions['signed'] = !$field['unsigned'];
$phinxColumnOptions['identity'] = $field['autoIncrement'];
return [
'type' => $phinxTypeData['type'],
'options' => $phinxColumnOptions,
];
}
/**
* 表字段排序
* @param string $tableName 表名
* @param array $fields 字段数据
* @param array $designChange 前端字段改变数据
* @param ?string $connection 数据库连接标识
* @return void
* @throws Throwable
*/
public static function updateFieldOrder(string $tableName, array $fields, array $designChange, ?string $connection = null): void
{
if ($designChange) {
$table = TableManager::phinxTable($tableName, [], false, $connection);
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if (!empty($item['after'])) {
$fieldName = in_array($item['type'], ['add-field', 'change-field-name']) ? $item['newName'] : $item['oldName'];
$field = self::searchArray($fields, function ($field) use ($fieldName) {
return $field['name'] == $fieldName;
});
$phinxFieldData = self::getPhinxFieldData($field);
// 字段顺序调整
if ($item['after'] == 'FIRST FIELD') {
$phinxFieldData['options']['after'] = MysqlAdapter::FIRST;
} else {
$phinxFieldData['options']['after'] = $item['after'];
}
$table->changeColumn($fieldName, $phinxFieldData['type'], $phinxFieldData['options']);
}
}
$table->update();
}
}
/**
* 表设计处理
* @param array $table 表数据
* @param array $fields 字段数据
* @return array
* @throws Throwable
*/
public static function handleTableDesign(array $table, array $fields): array
{
$name = TableManager::tableName($table['name'], true, $table['databaseConnection']);
$comment = $table['comment'] ?? '';
$designChange = $table['designChange'] ?? [];
$adapter = TableManager::phinxAdapter(false, $table['databaseConnection']);
$pk = self::searchArray($fields, function ($item) {
return $item['primaryKey'];
});
$pk = $pk ? $pk['name'] : '';
if ($adapter->hasTable($name)) {
// 更新表
if ($designChange) {
$tableManager = TableManager::phinxTable($name, [], false, $table['databaseConnection']);
$tableManager->changeComment($comment)->update();
// 改名和删除操作优先
$priorityOpt = false;
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if (in_array($item['type'], ['change-field-name', 'del-field']) && !$tableManager->hasColumn($item['oldName'])) {
// 字段不存在
throw new BaException(__($item['type'] . ' fail not exist', [$item['oldName']]));
}
if ($item['type'] == 'change-field-name') {
$priorityOpt = true;
$tableManager->renameColumn($item['oldName'], $item['newName']);
} elseif ($item['type'] == 'del-field') {
$priorityOpt = true;
$tableManager->removeColumn($item['oldName']);
}
}
// 保存需要优先执行的操作,避免先改名再改属性时找不到字段
if ($priorityOpt) {
$tableManager->update();
}
// 修改字段属性和添加字段操作
foreach ($designChange as $item) {
if (!$item['sync']) continue;
if ($item['type'] == 'change-field-attr') {
if (!$tableManager->hasColumn($item['oldName'])) {
// 字段不存在
throw new BaException(__($item['type'] . ' fail not exist', [$item['oldName']]));
}
$phinxFieldData = self::getPhinxFieldData(self::searchArray($fields, function ($field) use ($item) {
return $field['name'] == $item['oldName'];
}));
$tableManager->changeColumn($item['oldName'], $phinxFieldData['type'], $phinxFieldData['options']);
} elseif ($item['type'] == 'add-field') {
if ($tableManager->hasColumn($item['newName'])) {
// 字段已经存在
throw new BaException(__($item['type'] . ' fail exist', [$item['newName']]));
}
$phinxFieldData = self::getPhinxFieldData(self::searchArray($fields, function ($field) use ($item) {
return $field['name'] == $item['newName'];
}));
$tableManager->addColumn($item['newName'], $phinxFieldData['type'], $phinxFieldData['options']);
}
}
$tableManager->update();
// 表更新结构完成再处理字段排序
self::updateFieldOrder($name, $fields, $designChange, $table['databaseConnection']);
}
} else {
// 创建表
$tableManager = TableManager::phinxTable($name, [
'id' => false,
'comment' => $comment,
'row_format' => 'DYNAMIC',
'primary_key' => $pk,
'collation' => 'utf8mb4_unicode_ci',
], false, $table['databaseConnection']);
foreach ($fields as $field) {
$phinxFieldData = self::getPhinxFieldData($field);
$tableManager->addColumn($field['name'], $phinxFieldData['type'], $phinxFieldData['options']);
}
$tableManager->create();
}
return [$pk];
}
/**
* 解析文件数据
* @throws Throwable
*/
public static function parseNameData($app, $table, $type, $value = ''): array
{
$pathArr = [];
if ($value) {
$value = str_replace('.php', '', $value);
$value = str_replace(['.', '/', '\\', '_'], '/', $value);
$pathArrTemp = explode('/', $value);
$redundantDir = [
'app' => 0,
$app => 1,
$type => 2,
];
foreach ($pathArrTemp as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
} elseif (isset(self::$parseNamePresets[$type]) && array_key_exists($table, self::$parseNamePresets[$type])) {
$pathArr = self::$parseNamePresets[$type][$table];
} else {
$table = str_replace(['.', '/', '\\', '_'], '/', $table);
$pathArr = explode('/', $table);
}
$originalLastName = array_pop($pathArr);
$pathArr = array_map('strtolower', $pathArr);
$lastName = ucfirst($originalLastName);
// 类名不能为内部关键字
if (in_array(strtolower($originalLastName), self::$reservedKeywords)) {
throw new Exception('Unable to use internal variable:' . $lastName);
}
$appDir = app()->getBasePath() . $app . DIRECTORY_SEPARATOR;
$namespace = "app\\$app\\$type" . ($pathArr ? '\\' . implode('\\', $pathArr) : '');
$parseFile = $appDir . $type . DIRECTORY_SEPARATOR . ($pathArr ? implode(DIRECTORY_SEPARATOR, $pathArr) . DIRECTORY_SEPARATOR : '') . $lastName . '.php';
$rootFileName = $namespace . "/$lastName" . '.php';
return [
'lastName' => $lastName,
'originalLastName' => $originalLastName,
'path' => $pathArr,
'namespace' => $namespace,
'parseFile' => Filesystem::fsFit($parseFile),
'rootFileName' => Filesystem::fsFit($rootFileName),
];
}
public static function parseWebDirNameData($table, $type, $value = ''): array
{
$pathArr = [];
if ($value) {
$value = str_replace(['.', '/', '\\', '_'], '/', $value);
$pathArrTemp = explode('/', $value);
$redundantDir = [
'web' => 0,
'src' => 1,
'views' => 2,
'lang' => 2,
'backend' => 3,
'pages' => 3,
'en' => 4,
'zh-cn' => 4,
];
foreach ($pathArrTemp as $key => $item) {
if (!array_key_exists($item, $redundantDir) || $key !== $redundantDir[$item]) {
$pathArr[] = $item;
}
}
} elseif (array_key_exists($table, self::$parseWebDirPresets[$type])) {
$pathArr = self::$parseWebDirPresets[$type][$table];
} else {
$table = str_replace(['.', '/', '\\', '_'], '/', $table);
$pathArr = explode('/', $table);
}
$originalLastName = array_pop($pathArr);
$pathArr = array_map('strtolower', $pathArr);
$lastName = lcfirst($originalLastName);
$webDir['path'] = $pathArr;
$webDir['lastName'] = $lastName;
$webDir['originalLastName'] = $originalLastName;
if ($type == 'views') {
$webDir['views'] = "web/src/views/backend" . ($pathArr ? '/' . implode('/', $pathArr) : '') . "/$lastName";
} elseif ($type == 'lang') {
$webDir['lang'] = array_merge($pathArr, [$lastName]);
$langDir = ['en', 'zh-cn'];
foreach ($langDir as $item) {
$webDir[$item] = "web/src/lang/backend/$item" . ($pathArr ? '/' . implode('/', $pathArr) : '') . "/$lastName";
}
}
foreach ($webDir as &$item) {
if (is_string($item)) $item = Filesystem::fsFit($item);
}
return $webDir;
}
/**
* 获取菜单name、path
* @param array $webDir
* @return string
*/
public static function getMenuName(array $webDir): string
{
return ($webDir['path'] ? implode('/', $webDir['path']) . '/' : '') . $webDir['originalLastName'];
}
/**
* 获取基础模板文件路径
* @param string $name
* @return string
*/
public static function getStubFilePath(string $name): string
{
return app_path() . DIRECTORY_SEPARATOR . 'library' . DIRECTORY_SEPARATOR . 'crud' . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . Filesystem::fsFit($name) . '.stub';
}
/**
* 多维数组转字符串
*/
public static function arrayToString(array|string $value): string
{
if (!is_array($value)) {
return $value;
}
foreach ($value as &$item) {
$item = self::arrayToString($item);
}
return implode(PHP_EOL, $value);
}
/**
* 组装模板
* @param string $name
* @param array $data
* @param bool $escape
* @return string
*/
public static function assembleStub(string $name, array $data, bool $escape = false): string
{
foreach ($data as &$datum) {
$datum = self::arrayToString($datum);
}
$search = $replace = [];
foreach ($data as $k => $v) {
$search[] = "{%$k%}";
$replace[] = $v;
}
$stubPath = self::getStubFilePath($name);
$stubContent = file_get_contents($stubPath);
$content = str_replace($search, $replace, $stubContent);
return $escape ? self::escape($content) : $content;
}
/**
* 获取转义编码后的值
* @param array|string $value
* @return string
*/
public static function escape(array|string $value): string
{
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false);
}
public static function tab(int $num = 1): string
{
return str_pad('', 4 * $num);
}
/**
* 根据数据表解析字段数据
* @throws Throwable
*/
public static function parseTableColumns(string $table, bool $analyseField = false, ?string $connection = null): array
{
$connection = TableManager::getConnection($connection);
$connectionConfig = TableManager::getConnectionConfig($connection);
// 从数据库中获取表字段信息
$sql = 'SELECT * FROM `information_schema`.`columns` '
. 'WHERE TABLE_SCHEMA = ? AND table_name = ? '
. 'ORDER BY ORDINAL_POSITION';
$columns = [];
$tableColumn = Db::connect($connection)->query($sql, [$connectionConfig['database'], TableManager::tableName($table, true, $connection)]);
foreach ($tableColumn as $item) {
$isNullAble = $item['IS_NULLABLE'] == 'YES';
if (str_contains($item['COLUMN_TYPE'], '(')) {
$dataType = substr_replace($item['COLUMN_TYPE'], '', stripos($item['COLUMN_TYPE'], ')') + 1);
} else {
$dataType = str_replace(' unsigned', '', $item['COLUMN_TYPE']);
}
// 默认值和默认值类型分析
$default = '';
if ($isNullAble && is_null($item['COLUMN_DEFAULT'])) {
$defaultType = 'NULL';
} elseif ($item['COLUMN_DEFAULT'] == '' && in_array($item['DATA_TYPE'], ['varchar', 'char'])) {
$defaultType = 'EMPTY STRING';
} elseif (!$isNullAble && is_null($item['COLUMN_DEFAULT'])) {
$defaultType = 'NONE';
} else {
$defaultType = 'INPUT';
$default = $item['COLUMN_DEFAULT'];
}
$column = [
'name' => $item['COLUMN_NAME'],
'type' => $item['DATA_TYPE'],
'dataType' => $dataType,
'default' => $default,
'defaultType' => $defaultType,
'null' => $isNullAble,
'primaryKey' => $item['COLUMN_KEY'] == 'PRI',
'unsigned' => (bool)stripos($item['COLUMN_TYPE'], 'unsigned'),
'autoIncrement' => stripos($item['EXTRA'], 'auto_increment') !== false,
'comment' => $item['COLUMN_COMMENT'],
'designType' => self::getTableColumnsDataType($item),
'table' => [],
'form' => [],
];
if ($analyseField) {
self::analyseField($column);
} else {
self::handleTableColumn($column);
}
$columns[$item['COLUMN_NAME']] = $column;
}
return $columns;
}
/**
* 解析到的表字段的额外处理
*/
public static function handleTableColumn(&$column): void
{
// 预留
}
/**
* 分析字段数据类型
* @param array $field 字段数据
* @return string 字段类型
*/
public static function analyseFieldType(array $field): string
{
$dataType = (isset($field['dataType']) && $field['dataType']) ? $field['dataType'] : $field['type'];
if (stripos($dataType, '(') !== false) {
$typeName = explode('(', $dataType);
return trim($typeName[0]);
}
return trim($dataType);
}
/**
* 分析字段的完整数据类型定义
* @param array $field 字段数据
* @return string
*/
public static function analyseFieldDataType(array $field): string
{
if (!empty($field['dataType'])) return $field['dataType'];
$conciseType = self::analyseFieldType($field);
$limit = self::analyseFieldLimit($conciseType, $field);
if (isset($limit['precision'])) {
$dataType = "$conciseType({$limit['precision']}, {$limit['scale']})";
} elseif (isset($limit['values'])) {
$values = implode(',', $limit['values']);
$dataType = "$conciseType($values)";
} else {
$dataType = "$conciseType({$limit['limit']})";
}
return $dataType;
}
/**
* 分析字段
*/
public static function analyseField(&$field): void
{
$field['type'] = self::analyseFieldType($field);
$field['originalDesignType'] = $field['designType'];
// 表单项类型转换对照表
$designTypeComparison = [
'pk' => 'string',
'weigh' => 'number',
'timestamp' => 'datetime',
'float' => 'number',
];
if (array_key_exists($field['designType'], $designTypeComparison)) {
$field['designType'] = $designTypeComparison[$field['designType']];
}
// 是否开启了多选
$supportMultipleComparison = ['select', 'image', 'file', 'remoteSelect'];
if (in_array($field['designType'], $supportMultipleComparison)) {
$multiKey = $field['designType'] == 'remoteSelect' ? 'select-multi' : $field['designType'] . '-multi';
if (isset($field['form'][$multiKey]) && $field['form'][$multiKey]) {
$field['designType'] = $field['designType'] . 's';
}
}
}
public static function getTableColumnsDataType($column)
{
if (stripos($column['COLUMN_NAME'], 'id') !== false && stripos($column['EXTRA'], 'auto_increment') !== false) {
return 'pk';
} elseif ($column['COLUMN_NAME'] == 'weigh') {
return 'weigh';
} elseif (in_array($column['COLUMN_NAME'], ['createtime', 'updatetime', 'create_time', 'update_time'])) {
return 'timestamp';
}
foreach (self::$inputTypeRule as $item) {
$typeBool = true;
$suffixBool = true;
$columnTypeBool = true;
if (isset($item['type']) && $item['type'] && !in_array($column['DATA_TYPE'], $item['type'])) {
$typeBool = false;
}
if (isset($item['suffix']) && $item['suffix']) {
$suffixBool = self::isMatchSuffix($column['COLUMN_NAME'], $item['suffix']);
}
if (isset($item['column_type']) && $item['column_type'] && !in_array($column['COLUMN_TYPE'], $item['column_type'])) {
$columnTypeBool = false;
}
if ($typeBool && $suffixBool && $columnTypeBool) {
return $item['value'];
}
}
return 'string';
}
/**
* 判断是否符合指定后缀
*
* @param string $field 字段名称
* @param string|array $suffixArr 后缀
* @return bool
*/
protected static function isMatchSuffix(string $field, string|array $suffixArr): bool
{
$suffixArr = is_array($suffixArr) ? $suffixArr : explode(',', $suffixArr);
foreach ($suffixArr as $v) {
if (preg_match("/$v$/i", $field)) {
return true;
}
}
return false;
}
/**
* 创建菜单
* @throws Throwable
*/
public static function createMenu($webViewsDir, $tableComment): void
{
$menuName = self::getMenuName($webViewsDir);
if (AdminRule::where('name', $menuName)->value('id')) {
return;
}
// 组装权限节点数据
$menuChildren = self::$menuChildren;
foreach ($menuChildren as &$item) {
$item['name'] = $menuName . $item['name'];
}
// 组件路径
$componentPath = str_replace(['\\', 'web/src'], ['/', '/src'], $webViewsDir['views'] . '/' . 'index.vue');
// 菜单数组
$menus = [
'type' => 'menu',
'title' => $tableComment ?: $webViewsDir['originalLastName'],
'name' => $menuName,
'path' => $menuName,
'menu_type' => 'tab',
'keepalive' => 1,
'component' => $componentPath,
'children' => $menuChildren,
];
$paths = array_reverse($webViewsDir['path']);
foreach ($paths as $path) {
$menus = [
'type' => 'menu_dir',
'title' => $path,
'name' => $path,
'path' => $path,
'children' => [$menus],
];
}
// 创建菜单
Menu::create([$menus], 0, 'ignore');
}
public static function writeWebLangFile($langData, $webLangDir): void
{
foreach ($langData as $lang => $langDatum) {
$langTsContent = '';
foreach ($langDatum as $key => $item) {
$quote = self::getQuote($item);
$keyStr = self::formatObjectKey($key);
$langTsContent .= self::tab() . $keyStr . ": $quote$item$quote,\n";
}
$langTsContent = "export default {\n" . $langTsContent . "}\n";
self::writeFile(root_path() . $webLangDir[$lang] . '.ts', $langTsContent);
}
}
public static function writeFile($path, $content): bool|int
{
$path = Filesystem::fsFit($path);
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
return file_put_contents($path, $content);
}
public static function buildModelAppend($append): string
{
if (!$append) return '';
$append = self::buildFormatSimpleArray($append);
return "\n" . self::tab() . "// 追加属性" . "\n" . self::tab() . "protected \$append = $append;\n";
}
public static function buildModelFieldType(array $fieldType): string
{
if (!$fieldType) return '';
$maxStrLang = 0;
foreach ($fieldType as $key => $item) {
$strLang = strlen($key);
$maxStrLang = max($strLang, $maxStrLang);
}
$str = '';
foreach ($fieldType as $key => $item) {
$str .= self::tab(2) . "'$key'" . str_pad('=>', ($maxStrLang - strlen($key) + 3), ' ', STR_PAD_LEFT) . " '$item',\n";
}
return "\n" . self::tab() . "// 字段类型转换" . "\n" . self::tab() . "protected \$type = [\n" . rtrim($str, "\n") . "\n" . self::tab() . "];\n";
}
public static function writeModelFile(string $tablePk, array $fieldsMap, array $modelData, array $modelFile): void
{
if ($modelData['connection'] && $modelData['connection'] != config('database.default')) {
$modelData['connection'] = "\n" . self::tab() . "// 数据库连接配置标识\n" . self::tab() . 'protected $connection = ' . "'{$modelData['connection']}';\n";
} else {
$modelData['connection'] = '';
}
$modelData['pk'] = $tablePk == 'id' ? '' : "\n" . self::tab() . "// 表主键\n" . self::tab() . 'protected $pk = ' . "'$tablePk';\n";
$modelData['autoWriteTimestamp'] = array_key_exists(self::$createTimeField, $fieldsMap) || array_key_exists(self::$updateTimeField, $fieldsMap) ? 'true' : 'false';
if ($modelData['autoWriteTimestamp'] == 'true') {
$modelData['createTime'] = array_key_exists(self::$createTimeField, $fieldsMap) ? '' : "\n" . self::tab() . "protected \$createTime = false;";
$modelData['updateTime'] = array_key_exists(self::$updateTimeField, $fieldsMap) ? '' : "\n" . self::tab() . "protected \$updateTime = false;";
}
$modelMethodList = isset($modelData['relationMethodList']) ? array_merge($modelData['methods'], $modelData['relationMethodList']) : $modelData['methods'];
$modelData['methods'] = $modelMethodList ? "\n" . implode("\n", $modelMethodList) : '';
$modelData['append'] = self::buildModelAppend($modelData['append']);
$modelData['fieldType'] = self::buildModelFieldType($modelData['fieldType']);
// 生成雪花ID
if (isset($modelData['beforeInsertMixins']['snowflake'])) {
// beforeInsert 组装
$modelData['beforeInsert'] = Helper::assembleStub('mixins/model/beforeInsert', [
'setSnowFlakeIdCode' => $modelData['beforeInsertMixins']['snowflake']
]);
}
if ($modelData['afterInsert'] && $modelData['beforeInsert']) {
$modelData['afterInsert'] = "\n" . $modelData['afterInsert'];
}
$modelFileContent = self::assembleStub('mixins/model/model', $modelData);
self::writeFile($modelFile['parseFile'], $modelFileContent);
}
public static function writeControllerFile(array $controllerData, array $controllerFile): void
{
if (isset($controllerData['relationVisibleFieldList']) && $controllerData['relationVisibleFieldList']) {
$relationVisibleFields = '->visible([';
foreach ($controllerData['relationVisibleFieldList'] as $cKey => $controllerDatum) {
$relationVisibleFields .= "'$cKey' => ['" . implode("', '", $controllerDatum) . "'], ";
}
$relationVisibleFields = rtrim($relationVisibleFields, ', ');
$relationVisibleFields .= '])';
// 重写index
$controllerData['methods']['index'] = self::assembleStub('mixins/controller/index', [
'relationVisibleFields' => $relationVisibleFields
]);
$controllerData['use']['Throwable'] = "\nuse Throwable;";
unset($controllerData['relationVisibleFieldList']);
}
$controllerAttr = '';
foreach ($controllerData['attr'] as $key => $item) {
$attrType = '';
if (array_key_exists($key, self::$attrType['controller'])) {
$attrType = self::$attrType['controller'][$key];
}
if (is_array($item)) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = ['" . implode("', '", $item) . "'];\n";
} elseif ($item) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = '$item';\n";
}
}
$controllerData['attr'] = $controllerAttr;
$controllerData['initialize'] = self::assembleStub('mixins/controller/initialize', [
'modelNamespace' => $controllerData['modelNamespace'],
'modelName' => $controllerData['modelName'],
'filterRule' => $controllerData['filterRule'],
]);
$contentFileContent = self::assembleStub('mixins/controller/controller', $controllerData);
self::writeFile($controllerFile['parseFile'], $contentFileContent);
}
public static function writeFormFile($formVueData, $webViewsDir, $fields, $webTranslate): void
{
$fieldHtml = "\n";
$formVueData['bigDialog'] = $formVueData['bigDialog'] ? "\n" . self::tab(2) . 'width="70%"' : '';
foreach ($formVueData['formFields'] as $field) {
$fieldHtml .= self::tab(5) . "<FormItem";
foreach ($field as $key => $attr) {
if (is_array($attr)) {
$fieldHtml .= ' ' . $key . '="' . self::getJsonFromArray($attr) . '"';
} else {
$fieldHtml .= ' ' . $key . '="' . $attr . '"';
}
}
$fieldHtml .= " />\n";
}
$formVueData['formFields'] = rtrim($fieldHtml, "\n");
// 表单验证规则
foreach ($fields as $field) {
if (isset($field['form']['validator'])) {
foreach ($field['form']['validator'] as $item) {
$message = '';
if (isset($field['form']['validatorMsg']) && $field['form']['validatorMsg']) {
$message = ", message: '{$field['form']['validatorMsg']}'";
}
$formVueData['formValidatorRules'][$field['name']][] = "buildValidatorData({ name: '$item', title: t('$webTranslate{$field['name']}')$message })";
}
}
}
if ($formVueData['formValidatorRules']) {
$formVueData['imports'][] = "import { buildValidatorData } from '/@/utils/validate'";
}
$formVueData['importExpand'] = self::buildImportExpand($formVueData['imports']);
$formVueData['formItemRules'] = self::buildFormValidatorRules($formVueData['formValidatorRules']);
$formVueContent = self::assembleStub('html/form', $formVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'popupForm.vue', $formVueContent);
}
public static function buildImportExpand(array $imports): string
{
$importExpand = '';
foreach ($imports as $import) {
$importExpand .= "\n$import";
}
return $importExpand;
}
public static function buildFormValidatorRules(array $formValidatorRules): string
{
$rulesHtml = "";
foreach ($formValidatorRules as $key => $formItemRule) {
$rulesArrHtml = '';
foreach ($formItemRule as $item) {
$rulesArrHtml .= $item . ', ';
}
$rulesHtml .= self::tab() . $key . ': [' . rtrim($rulesArrHtml, ', ') . "],\n";
}
return $rulesHtml ? "\n" . $rulesHtml : '';
}
public static function writeIndexFile($indexVueData, $webViewsDir, $controllerFile): void
{
$indexVueData['optButtons'] = self::buildSimpleArray($indexVueData['optButtons']);
$indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems']);
$indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn']);
$indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn']);
$controllerFile['path'][] = $controllerFile['originalLastName'];
$indexVueData['controllerUrl'] = '\'/admin/' . ($controllerFile['path'] ? implode('.', $controllerFile['path']) : '') . '/\'';
$indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName'];
$indexVueContent = self::assembleStub('html/index', $indexVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent);
}
public static function buildTableColumn($tableColumnList): string
{
$columnJson = '';
$emptyUnset = ['comSearchInputAttr', 'replaceValue', 'custom'];
foreach ($tableColumnList as $column) {
foreach ($emptyUnset as $unsetKey) {
if (empty($column[$unsetKey])) {
unset($column[$unsetKey]);
}
}
$columnJson .= self::tab(3) . '{';
foreach ($column as $key => $item) {
$columnJson .= self::buildTableColumnKey($key, $item);
}
$columnJson = rtrim($columnJson, ',');
$columnJson .= ' }' . ",\n";
}
return rtrim($columnJson, "\n");
}
public static function buildTableColumnKey($key, $item): string
{
$key = self::formatObjectKey($key);
if (is_array($item)) {
$itemJson = ' ' . $key . ': {';
foreach ($item as $ik => $iItem) {
$itemJson .= self::buildTableColumnKey($ik, $iItem);
}
$itemJson = rtrim($itemJson, ',');
$itemJson .= ' },';
} elseif ($item === 'false' || $item === 'true') {
$itemJson = ' ' . $key . ': ' . $item . ',';
} elseif (in_array($key, ['label', 'width', 'buttons'], true) || str_starts_with($item, "t('") || str_starts_with($item, "t(\"")) {
$itemJson = ' ' . $key . ': ' . $item . ',';
} else {
$itemJson = ' ' . $key . ': \'' . $item . '\',';
}
return $itemJson;
}
public static function formatObjectKey(string $keyName): string
{
if (preg_match("/^[a-zA-Z_][a-zA-Z0-9_]+$/", $keyName)) {
return $keyName;
} else {
$quote = self::getQuote($keyName);
return "$quote$keyName$quote";
}
}
public static function getQuote(string $value): string
{
return stripos($value, "'") === false ? "'" : '"';
}
public static function buildFormatSimpleArray($arr, int $tab = 2): string
{
if (!$arr) return '[]';
$str = '[' . PHP_EOL;
foreach ($arr as $item) {
if ($item == 'undefined' || $item == 'false' || is_numeric($item)) {
$str .= self::tab($tab) . $item . ',' . PHP_EOL;
} else {
$quote = self::getQuote($item);
$str .= self::tab($tab) . "$quote$item$quote," . PHP_EOL;
}
}
return $str . self::tab($tab - 1) . ']';
}
public static function buildSimpleArray($arr): string
{
if (!$arr) return '[]';
$str = '';
foreach ($arr as $item) {
if ($item == 'undefined' || $item == 'false' || is_numeric($item)) {
$str .= $item . ', ';
} else {
$quote = self::getQuote($item);
$str .= "$quote$item$quote, ";
}
}
return '[' . rtrim($str, ", ") . ']';
}
public static function buildDefaultOrder(string $field, string $type): string
{
if ($field && $type) {
$defaultOrderStub = [
'prop' => $field,
'order' => $type,
];
$defaultOrderStub = self::getJsonFromArray($defaultOrderStub);
if ($defaultOrderStub) {
return "\n" . self::tab(2) . "defaultOrder: " . $defaultOrderStub . ',';
}
}
return '';
}
public static function getJsonFromArray($arr)
{
if (is_array($arr)) {
$jsonStr = '';
foreach ($arr as $key => $item) {
$keyStr = ' ' . self::formatObjectKey($key) . ': ';
if (is_array($item)) {
$jsonStr .= $keyStr . self::getJsonFromArray($item) . ',';
} elseif ($item === 'false' || $item === 'true') {
$jsonStr .= $keyStr . ($item === 'false' ? 'false' : 'true') . ',';
} elseif ($item === null) {
$jsonStr .= $keyStr . 'null,';
} elseif (str_starts_with($item, "t('") || str_starts_with($item, "t(\"") || $item == '[]' || in_array(gettype($item), ['integer', 'double'])) {
$jsonStr .= $keyStr . $item . ',';
} elseif (isset($item[0]) && $item[0] == '[' && str_ends_with($item, ']')) {
$jsonStr .= $keyStr . $item . ',';
} else {
$quote = self::getQuote($item);
$jsonStr .= $keyStr . "$quote$item$quote,";
}
}
return $jsonStr ? '{' . rtrim($jsonStr, ',') . ' }' : '{}';
} else {
return $arr;
}
}
}

View File

@@ -0,0 +1,63 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-item、FormItem、ba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"{%bigDialog%}
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '':'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>{%formFields%}
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'{%importExpand%}
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({{%formItemRules%}})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽,甚至公共搜索也可以使用具名插槽渲染,参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('{%webTranslate%}quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
<!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: '{%componentName%}',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons({%optButtons%})
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi({%controllerUrl%}),
{
pk: '{%tablePk%}',
column: [
{%tableColumn%}
],
dblClickNotEditColumn: {%dblClickNotEditColumn%},{%defaultOrder%}
},
{
defaultItems: {%defaultItems%},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,24 @@
<?php
namespace {%namespace%};
{%use%}
use app\common\controller\Backend;
/**
* {%tableComment%}
*/
class {%className%} extends Backend
{
/**
* {%modelName%}模型对象
* @var object
* @phpstan-var \{%modelNamespace%}\{%modelName%}
*/
protected object $model;
{%attr%}{%initialize%}
{%methods%}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,32 @@
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request->param('select')) {
$this->select();
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
{%relationVisibleFields%}
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}

View File

@@ -0,0 +1,6 @@
public function initialize(): void
{
parent::initialize();
$this->model = new \{%modelNamespace%}\{%modelName%}();{%filterRule%}
}

View File

@@ -0,0 +1,12 @@
protected static function onAfterInsert($model): void
{
if (is_null($model->{%field%})) {
$pk = $model->getPk();
if (strlen($model[$pk]) >= 19) {
$model->where($pk, $model[$pk])->update(['{%field%}' => $model->count()]);
} else {
$model->where($pk, $model[$pk])->update(['{%field%}' => $model[$pk]]);
}
}
}

View File

@@ -0,0 +1,5 @@
protected static function onBeforeInsert($model): void
{
{%setSnowFlakeIdCode%}
}

View File

@@ -0,0 +1,5 @@
public function {%relationMethod%}(): \think\model\relation\BelongsTo
{
return $this->{%relationMode%}({%relationClassName%}, '{%relationForeignKey%}', '{%relationPrimaryKey%}');
}

View File

@@ -0,0 +1,7 @@
public function get{%field%}Attr($value, $row): string
{
if ($row['{%originalFieldName%}'] === '' || $row['{%originalFieldName%}'] === null) return '';
$cityNames = \think\facade\Db::name('area')->whereIn('id', $row['{%originalFieldName%}'])->column('name');
return $cityNames ? implode(',', $cityNames) : '';
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): ?float
{
return is_null($value) ? null : (float)$value;
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): string
{
return !$value ? '' : htmlspecialchars_decode($value);
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): array
{
return !$value ? [] : json_decode($value, true);
}

View File

@@ -0,0 +1,7 @@
public function get{%field%}Attr($value, $row): array
{
return [
'{%labelFieldName%}' => {%className%}::whereIn('{%primaryKey%}', $row['{%foreignKey%}'])->column('{%labelFieldName%}'),
];
}

View File

@@ -0,0 +1,5 @@
public function get{%field%}Attr($value): string
{
return (string)$value;
}

View File

@@ -0,0 +1,9 @@
public function get{%field%}Attr($value): array
{
if ($value === '' || $value === null) return [];
if (!is_array($value)) {
return explode(',', $value);
}
return $value;
}

View File

@@ -0,0 +1,2 @@
$pk = $model->getPk();
$model->$pk = \app\common\library\SnowFlake::generateParticle();

View File

@@ -0,0 +1,18 @@
<?php
namespace {%namespace%};
use think\Model;
/**
* {%className%}
*/
class {%className%} extends Model
{{%connection%}{%pk%}
// 表名
protected $name = '{%name%}';
// 自动写入时间戳字段
protected $autoWriteTimestamp = {%autoWriteTimestamp%};{%createTime%}{%updateTime%}
{%append%}{%fieldType%}{%beforeInsert%}{%afterInsert%}{%methods%}
}

View File

@@ -0,0 +1,5 @@
public function set{%field%}Attr($value): string
{
return is_array($value) ? implode(',', $value) : $value;
}

View File

@@ -0,0 +1,5 @@
public function set{%field%}Attr($value): ?string
{
return $value ? date('H:i:s', strtotime($value)) : $value;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace {%namespace%};
use think\Validate;
class {%className%} extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -0,0 +1,971 @@
<?php
namespace app\admin\library\module;
use Throwable;
use ba\Version;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use FilesystemIterator;
use think\facade\Config;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* 模块管理类
*/
class Manage
{
public const UNINSTALLED = 0;
public const INSTALLED = 1;
public const WAIT_INSTALL = 2;
public const CONFLICT_PENDING = 3;
public const DEPENDENT_WAIT_INSTALL = 4;
public const DIRECTORY_OCCUPIED = 5;
public const DISABLE = 6;
/**
* @var ?Manage 对象实例
*/
protected static ?Manage $instance = null;
/**
* @var string 安装目录
*/
protected string $installDir;
/**
* @var string 备份目录
*/
protected string $backupsDir;
/**
* @var string 模板唯一标识
*/
protected string $uid;
/**
* @var string 模板根目录
*/
protected string $modulesDir;
/**
* 初始化
* @access public
* @param string $uid
* @return Manage
*/
public static function instance(string $uid = ''): Manage
{
if (is_null(self::$instance)) {
self::$instance = new static($uid);
}
return self::$instance->setModuleUid($uid);
}
public function __construct(string $uid)
{
$this->installDir = root_path() . 'modules' . DIRECTORY_SEPARATOR;
$this->backupsDir = $this->installDir . 'backups' . DIRECTORY_SEPARATOR;
if (!is_dir($this->installDir)) {
mkdir($this->installDir, 0755, true);
}
if (!is_dir($this->backupsDir)) {
mkdir($this->backupsDir, 0755, true);
}
if ($uid) {
$this->setModuleUid($uid);
}
}
public function getInstallState()
{
if (!is_dir($this->modulesDir)) {
return self::UNINSTALLED;
}
$info = $this->getInfo();
if ($info && isset($info['state'])) {
return $info['state'];
}
// 目录已存在,但非正常的模块
return Filesystem::dirIsEmpty($this->modulesDir) ? self::UNINSTALLED : self::DIRECTORY_OCCUPIED;
}
/**
* 下载模块文件
* @return string 已下载文件路径
* @throws Throwable
*/
public function download(): string
{
$token = request()->param("token/s");
$version = request()->param('version/s');
$orderId = request()->param("orderId/d");
if (!$orderId) {
throw new Exception('Order not found');
}
// 下载 - 系统版本号要求、已安装模块的互斥和依赖检测
$zipFile = Server::download($this->uid, $this->installDir, [
'version' => $version,
'orderId' => $orderId,
'nuxtVersion' => Server::getNuxtVersion(),
'sysVersion' => Config::get('buildadmin.version'),
'installed' => Server::getInstalledIds($this->installDir),
'ba-user-token' => $token,
]);
// 删除旧版本代码
Filesystem::delDir($this->modulesDir);
// 解压
Filesystem::unzip($zipFile);
// 删除下载的zip
@unlink($zipFile);
// 检查是否完整
$this->checkPackage();
// 设置为待安装状态
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $zipFile;
}
/**
* 上传安装
* @param string $file 已经上传完成的文件
* @return array 模块的基本信息
* @throws Throwable
*/
public function upload(string $token, string $file): array
{
$file = Filesystem::fsFit(root_path() . 'public' . $file);
if (!is_file($file)) {
// 包未找到
throw new Exception('Zip file not found');
}
$copyTo = $this->installDir . 'uploadTemp' . date('YmdHis') . '.zip';
copy($file, $copyTo);
// 解压
$copyToDir = Filesystem::unzip($copyTo);
$copyToDir .= DIRECTORY_SEPARATOR;
// 删除zip
@unlink($file);
@unlink($copyTo);
// 读取ini
$info = Server::getIni($copyToDir);
if (empty($info['uid'])) {
Filesystem::delDir($copyToDir);
// 基本配置不完整
throw new Exception('Basic configuration of the Module is incomplete');
}
$this->setModuleUid($info['uid']);
$upgrade = false;
if (is_dir($this->modulesDir)) {
$oldInfo = $this->getInfo();
if ($oldInfo && !empty($oldInfo['uid'])) {
$versions = explode('.', $oldInfo['version']);
if (isset($versions[2])) {
$versions[2]++;
}
$nextVersion = implode('.', $versions);
$upgrade = Version::compare($nextVersion, $info['version']);
if ($upgrade) {
// 检查模块是否已禁用
if (!in_array($oldInfo['state'], [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
Filesystem::delDir($copyToDir);
throw new Exception('Please disable the module before updating');
}
} else {
Filesystem::delDir($copyToDir);
// 模块已经存在
throw new Exception('Module already exists');
}
}
if (!Filesystem::dirIsEmpty($this->modulesDir) && !$upgrade) {
Filesystem::delDir($copyToDir);
// 模块目录被占
throw new Exception('The directory required by the module is occupied');
}
}
// 安装预检 - 系统版本号要求、已安装模块的互斥和依赖检测
try {
Server::installPreCheck([
'uid' => $info['uid'],
'version' => $info['version'],
'sysVersion' => Config::get('buildadmin.version'),
'nuxtVersion' => Server::getNuxtVersion(),
'moduleVersion' => $info['version'],
'ba-user-token' => $token,
'installed' => Server::getInstalledIds($this->installDir),
'server' => 1,
]);
} catch (Throwable $e) {
Filesystem::delDir($copyToDir);
throw $e;
}
$newInfo = ['state' => self::WAIT_INSTALL];
if ($upgrade) {
$info['update'] = 1;
// 清理旧版本代码
Filesystem::delDir($this->modulesDir);
}
// 放置新模块
rename($copyToDir, $this->modulesDir);
// 检查新包是否完整
$this->checkPackage();
// 设置为待安装状态
$this->setInfo($newInfo);
return $info;
}
/**
* 安装模块
* @return array 模块基本信息
* @throws Throwable
*/
public function install(bool $update): array
{
$state = $this->getInstallState();
if ($update) {
if (!in_array($state, [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
throw new Exception('Please disable the module before updating');
}
/**
* self::WAIT_INSTALL=待安装
* 即本地上传文件进行升级的安装流程,文件上传成功后将被标记为待安装,免去此处的下载
*/
if ($state == self::UNINSTALLED || $state != self::WAIT_INSTALL) {
$this->download();
}
} else {
if ($state == self::INSTALLED || $state == self::DIRECTORY_OCCUPIED || $state == self::DISABLE) {
throw new Exception('Module already exists');
}
if ($state == self::UNINSTALLED) {
$this->download();
}
}
// 导入sql
Server::importSql($this->modulesDir);
// 如果是更新,先执行更新脚本
$info = $this->getInfo();
if ($update) {
$info['update'] = 1;
Server::execEvent($this->uid, 'update');
}
// 执行安装脚本 - 排除冲突处理时会重复提交至此的请求
$extend = request()->post('extend/a', []);
if (!isset($extend['conflictHandle'])) {
Server::execEvent($this->uid, 'install');
}
// 启用插件
$this->enable('install');
return $info;
}
/**
* 卸载
* @throws Throwable
*/
public function uninstall(): void
{
$info = $this->getInfo();
if ($info['state'] != self::DISABLE) {
throw new Exception('Please disable the module first', 0, [
'uid' => $this->uid,
]);
}
// 执行卸载脚本
Server::execEvent($this->uid, 'uninstall');
Filesystem::delDir($this->modulesDir);
}
/**
* 修改模块状态
* @param bool $state 新状态
* @return array 模块基本信息
* @throws Throwable
*/
public function changeState(bool $state): array
{
$info = $this->getInfo();
if (!$state) {
$canDisable = [
self::INSTALLED,
self::CONFLICT_PENDING,
self::DEPENDENT_WAIT_INSTALL,
];
if (!in_array($info['state'], $canDisable)) {
throw new Exception('The current state of the module cannot be set to disabled', 0, [
'uid' => $this->uid,
'state' => $info['state'],
]);
}
return $this->disable();
}
if ($info['state'] != self::DISABLE) {
throw new Exception('The current state of the module cannot be set to enabled', 0, [
'uid' => $this->uid,
'state' => $info['state'],
]);
}
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $info;
}
/**
* 启用
* @param string $trigger 触发启用的标志,比如:install=安装
* @throws Throwable
*/
public function enable(string $trigger): void
{
// 安装 WebBootstrap
Server::installWebBootstrap($this->uid, $this->modulesDir);
// 建立 .runtime
Server::createRuntime($this->modulesDir);
// 冲突检查
$this->conflictHandle($trigger);
// 执行启用脚本
Server::execEvent($this->uid, 'enable');
$this->dependUpdateHandle();
}
/**
* 禁用
* @return array 模块基本信息
* @throws Throwable
*/
public function disable(): array
{
$update = request()->post("update/b", false);
$confirmConflict = request()->post("confirmConflict/b", false);
$dependConflictSolution = request()->post("dependConflictSolution/a", []);
$info = $this->getInfo();
$zipFile = $this->backupsDir . $this->uid . '-install.zip';
$zipDir = false;
if (is_file($zipFile)) {
try {
$zipDir = $this->backupsDir . $this->uid . '-install' . DIRECTORY_SEPARATOR;
Filesystem::unzip($zipFile, $zipDir);
} catch (Exception) {
// skip
}
}
$conflictFile = Server::getFileList($this->modulesDir, true);
$dependConflict = $this->disableDependCheck();
if (($conflictFile || !self::isEmptyArray($dependConflict)) && !$confirmConflict) {
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
foreach ($item as $depend => $v) {
$dependConflictTemp[] = [
'env' => $env,
'depend' => $depend,
'dependTitle' => $depend . ' ' . $v,
'solution' => 'delete',
];
}
}
throw new Exception('Module file updated', -1, [
'uid' => $this->uid,
'conflictFile' => $conflictFile,
'dependConflict' => $dependConflictTemp,
]);
}
// 执行禁用脚本
Server::execEvent($this->uid, 'disable', ['update' => $update]);
// 是否需要备份依赖?
$delNpmDepend = false;
$delNuxtNpmDepend = false;
$delComposerDepend = false;
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
if ($env == 'require' || $env == 'require-dev') {
$delComposerDepend = true;
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$delNpmDepend = true;
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$delNuxtNpmDepend = true;
}
}
// 备份
$dependJsonFiles = [
'composer' => 'composer.json',
'webPackage' => 'web' . DIRECTORY_SEPARATOR . 'package.json',
'webNuxtPackage' => 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json',
];
$dependWaitInstall = [];
if ($delComposerDepend) {
$conflictFile[] = $dependJsonFiles['composer'];
$dependWaitInstall[] = [
'pm' => false,
'command' => 'composer.update',
'type' => 'composer_dependent_wait_install',
];
}
if ($delNpmDepend) {
$conflictFile[] = $dependJsonFiles['webPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'web-install',
'type' => 'npm_dependent_wait_install',
];
}
if ($delNuxtNpmDepend) {
$conflictFile[] = $dependJsonFiles['webNuxtPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'nuxt-install',
'type' => 'nuxt_npm_dependent_wait_install',
];
}
if ($conflictFile) {
// 如果是模块自带文件需要备份,加上模块目录前缀
$overwriteDir = Server::getOverwriteDir();
foreach ($conflictFile as $key => $item) {
$paths = explode(DIRECTORY_SEPARATOR, $item);
if (in_array($paths[0], $overwriteDir) || in_array($item, $dependJsonFiles)) {
$conflictFile[$key] = $item;
} else {
$conflictFile[$key] = Filesystem::fsFit(str_replace(root_path(), '', $this->modulesDir . $item));
}
if (!is_file(root_path() . $conflictFile[$key])) {
unset($conflictFile[$key]);
}
}
$backupsZip = $this->backupsDir . $this->uid . '-disable-' . date('YmdHis') . '.zip';
Filesystem::zip($conflictFile, $backupsZip);
}
// 删除依赖
$serverDepend = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
$dev = !(stripos($env, 'dev') === false);
if ($env == 'require' || $env == 'require-dev') {
$serverDepend->removeDepends($depends, $dev);
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$webDep->removeDepends($depends, $dev);
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$webNuxtDep->removeDepends($depends, $dev);
}
}
// 删除 composer.json 中的 config
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDepend->removeComposerConfig($composerConfig);
}
// 配置了不删除的文件
$protectedFiles = Server::getConfig($this->modulesDir, 'protectedFiles');
foreach ($protectedFiles as &$protectedFile) {
$protectedFile = Filesystem::fsFit(root_path() . $protectedFile);
}
// 模块文件列表
$moduleFile = Server::getFileList($this->modulesDir);
// 删除模块文件
foreach ($moduleFile as &$file) {
// 纯净模式下,模块文件将被删除,此处直接检查模块目录中是否有该文件并恢复(不检查是否开启纯净模式,因为开关可能被调整)
$moduleFilePath = Filesystem::fsFit($this->modulesDir . $file);
$file = Filesystem::fsFit(root_path() . $file);
if (!file_exists($file)) continue;
if (!file_exists($moduleFilePath)) {
if (!is_dir(dirname($moduleFilePath))) {
mkdir(dirname($moduleFilePath), 0755, true);
}
copy($file, $moduleFilePath);
}
if (in_array($file, $protectedFiles)) {
continue;
}
if (file_exists($file)) {
unlink($file);
}
Filesystem::delEmptyDir(dirname($file));
}
// 恢复备份文件
if ($zipDir) {
$unrecoverableFiles = [
Filesystem::fsFit(root_path() . 'composer.json'),
Filesystem::fsFit(root_path() . 'web/package.json'),
Filesystem::fsFit(root_path() . 'web-nuxt/package.json'),
];
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($zipDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$backupsFile = Filesystem::fsFit(root_path() . str_replace($zipDir, '', $item->getPathname()));
// 在模块包中,同时不在 $protectedFiles 列表的文件不恢复,这些文件可能是模块升级时备份的
if (in_array($backupsFile, $moduleFile) && !in_array($backupsFile, $protectedFiles)) {
continue;
}
if ($item->isDir()) {
if (!is_dir($backupsFile)) {
mkdir($backupsFile, 0755, true);
}
} elseif (!in_array($backupsFile, $unrecoverableFiles)) {
copy($item, $backupsFile);
}
}
}
// 删除解压后的备份文件
Filesystem::delDir($zipDir);
// 卸载 WebBootstrap
Server::uninstallWebBootstrap($this->uid);
$this->setInfo([
'state' => self::DISABLE,
]);
if ($update) {
throw new Exception('update', -3, [
'uid' => $this->uid,
]);
}
if ($dependWaitInstall) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'wait_install' => $dependWaitInstall,
]);
}
return $info;
}
/**
* 处理依赖和文件冲突,并完成与前端的冲突处理交互
* @throws Throwable
*/
public function conflictHandle(string $trigger): bool
{
$info = $this->getInfo();
if ($info['state'] != self::WAIT_INSTALL && $info['state'] != self::CONFLICT_PENDING) {
return false;
}
$fileConflict = Server::getFileList($this->modulesDir, true);// 文件冲突
$dependConflict = Server::dependConflictCheck($this->modulesDir);// 依赖冲突
$installFiles = Server::getFileList($this->modulesDir);// 待安装文件
$depends = Server::getDepend($this->modulesDir);// 待安装依赖
$coverFiles = [];// 要覆盖的文件-备份
$discardFiles = [];// 抛弃的文件-复制时不覆盖
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
if ($fileConflict || !self::isEmptyArray($dependConflict)) {
$extend = request()->post('extend/a', []);
if (!$extend) {
// 发现冲突->手动处理->转换为方便前端使用的格式
$fileConflictTemp = [];
foreach ($fileConflict as $key => $item) {
$fileConflictTemp[$key] = [
'newFile' => $this->uid . DIRECTORY_SEPARATOR . $item,
'oldFile' => $item,
'solution' => 'cover',
];
}
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
$dev = !(stripos($env, 'dev') === false);
foreach ($item as $depend => $v) {
$oldDepend = '';
if (in_array($env, ['require', 'require-dev'])) {
$oldDepend = $depend . ' ' . $serverDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['dependencies', 'devDependencies'])) {
$oldDepend = $depend . ' ' . $webDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['nuxtDependencies', 'nuxtDevDependencies'])) {
$oldDepend = $depend . ' ' . $webNuxtDep->hasDepend($depend, $dev);
}
$dependConflictTemp[] = [
'env' => $env,
'newDepend' => $depend . ' ' . $v,
'oldDepend' => $oldDepend,
'depend' => $depend,
'solution' => 'cover',
];
}
}
$this->setInfo([
'state' => self::CONFLICT_PENDING,
]);
throw new Exception('Module file conflicts', -1, [
'fileConflict' => $fileConflictTemp,
'dependConflict' => $dependConflictTemp,
'uid' => $this->uid,
'state' => self::CONFLICT_PENDING,
]);
}
// 处理冲突
if ($fileConflict && isset($extend['fileConflict'])) {
foreach ($installFiles as $ikey => $installFile) {
if (isset($extend['fileConflict'][$installFile])) {
if ($extend['fileConflict'][$installFile] == 'discard') {
$discardFiles[] = $installFile;
unset($installFiles[$ikey]);
} else {
$coverFiles[] = $installFile;
}
}
}
}
if (!self::isEmptyArray($dependConflict) && isset($extend['dependConflict'])) {
foreach ($depends as $fKey => $fItem) {
foreach ($fItem as $cKey => $cItem) {
if (isset($extend['dependConflict'][$fKey][$cKey])) {
if ($extend['dependConflict'][$fKey][$cKey] == 'discard') {
unset($depends[$fKey][$cKey]);
}
}
}
}
}
}
// 如果有依赖更新,增加要备份的文件
if ($depends) {
foreach ($depends as $key => $item) {
if (!$item) {
continue;
}
if ($key == 'require' || $key == 'require-dev') {
$coverFiles[] = 'composer.json';
continue;
}
if ($key == 'dependencies' || $key == 'devDependencies') {
$coverFiles[] = 'web' . DIRECTORY_SEPARATOR . 'package.json';
}
if ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
$coverFiles[] = 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json';
}
}
}
// 备份将被覆盖的文件
if ($coverFiles) {
$backupsZip = $trigger == 'install' ? $this->backupsDir . $this->uid . '-install.zip' : $this->backupsDir . $this->uid . '-cover-' . date('YmdHis') . '.zip';
Filesystem::zip($coverFiles, $backupsZip);
}
if ($depends) {
$npm = false;
$composer = false;
$nuxtNpm = false;
// composer config 更新
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDep->setComposerConfig($composerConfig);
}
foreach ($depends as $key => $item) {
if (!$item) {
continue;
}
if ($key == 'require') {
$composer = true;
$serverDep->addDepends($item, false, true);
} elseif ($key == 'require-dev') {
$composer = true;
$serverDep->addDepends($item, true, true);
} elseif ($key == 'dependencies') {
$npm = true;
$webDep->addDepends($item, false, true);
} elseif ($key == 'devDependencies') {
$npm = true;
$webDep->addDepends($item, true, true);
} elseif ($key == 'nuxtDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, false, true);
} elseif ($key == 'nuxtDevDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, true, true);
}
}
if ($npm) {
$info['npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($composer) {
$info['composer_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($nuxtNpm) {
$info['nuxt_npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($info['state'] != self::DEPENDENT_WAIT_INSTALL) {
// 无冲突
$this->setInfo([
'state' => self::INSTALLED,
]);
} else {
$this->setInfo([], $info);
}
} else {
// 无冲突
$this->setInfo([
'state' => self::INSTALLED,
]);
}
// 复制文件
$overwriteDir = Server::getOverwriteDir();
foreach ($overwriteDir as $dirItem) {
$baseDir = $this->modulesDir . $dirItem;
$destDir = root_path() . $dirItem;
if (!is_dir($baseDir)) {
continue;
}
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$destDirItem = Filesystem::fsFit($destDir . DIRECTORY_SEPARATOR . str_replace($baseDir, '', $item->getPathname()));
if ($item->isDir()) {
Filesystem::mkdir($destDirItem);
} elseif (!in_array(str_replace(root_path(), '', $destDirItem), $discardFiles)) {
Filesystem::mkdir(dirname($destDirItem));
copy($item, $destDirItem);
}
}
// 纯净模式
if (Config::get('buildadmin.module_pure_install')) {
Filesystem::delDir($baseDir);
}
}
return true;
}
/**
* 依赖升级处理
* @throws Throwable
*/
public function dependUpdateHandle(): void
{
$info = $this->getInfo();
if ($info['state'] == self::DEPENDENT_WAIT_INSTALL) {
$waitInstall = [];
if (isset($info['composer_dependent_wait_install'])) {
$waitInstall[] = 'composer_dependent_wait_install';
}
if (isset($info['npm_dependent_wait_install'])) {
$waitInstall[] = 'npm_dependent_wait_install';
}
if (isset($info['nuxt_npm_dependent_wait_install'])) {
$waitInstall[] = 'nuxt_npm_dependent_wait_install';
}
if ($waitInstall) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'state' => self::DEPENDENT_WAIT_INSTALL,
'wait_install' => $waitInstall,
]);
} else {
$this->setInfo([
'state' => self::INSTALLED,
]);
}
}
}
/**
* 依赖安装完成标记
* @throws Throwable
*/
public function dependentInstallComplete(string $type): void
{
$info = $this->getInfo();
if ($info['state'] == self::DEPENDENT_WAIT_INSTALL) {
if ($type == 'npm') {
unset($info['npm_dependent_wait_install']);
}
if ($type == 'nuxt_npm') {
unset($info['nuxt_npm_dependent_wait_install']);
}
if ($type == 'composer') {
unset($info['composer_dependent_wait_install']);
}
if ($type == 'all') {
unset($info['npm_dependent_wait_install'], $info['composer_dependent_wait_install'], $info['nuxt_npm_dependent_wait_install']);
}
if (!isset($info['npm_dependent_wait_install']) && !isset($info['composer_dependent_wait_install']) && !isset($info['nuxt_npm_dependent_wait_install'])) {
$info['state'] = self::INSTALLED;
}
$this->setInfo([], $info);
}
}
/**
* 禁用依赖检查
* @throws Throwable
*/
public function disableDependCheck(): array
{
// 读取模块所有依赖
$depend = Server::getDepend($this->modulesDir);
if (!$depend) {
return [];
}
// 读取所有依赖中,系统上已经安装的依赖
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($depend as $key => $depends) {
$dev = !(stripos($key, 'dev') === false);
if ($key == 'require' || $key == 'require-dev') {
foreach ($depends as $dependKey => $dependItem) {
if (!$serverDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'dependencies' || $key == 'devDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webNuxtDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
}
}
return $depend;
}
/**
* 检查包是否完整
* @throws Throwable
*/
public function checkPackage(): bool
{
if (!is_dir($this->modulesDir)) {
throw new Exception('Module package file does not exist');
}
$info = $this->getInfo();
$infoKeys = ['uid', 'title', 'intro', 'author', 'version', 'state'];
foreach ($infoKeys as $value) {
if (!array_key_exists($value, $info)) {
Filesystem::delDir($this->modulesDir);
throw new Exception('Basic configuration of the Module is incomplete');
}
}
return true;
}
/**
* 获取模块基本信息
*/
public function getInfo(): array
{
return Server::getIni($this->modulesDir);
}
/**
* 设置模块基本信息
* @throws Throwable
*/
public function setInfo(array $kv = [], array $arr = []): bool
{
if ($kv) {
$info = $this->getInfo();
foreach ($kv as $k => $v) {
$info[$k] = $v;
}
return Server::setIni($this->modulesDir, $info);
} elseif ($arr) {
return Server::setIni($this->modulesDir, $arr);
}
throw new Exception('Parameter error');
}
/**
* 检查多维数组是否全部为空
*/
public static function isEmptyArray($arr): bool
{
foreach ($arr as $item) {
if (is_array($item)) {
$empty = self::isEmptyArray($item);
if (!$empty) return false;
} elseif ($item) {
return false;
}
}
return true;
}
public function setModuleUid(string $uid): static
{
$this->uid = $uid;
$this->modulesDir = $this->installDir . $uid . DIRECTORY_SEPARATOR;
return $this;
}
}

View File

@@ -0,0 +1,590 @@
<?php
namespace app\admin\library\module;
use Throwable;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use think\facade\Db;
use FilesystemIterator;
use think\facade\Config;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use think\db\exception\PDOException;
use app\admin\library\crud\Helper;
use GuzzleHttp\Exception\TransferException;
/**
* 模块服务类
*/
class Server
{
private static string $apiBaseUrl = '/api/v7.store/';
/**
* 下载
* @throws Throwable
*/
public static function download(string $uid, string $dir, array $extend = []): string
{
$tmpFile = $dir . $uid . ".zip";
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'download', ['query' => array_merge(['uid' => $uid, 'server' => 1], $extend)]);
$body = $response->getBody();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false) {
throw new Exception('package download failed', 0);
}
if (str_starts_with($content, '{')) {
$json = (array)json_decode($content, true);
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
} catch (TransferException $e) {
throw new Exception('package download failed', 0, ['msg' => $e->getMessage()]);
}
if ($write = fopen($tmpFile, 'w')) {
fwrite($write, $content);
fclose($write);
return $tmpFile;
}
throw new Exception("No permission to write temporary files");
}
/**
* 安装预检
* @throws Throwable
*/
public static function installPreCheck(array $query = []): bool
{
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'preCheck', ['query' => $query]);
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
return true;
}
if (str_starts_with($content, '{')) {
$json = json_decode($content, true);
if ($json && $json['code'] == 0) {
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
}
} catch (TransferException $e) {
throw new Exception('package check failed', 0, ['msg' => $e->getMessage()]);
}
return true;
}
public static function getConfig(string $dir, $key = ''): array
{
$configFile = $dir . 'config.json';
if (!is_dir($dir) || !is_file($configFile)) {
return [];
}
$configContent = @file_get_contents($configFile);
$configContent = json_decode($configContent, true);
if (!$configContent) {
return [];
}
if ($key) {
return $configContent[$key] ?? [];
}
return $configContent;
}
public static function getDepend(string $dir, string $key = ''): array
{
if ($key) {
return self::getConfig($dir, $key);
}
$configContent = self::getConfig($dir);
$dependKey = ['require', 'require-dev', 'dependencies', 'devDependencies', 'nuxtDependencies', 'nuxtDevDependencies'];
$dependArray = [];
foreach ($dependKey as $item) {
if (array_key_exists($item, $configContent) && $configContent[$item]) {
$dependArray[$item] = $configContent[$item];
}
}
return $dependArray;
}
/**
* 依赖冲突检查
* @throws Throwable
*/
public static function dependConflictCheck(string $dir): array
{
$depend = self::getDepend($dir);
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
$sysDepend = [
'require' => $serverDep->getDepends(),
'require-dev' => $serverDep->getDepends(true),
'dependencies' => $webDep->getDepends(),
'devDependencies' => $webDep->getDepends(true),
'nuxtDependencies' => $webNuxtDep->getDepends(),
'nuxtDevDependencies' => $webNuxtDep->getDepends(true),
];
$conflict = [];
foreach ($depend as $key => $item) {
$conflict[$key] = array_uintersect_assoc($item, $sysDepend[$key], function ($a, $b) {
return $a == $b ? -1 : 0;
});
}
return $conflict;
}
/**
* 获取模块[冲突]文件列表
* @param string $dir 模块目录
* @param bool $onlyConflict 是否只获取冲突文件
*/
public static function getFileList(string $dir, bool $onlyConflict = false): array
{
if (!is_dir($dir)) {
return [];
}
$fileList = [];
$overwriteDir = self::getOverwriteDir();
$moduleFileList = self::getRuntime($dir, 'files');
if ($moduleFileList) {
// 有冲突的文件
if ($onlyConflict) {
// 排除的文件
$excludeFile = [
'info.ini'
];
foreach ($moduleFileList as $file) {
// 如果是要安装到项目的文件,从项目根目录开始,如果不是,从模块根目录开始
$path = Filesystem::fsFit(str_replace($dir, '', $file['path']));
$paths = explode(DIRECTORY_SEPARATOR, $path);
$overwriteFile = in_array($paths[0], $overwriteDir) ? root_path() . $path : $dir . $path;
if (is_file($overwriteFile) && !in_array($path, $excludeFile) && (filesize($overwriteFile) != $file['size'] || md5_file($overwriteFile) != $file['md5'])) {
$fileList[] = $path;
}
}
} else {
// 要安装的文件
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
foreach ($moduleFileList as $file) {
if (!str_starts_with($file['path'], $baseDir)) continue;
$fileList[] = Filesystem::fsFit(str_replace($dir, '', $file['path']));
}
}
}
return $fileList;
}
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
if (!is_dir($baseDir)) {
continue;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isFile()) {
$filePath = $file->getPathName();
$path = str_replace($dir, '', $filePath);
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if ($onlyConflict) {
$overwriteFile = root_path() . $path;
if (is_file($overwriteFile) && (filesize($overwriteFile) != filesize($filePath) || md5_file($overwriteFile) != md5_file($filePath))) {
$fileList[] = $path;
}
} else {
$fileList[] = $path;
}
}
}
}
return $fileList;
}
public static function getOverwriteDir(): array
{
return [
'app',
'config',
'database',
'extend',
'modules',
'public',
'vendor',
'web',
'web-nuxt',
];
}
public static function importSql(string $dir): bool
{
$sqlFile = $dir . 'install.sql';
$tempLine = '';
if (is_file($sqlFile)) {
$lines = file($sqlFile);
foreach ($lines as $line) {
if (str_starts_with($line, '--') || $line == '' || str_starts_with($line, '/*')) {
continue;
}
$tempLine .= $line;
if (str_ends_with(trim($line), ';')) {
$tempLine = str_ireplace('__PREFIX__', Config::get('database.connections.mysql.prefix'), $tempLine);
$tempLine = str_ireplace('INSERT INTO ', 'INSERT IGNORE INTO ', $tempLine);
try {
Db::execute($tempLine);
} catch (PDOException) {
// $e->getMessage();
}
$tempLine = '';
}
}
}
return true;
}
public static function installedList(string $dir): array
{
if (!is_dir($dir)) {
return [];
}
$installedDir = scandir($dir);
$installedList = [];
foreach ($installedDir as $item) {
if ($item === '.' or $item === '..' || is_file($dir . $item)) {
continue;
}
$tempDir = $dir . $item . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
continue;
}
$info = self::getIni($tempDir);
if (!isset($info['uid'])) {
continue;
}
$installedList[] = $info;
}
return $installedList;
}
public static function getInstalledIds(string $dir): array
{
$installedIds = [];
$installed = self::installedList($dir);
foreach ($installed as $item) {
$installedIds[] = $item['uid'];
}
return $installedIds;
}
/**
* 获取模块ini
* @param string $dir 模块目录路径
*/
public static function getIni(string $dir): array
{
$infoFile = $dir . 'info.ini';
$info = [];
if (is_file($infoFile)) {
$info = parse_ini_file($infoFile, true, INI_SCANNER_TYPED) ?: [];
if (!$info) return [];
}
return $info;
}
/**
* 设置模块ini
* @param string $dir 模块目录路径
* @param array $arr 新的ini数据
* @return bool
* @throws Throwable
*/
public static function setIni(string $dir, array $arr): bool
{
$infoFile = $dir . 'info.ini';
$ini = [];
foreach ($arr as $key => $val) {
if (is_array($val)) {
$ini[] = "[$key]";
foreach ($val as $ikey => $ival) {
$ini[] = "$ikey = $ival";
}
} else {
$ini[] = "$key = $val";
}
}
if (!file_put_contents($infoFile, implode("\n", $ini) . "\n", LOCK_EX)) {
throw new Exception("Configuration file has no write permission");
}
return true;
}
public static function getClass(string $uid, string $type = 'event', ?string $class = null): string
{
$name = parse_name($uid);
if (!is_null($class) && strpos($class, '.')) {
$class = explode('.', $class);
$class[count($class) - 1] = parse_name(end($class), 1);
$class = implode('\\', $class);
} else {
$class = parse_name(is_null($class) ? $name : $class, 1);
}
$namespace = match ($type) {
'controller' => '\\modules\\' . $name . '\\controller\\' . $class,
default => '\\modules\\' . $name . '\\' . $class,
};
return class_exists($namespace) ? $namespace : '';
}
public static function execEvent(string $uid, string $event, array $params = []): void
{
$eventClass = self::getClass($uid);
if (class_exists($eventClass)) {
$handle = new $eventClass();
if (method_exists($eventClass, $event)) {
$handle->$event($params);
}
}
}
/**
* 分析 WebBootstrap 代码
*/
public static function analysisWebBootstrap(string $uid, string $dir): array
{
$bootstrapFile = $dir . 'webBootstrap.stub';
if (!file_exists($bootstrapFile)) return [];
$bootstrapContent = file_get_contents($bootstrapFile);
$pregArr = [
'mainTsImport' => '/#main.ts import code start#([\s\S]*?)#main.ts import code end#/i',
'mainTsStart' => '/#main.ts start code start#([\s\S]*?)#main.ts start code end#/i',
'appVueImport' => '/#App.vue import code start#([\s\S]*?)#App.vue import code end#/i',
'appVueOnMounted' => '/#App.vue onMounted code start#([\s\S]*?)#App.vue onMounted code end#/i',
'nuxtAppVueImport' => '/#web-nuxt\/app.vue import code start#([\s\S]*?)#web-nuxt\/app.vue import code end#/i',
'nuxtAppVueStart' => '/#web-nuxt\/app.vue start code start#([\s\S]*?)#web-nuxt\/app.vue start code end#/i',
];
$codeStrArr = [];
foreach ($pregArr as $key => $item) {
preg_match($item, $bootstrapContent, $matches);
if (isset($matches[1]) && $matches[1]) {
$mainImportCodeArr = array_filter(preg_split('/\r\n|\r|\n/', $matches[1]));
if ($mainImportCodeArr) {
$codeStrArr[$key] = "\n";
if (count($mainImportCodeArr) == 1) {
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . self::buildMarkStr('module-line-mark', $uid, $key);
}
} else {
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-start', $uid, $key);
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . "\n";
}
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-end', $uid, $key);
}
}
}
unset($matches);
}
return $codeStrArr;
}
/**
* 安装 WebBootstrap
*/
public static function installWebBootstrap(string $uid, string $dir): void
{
$bootstrapCode = self::analysisWebBootstrap($uid, $dir);
if (!$bootstrapCode) {
return;
}
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marks = [
'mainTsImport' => self::buildMarkStr('import-root-mark'),
'mainTsStart' => self::buildMarkStr('start-root-mark'),
'appVueImport' => self::buildMarkStr('import-root-mark'),
'appVueOnMounted' => self::buildMarkStr('onMounted-root-mark'),
'nuxtAppVueImport' => self::buildMarkStr('import-root-mark'),
'nuxtAppVueStart' => self::buildMarkStr('start-root-mark'),
];
foreach ($bootstrapCode as $key => $item) {
if ($item && isset($marks[$key])) {
$content = file_get_contents($filePaths[$key]);
$markPos = stripos($content, $marks[$key]);
if ($markPos && strripos($content, self::buildMarkStr('module-line-mark', $uid, $key)) === false && strripos($content, self::buildMarkStr('module-multi-line-mark-start', $uid, $key)) === false) {
$content = substr_replace($content, $item, $markPos + strlen($marks[$key]), 0);
file_put_contents($filePaths[$key], $content);
}
}
}
}
/**
* 卸载 WebBootstrap
*/
public static function uninstallWebBootstrap(string $uid): void
{
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marksKey = [
'mainTsImport',
'mainTsStart',
'appVueImport',
'appVueOnMounted',
'nuxtAppVueImport',
'nuxtAppVueStart',
];
foreach ($marksKey as $item) {
if (!is_file($filePaths[$item])) {
continue;
}
$content = file_get_contents($filePaths[$item]);
$moduleLineMark = self::buildMarkStr('module-line-mark', $uid, $item);
$moduleMultiLineMarkStart = self::buildMarkStr('module-multi-line-mark-start', $uid, $item);
$moduleMultiLineMarkEnd = self::buildMarkStr('module-multi-line-mark-end', $uid, $item);
// 寻找标记,找到则将其中内容删除
$moduleLineMarkPos = strripos($content, $moduleLineMark);
if ($moduleLineMarkPos !== false) {
$delStartTemp = explode($moduleLineMark, $content);
$delStartPos = strripos(rtrim($delStartTemp[0], "\n"), "\n");
$delEndPos = stripos($content, "\n", $moduleLineMarkPos);
$content = substr_replace($content, '', $delStartPos, $delEndPos - $delStartPos);
}
$moduleMultiLineMarkStartPos = stripos($content, $moduleMultiLineMarkStart);
if ($moduleMultiLineMarkStartPos !== false) {
$moduleMultiLineMarkStartPos--;
$moduleMultiLineMarkEndPos = stripos($content, $moduleMultiLineMarkEnd);
$delLang = ($moduleMultiLineMarkEndPos + strlen($moduleMultiLineMarkEnd)) - $moduleMultiLineMarkStartPos;
$content = substr_replace($content, '', $moduleMultiLineMarkStartPos, $delLang);
}
if ($moduleLineMarkPos || $moduleMultiLineMarkStartPos) {
file_put_contents($filePaths[$item], $content);
}
}
}
/**
* 构建 WebBootstrap 需要的各种标记字符串
* @param string $type
* @param string $uid 模块UID
* @param string $extend 扩展数据
* @return string
*/
public static function buildMarkStr(string $type, string $uid = '', string $extend = ''): string
{
$nonTabKeys = ['mti', 'avi', 'navi', 'navs'];
$extend = match ($extend) {
'mainTsImport' => 'mti',
'mainTsStart' => 'mts',
'appVueImport' => 'avi',
'appVueOnMounted' => 'avo',
'nuxtAppVueImport' => 'navi',
'nuxtAppVueStart' => 'navs',
default => '',
};
return match ($type) {
'import-root-mark' => '// modules import mark, Please do not remove.',
'start-root-mark' => '// modules start mark, Please do not remove.',
'onMounted-root-mark' => '// Modules onMounted mark, Please do not remove.',
'module-line-mark' => ' // Code from module \'' . $uid . "'" . ($extend ? "($extend)" : ''),
'module-multi-line-mark-start' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' start" . ($extend ? "($extend)" : '') . "\n",
'module-multi-line-mark-end' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' end",
default => '',
};
}
public static function getNuxtVersion()
{
$nuxtPackageJsonPath = Filesystem::fsFit(root_path() . 'web-nuxt/package.json');
if (is_file($nuxtPackageJsonPath)) {
$nuxtPackageJson = file_get_contents($nuxtPackageJsonPath);
$nuxtPackageJson = json_decode($nuxtPackageJson, true);
if ($nuxtPackageJson && isset($nuxtPackageJson['version'])) {
return $nuxtPackageJson['version'];
}
}
return false;
}
/**
* 创建 .runtime
*/
public static function createRuntime(string $dir): void
{
$runtimeFilePath = $dir . '.runtime';
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::LEAVES_ONLY
);
$filePaths = [];
foreach ($files as $file) {
if (!$file->isDir()) {
$pathName = $file->getPathName();
if ($pathName == $runtimeFilePath) continue;
$filePaths[] = [
'path' => Filesystem::fsFit($pathName),
'size' => filesize($pathName),
'md5' => md5_file($pathName),
];
}
}
file_put_contents($runtimeFilePath, json_encode([
'files' => $filePaths,
'pure' => Config::get('buildadmin.module_pure_install'),
]));
}
/**
* 读取 .runtime
*/
public static function getRuntime(string $dir, string $key = ''): mixed
{
$runtimeFilePath = $dir . '.runtime';
$runtimeContent = @file_get_contents($runtimeFilePath);
$runtimeContentArr = json_decode($runtimeContent, true);
if (!$runtimeContentArr) return [];
if ($key) {
return $runtimeContentArr[$key] ?? [];
} else {
return $runtimeContentArr;
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
// +----------------------------------------------------------------------
// | BUILDADMIN
// +----------------------------------------------------------------------
// | Copyright (c) 2022-2023 https://buildadmin.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: 妙码生花 <hi@buildadmin.com>
// +----------------------------------------------------------------------
// [ 应用入口文件 ]
namespace think;
require __DIR__ . '/../vendor/autoload.php';
// 执行HTTP应用并响应
$http = (new App())->http;
$response = $http->name('admin')->run();
$response->send();
$http->end($response);

View File

@@ -0,0 +1,301 @@
<?php
namespace app\admin\library\traits;
use Throwable;
/**
* 后台控制器trait类
* 已导入到 @see \app\common\controller\Backend 中
* 若需修改此类方法:请复制方法至对应控制器后进行重写
*/
trait Backend
{
/**
* 排除入库字段
* @param array $params
* @return array
*/
protected function excludeFields(array $params): array
{
if (!is_array($this->preExcludeFields)) {
$this->preExcludeFields = explode(',', (string)$this->preExcludeFields);
}
foreach ($this->preExcludeFields as $field) {
if (array_key_exists($field, $params)) {
unset($params[$field]);
}
}
return $params;
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
// 模型验证
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) $validate->scene('edit');
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 排序 - 增量重排法
* @throws Throwable
*/
public function sortable(): void
{
$pk = $this->model->getPk();
$move = $this->request->param('move');
$target = $this->request->param('target');
$order = $this->request->param("order/s") ?: $this->defaultSortField;
$direction = $this->request->param('direction');
$dataLimitWhere = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$dataLimitWhere[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$moveRow = $this->model->where($dataLimitWhere)->find($move);
$targetRow = $this->model->where($dataLimitWhere)->find($target);
if ($move == $target || !$moveRow || !$targetRow || !$direction) {
$this->error(__('Record not found'));
}
// 当前是否以权重字段排序(只检查当前排序和默认排序字段,不检查有序保证字段)
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!array_key_exists($this->weighField, $order)) {
$this->error(__('Please use the %s field to sort before operating', [$this->weighField]));
}
// 开始增量重排
$order = $this->queryOrderBuilder();
$weigh = $targetRow[$this->weighField];
// 波及行的权重值向上增加还是向下减少
if ($order[$this->weighField] == 'desc') {
$updateMethod = $direction == 'up' ? 'dec' : 'inc';
} else {
$updateMethod = $direction == 'up' ? 'inc' : 'dec';
}
// 与目标行权重相同的行
$weighRowIds = $this->model
->where($dataLimitWhere)
->where($this->weighField, $weigh)
->order($order)
->column($pk);
$weighRowsCount = count($weighRowIds);
// 单个 SQL 查询中完成大于目标权重行的修改
$this->model->where($dataLimitWhere)
->where($this->weighField, $updateMethod == 'dec' ? '<' : '>', $weigh)
->whereNotIn($pk, [$moveRow->$pk])
->$updateMethod($this->weighField, $weighRowsCount)
->save();
// 遍历与目标行权重相同的行,每出现一行权重值将额外 +1保证权重相同行的顺序位置不变
if ($direction == 'down') {
$weighRowIds = array_reverse($weighRowIds);
}
$moveComplete = 0;
$weighRowIds = implode(',', $weighRowIds);
$weighRows = $this->model->where($dataLimitWhere)
->where($pk, 'in', $weighRowIds)
->orderRaw("field($pk,$weighRowIds)")
->select();
// 权重相等行
foreach ($weighRows as $key => $weighRow) {
// 跳过当前拖动行(相等权重数据之间的拖动时,被拖动行会出现在 $weighRows 内)
if ($moveRow[$pk] == $weighRow[$pk]) {
continue;
}
if ($updateMethod == 'dec') {
$rowWeighVal = $weighRow[$this->weighField] - $key;
} else {
$rowWeighVal = $weighRow[$this->weighField] + $key;
}
// 找到了目标行
if ($weighRow[$pk] == $targetRow[$pk]) {
$moveComplete = 1;
$moveRow[$this->weighField] = $rowWeighVal;
$moveRow->save();
}
$rowWeighVal = $updateMethod == 'dec' ? $rowWeighVal - $moveComplete : $rowWeighVal + $moveComplete;
$weighRow[$this->weighField] = $rowWeighVal;
$weighRow->save();
}
$this->success();
}
/**
* 加载为select(远程下拉选择框)数据,默认还是走$this->index()方法
* 必要时请在对应控制器类中重写
*/
public function select(): void
{
}
}

6
app/admin/middleware.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
\app\common\middleware\AllowCrossDomain::class,
\app\common\middleware\AdminLog::class,
\think\middleware\LoadLangPack::class,
];

72
app/admin/model/Admin.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace app\admin\model;
use think\Model;
use think\facade\Db;
/**
* Admin模型
* @property int $id 管理员ID
* @property string $username 管理员用户名
* @property string $nickname 管理员昵称
* @property string $email 管理员邮箱
* @property string $mobile 管理员手机号
* @property string $last_login_ip 上次登录IP
* @property string $last_login_time 上次登录时间
* @property int $login_failure 登录失败次数
* @property string $password 密码密文
* @property string $salt 密码盐(废弃待删)
* @property string $status 状态:enable=启用,disable=禁用,...(string存储可自定义其他)
*/
class Admin extends Model
{
/**
* @var string 自动写入时间戳
*/
protected $autoWriteTimestamp = true;
/**
* 追加属性
*/
protected $append = [
'group_arr',
'group_name_arr',
];
public function getGroupArrAttr($value, $row): array
{
return Db::name('admin_group_access')
->where('uid', $row['id'])
->column('group_id');
}
public function getGroupNameArrAttr($value, $row): array
{
$groupAccess = Db::name('admin_group_access')
->where('uid', $row['id'])
->column('group_id');
return AdminGroup::whereIn('id', $groupAccess)->column('name');
}
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
/**
* 重置用户密码
* @param int|string $uid 管理员ID
* @param string $newPassword 新密码
* @return int|Admin
*/
public function resetPassword(int|string $uid, string $newPassword): int|Admin
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* AdminGroup模型
*/
class AdminGroup extends Model
{
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,160 @@
<?php
namespace app\admin\model;
use Throwable;
use think\Model;
use app\admin\library\Auth;
use think\model\relation\BelongsTo;
/**
* AdminLog模型
*/
class AdminLog extends Model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 自定义日志标题
* @var string
*/
protected string $title = '';
/**
* 自定义日志内容
* @var string|array
*/
protected string|array $data = '';
/**
* 忽略的链接正则列表
* @var array
*/
protected array $urlIgnoreRegex = [
'/^(.*)\/(select|index|logout)$/i',
];
protected array $desensitizationRegex = [
'/(password|salt|token)/i'
];
public static function instance()
{
$request = request();
if (!isset($request->adminLog)) {
$request->adminLog = new static();
}
return $request->adminLog;
}
/**
* 设置标题
* @param string $title
*/
public function setTitle(string $title): void
{
$this->title = $title;
}
/**
* 设置日志内容
* @param string|array $data
*/
public function setData(string|array $data): void
{
$this->data = $data;
}
/**
* 设置忽略的链接正则列表
* @param array|string $regex
*/
public function setUrlIgnoreRegex(array|string $regex = []): void
{
$regex = is_array($regex) ? $regex : [$regex];
$this->urlIgnoreRegex = array_merge($this->urlIgnoreRegex, $regex);
}
/**
* 设置需要进行数据脱敏的正则列表
* @param array|string $regex
*/
public function setDesensitizationRegex(array|string $regex = []): void
{
$regex = is_array($regex) ? $regex : [$regex];
$this->desensitizationRegex = array_merge($this->desensitizationRegex, $regex);
}
/**
* 数据脱敏(只数组,根据数组 key 脱敏)
* @param array|string $data
* @return array|string
*/
protected function desensitization(array|string $data): array|string
{
if (!is_array($data) || !$this->desensitizationRegex) {
return $data;
}
foreach ($data as $index => &$item) {
foreach ($this->desensitizationRegex as $reg) {
if (preg_match($reg, $index)) {
$item = "***";
} elseif (is_array($item)) {
$item = $this->desensitization($item);
}
}
}
return $data;
}
/**
* 写入日志
* @param string $title
* @param string|array|null $data
* @throws Throwable
*/
public function record(string $title = '', string|array|null $data = null): void
{
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
$username = $auth->isLogin() ? $auth->username : request()->param('username', __('Unknown'));
$controller = str_replace('.', '/', request()->controller(true));
$action = request()->action(true);
$path = $controller . '/' . $action;
if ($this->urlIgnoreRegex) {
foreach ($this->urlIgnoreRegex as $item) {
if (preg_match($item, $path)) {
return;
}
}
}
$data = $data ?: $this->data;
if (!$data) {
$data = request()->param('', null, 'trim,strip_tags,htmlspecialchars');
}
$data = $this->desensitization($data);
$title = $title ?: $this->title;
if (!$title) {
$controllerTitle = AdminRule::where('name', $controller)->value('title');
$title = AdminRule::where('name', $path)->value('title');
$title = $title ?: __('Unknown') . '(' . $action . ')';
$title = $controllerTitle ? ($controllerTitle . '-' . $title) : $title;
}
self::create([
'admin_id' => $adminId,
'username' => $username,
'url' => substr(request()->url(), 0, 1500),
'title' => $title,
'data' => !is_scalar($data) ? json_encode($data) : $data,
'ip' => request()->ip(),
'useragent' => substr(request()->server('HTTP_USER_AGENT'), 0, 255),
]);
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* AdminRule 模型
* @property int $status 状态:0=禁用,1=启用
*/
class AdminRule extends Model
{
protected $autoWriteTimestamp = true;
public function setComponentAttr($value)
{
if ($value) $value = str_replace('\\', '/', $value);
return $value;
}
}

133
app/admin/model/Config.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace app\admin\model;
use Throwable;
use think\Model;
use think\facade\Cache;
/**
* 系统配置模型
* @property mixed $content
* @property mixed $rule
* @property mixed $extend
* @property mixed $allow_del
*/
class Config extends Model
{
public static string $cacheTag = 'sys_config';
protected $append = [
'value',
'content',
'extend',
'input_extend',
];
protected array $jsonDecodeType = ['checkbox', 'array', 'selects'];
protected array $needContent = ['radio', 'checkbox', 'select', 'selects'];
/**
* 入库前
* @throws Throwable
*/
public static function onBeforeInsert(Config $model): void
{
if (!in_array($model->getData('type'), $model->needContent)) {
$model->content = null;
} else {
$model->content = json_encode(str_attr_to_array($model->getData('content')));
}
if (is_array($model->rule)) {
$model->rule = implode(',', $model->rule);
}
if ($model->getData('extend') || $model->getData('inputExtend')) {
$extend = str_attr_to_array($model->getData('extend'));
$inputExtend = str_attr_to_array($model->getData('inputExtend'));
if ($inputExtend) $extend['baInputExtend'] = $inputExtend;
if ($extend) $model->extend = json_encode($extend);
}
$model->allow_del = 1;
}
/**
* 写入后
*/
public static function onAfterWrite(): void
{
// 清理配置缓存
Cache::tag(self::$cacheTag)->clear();
}
public function getValueAttr($value, $row)
{
if (!isset($row['type']) || $value == '0') return $value;
if (in_array($row['type'], $this->jsonDecodeType)) {
return empty($value) ? [] : json_decode($value, true);
} elseif ($row['type'] == 'switch') {
return (bool)$value;
} elseif ($row['type'] == 'editor') {
return !$value ? '' : htmlspecialchars_decode($value);
} elseif (in_array($row['type'], ['city', 'remoteSelects'])) {
if (!$value) return [];
if (!is_array($value)) return explode(',', $value);
return $value;
} else {
return $value ?: '';
}
}
public function setValueAttr(mixed $value, $row): mixed
{
if (in_array($row['type'], $this->jsonDecodeType)) {
return $value ? json_encode($value) : '';
} elseif ($row['type'] == 'switch') {
return $value ? '1' : '0';
} elseif ($row['type'] == 'time') {
return $value ? date('H:i:s', strtotime($value)) : '';
} elseif ($row['type'] == 'city') {
if ($value && is_array($value)) {
return implode(',', $value);
}
return $value ?: '';
} elseif (is_array($value)) {
return implode(',', $value);
}
return $value;
}
public function getContentAttr($value, $row)
{
if (!isset($row['type'])) return '';
if (in_array($row['type'], $this->needContent)) {
$arr = json_decode($value, true);
return $arr ?: [];
} else {
return '';
}
}
public function getExtendAttr($value)
{
if ($value) {
$arr = json_decode($value, true);
if ($arr) {
unset($arr['baInputExtend']);
return $arr;
}
}
return [];
}
public function getInputExtendAttr($value, $row)
{
if ($row && $row['extend']) {
$arr = json_decode($row['extend'], true);
if ($arr && isset($arr['baInputExtend'])) {
return $arr['baInputExtend'];
}
}
return [];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* Log
*/
class CrudLog extends Model
{
// 表名
protected $name = 'crud_log';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
protected $updateTime = false;
protected $type = [
'table' => 'array',
'fields' => 'array',
];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* DataRecycle 模型
*/
class DataRecycle extends Model
{
protected $name = 'security_data_recycle';
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* DataRecycleLog 模型
*/
class DataRecycleLog extends Model
{
protected $name = 'security_data_recycle_log';
protected $autoWriteTimestamp = true;
protected $updateTime = false;
public function recycle(): BelongsTo
{
return $this->belongsTo(DataRecycle::class, 'recycle_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* SensitiveData 模型
*/
class SensitiveData extends Model
{
protected $name = 'security_sensitive_data';
protected $autoWriteTimestamp = true;
protected $type = [
'data_fields' => 'array',
];
}

View File

@@ -0,0 +1,27 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* SensitiveDataLog 模型
*/
class SensitiveDataLog extends Model
{
protected $name = 'security_sensitive_data_log';
protected $autoWriteTimestamp = true;
protected $updateTime = false;
public function sensitive(): BelongsTo
{
return $this->belongsTo(SensitiveData::class, 'sensitive_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

52
app/admin/model/User.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* User 模型
* @property int $id 用户ID
* @property string password 密码密文
*/
class User extends Model
{
protected $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class, 'group_id');
}
/**
* 重置用户密码
* @param int|string $uid 用户ID
* @param string $newPassword 新密码
* @return int|User
*/
public function resetPassword(int|string $uid, string $newPassword): int|User
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* UserGroup 模型
*/
class UserGroup extends Model
{
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,80 @@
<?php
namespace app\admin\model;
use Throwable;
use think\model;
use think\Exception;
use think\model\relation\BelongsTo;
/**
* UserMoneyLog 模型
* 1. 创建余额日志自动完成会员余额的添加
* 2. 创建余额日志时,请开启事务
*/
class UserMoneyLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 入库前
* @throws Throwable
*/
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new Exception("The user can't find it");
}
if (!$model->memo) {
throw new Exception("Change note cannot be blank");
}
$model->before = $user->money;
$user->money += $model->money;
$user->save();
$model->after = $user->money;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getBeforeAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setBeforeAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getAfterAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setAfterAttr($value): string
{
return bcmul($value, 100, 2);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace app\admin\model;
use think\model;
/**
* UserRule 模型
* @property int $status 状态:0=禁用,1=启用
*/
class UserRule extends model
{
protected $autoWriteTimestamp = true;
protected static function onAfterInsert($model): void
{
$pk = $model->getPk();
$model->where($pk, $model[$pk])->update(['weigh' => $model[$pk]]);
}
public function setComponentAttr($value)
{
if ($value) $value = str_replace('\\', '/', $value);
return $value;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\model;
use Throwable;
use think\model;
use think\Exception;
use think\model\relation\BelongsTo;
/**
* UserScoreLog 模型
* 1. 创建积分日志自动完成会员积分的添加
* 2. 创建积分日志时,请开启事务
*/
class UserScoreLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 入库前
* @throws Throwable
*/
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new Exception("The user can't find it");
}
if (!$model->memo) {
throw new Exception("Change note cannot be blank");
}
$model->before = $user->score;
$user->score += $model->score;
$user->save();
$model->after = $user->score;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace app\admin\validate;
use think\Validate;
class Admin extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:admin',
'nickname' => 'require',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'email' => 'email|unique:admin',
'mobile' => 'mobile|unique:admin',
'group_arr' => 'require|array',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['username', 'nickname', 'password', 'email', 'mobile', 'group_arr'],
];
/**
* 验证场景-前台自己修改自己资料
*/
public function sceneInfo(): Admin
{
return $this->only(['nickname', 'password', 'email', 'mobile'])
->remove('password', 'require');
}
/**
* 验证场景-编辑资料
*/
public function sceneEdit(): Admin
{
return $this->only(['username', 'nickname', 'password', 'email', 'mobile', 'group_arr'])
->remove('password', 'require');
}
public function __construct()
{
$this->field = [
'username' => __('Username'),
'nickname' => __('Nickname'),
'password' => __('Password'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'group_arr' => __('Group Name Arr'),
];
$this->message = array_merge($this->message, [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class AdminGroup extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'rules' => 'require',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['name', 'rules'],
'edit' => ['name', 'rules'],
];
public function __construct()
{
$this->field = [
'name' => __('name'),
];
$this->message = [
'rules' => __('Please select rules'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class AdminRule extends Validate
{
protected $failException = true;
protected $rule = [
'type' => 'require',
'title' => 'require',
'name' => 'require|unique:admin_rule',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['type', 'title', 'name'],
'edit' => ['type', 'title', 'name'],
];
public function __construct()
{
$this->field = [
'type' => __('type'),
'title' => __('title'),
'name' => __('name'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace app\admin\validate;
use think\Validate;
class Config extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require|unique:config',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['name'],
];
public function __construct()
{
$this->field = [
'name' => __('Variable name'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace app\admin\validate;
use think\Validate;
class DataRecycle extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'controller' => 'require|unique:security_data_recycle',
'data_table' => 'require',
'primary_key' => 'require',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['name', 'controller', 'data_table', 'primary_key'],
'edit' => ['name', 'controller', 'data_table', 'primary_key'],
];
public function __construct()
{
$this->field = [
'name' => __('Name'),
'controller' => __('Controller'),
'data_table' => __('Data Table'),
'primary_key' => __('Primary Key'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\validate;
use think\Validate;
class SensitiveData extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'controller' => 'require|unique:security_sensitive_data',
'data_table' => 'require',
'primary_key' => 'require',
'data_fields' => 'require',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['name', 'data_fields', 'controller', 'data_table', 'primary_key'],
'edit' => ['name', 'data_fields', 'controller', 'data_table', 'primary_key'],
];
public function __construct()
{
$this->field = [
'name' => __('Name'),
'data_fields' => __('Data Fields'),
'controller' => __('Controller'),
'data_table' => __('Data Table'),
'primary_key' => __('Primary Key'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class UserMoneyLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'money' => 'require',
'memo' => 'require',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['user_id', 'money', 'memo'],
'edit' => ['user_id', 'money', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'money' => __('money'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class UserScoreLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'score' => 'require',
'memo' => 'require',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['user_id', 'score', 'memo'],
'edit' => ['user_id', 'score', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'score' => __('score'),
'memo' => __('memo'),
];
parent::__construct();
}
}

38
app/api/common.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
use ba\Filesystem;
use app\admin\library\module\Server;
if (!function_exists('get_account_verification_type')) {
/**
* 获取可用的账户验证方式
* 用于:用户找回密码|用户注册
* @return string[] email=电子邮件,mobile=手机短信验证
* @throws Throwable
*/
function get_account_verification_type(): array
{
$types = [];
// 电子邮件,检查后台系统邮件配置是否全部填写
$sysMailConfig = get_sys_config('', 'mail');
$configured = true;
foreach ($sysMailConfig as $item) {
if (!$item) {
$configured = false;
}
}
if ($configured) {
$types[] = 'email';
}
// 手机号,检查是否安装短信模块
$sms = Server::getIni(Filesystem::fsFit(root_path() . 'modules/sms/'));
if ($sms && $sms['state'] == 1) {
$types[] = 'mobile';
}
return $types;
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace app\api\controller;
use ba\Date;
use Throwable;
use ba\Captcha;
use ba\Random;
use app\common\model\User;
use think\facade\Validate;
use app\common\facade\Token;
use app\common\model\UserScoreLog;
use app\common\model\UserMoneyLog;
use app\common\controller\Frontend;
use app\api\validate\Account as AccountValidate;
class Account extends Frontend
{
protected array $noNeedLogin = ['retrievePassword'];
protected array $noNeedPermission = ['verification', 'changeBind'];
public function initialize(): void
{
parent::initialize();
}
public function overview(): void
{
$sevenDays = Date::unixTime('day', -6);
$score = $money = $days = [];
for ($i = 0; $i < 7; $i++) {
$days[$i] = date("Y-m-d", $sevenDays + ($i * 86400));
$tempToday0 = strtotime($days[$i]);
$tempToday24 = strtotime('+1 day', $tempToday0) - 1;
$score[$i] = UserScoreLog::where('user_id', $this->auth->id)
->where('create_time', 'BETWEEN', $tempToday0 . ',' . $tempToday24)
->sum('score');
$userMoneyTemp = UserMoneyLog::where('user_id', $this->auth->id)
->where('create_time', 'BETWEEN', $tempToday0 . ',' . $tempToday24)
->sum('money');
$money[$i] = bcdiv($userMoneyTemp, 100, 2);
}
$this->success('', [
'days' => $days,
'score' => $score,
'money' => $money,
]);
}
/**
* 会员资料
* @throws Throwable
*/
public function profile(): void
{
if ($this->request->isPost()) {
$model = $this->auth->getUser();
$data = $this->request->only(['avatar', 'username', 'nickname', 'gender', 'birthday', 'motto']);
$data['id'] = $this->auth->id;
if (!isset($data['birthday'])) {
$data['birthday'] = null;
}
try {
$validate = new AccountValidate();
$validate->scene('edit')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$model->startTrans();
try {
$model->save($data);
$model->commit();
} catch (Throwable $e) {
$model->rollback();
$this->error($e->getMessage());
}
$this->success(__('Data updated successfully~'));
}
$this->success('', [
'accountVerificationType' => get_account_verification_type()
]);
}
/**
* 通过手机号或邮箱验证账户
* 此处检查的验证码是通过 api/Ems或api/Sms发送的
* 验证成功后,向前端返回一个 email-pass Token或着 mobile-pass Token
* 在 changBind 方法中,通过 pass Token来确定用户已经通过了账户验证用户未绑定邮箱/手机时通过账户密码验证)
* @throws Throwable
*/
public function verification(): void
{
$captcha = new Captcha();
$params = $this->request->only(['type', 'captcha']);
if ($captcha->check($params['captcha'], ($params['type'] == 'email' ? $this->auth->email : $this->auth->mobile) . "user_{$params['type']}_verify")) {
$uuid = Random::uuid();
Token::set($uuid, $params['type'] . '-pass', $this->auth->id, 600);
$this->success('', [
'type' => $params['type'],
'accountVerificationToken' => $uuid,
]);
}
$this->error(__('Please enter the correct verification code'));
}
/**
* 修改绑定信息(手机号、邮箱)
* 通过 pass Token来确定用户已经通过了账户验证也就是以上的 verification 方法,同时用户未绑定邮箱/手机时通过账户密码验证
* @throws Throwable
*/
public function changeBind(): void
{
$captcha = new Captcha();
$params = $this->request->only(['type', 'captcha', 'email', 'mobile', 'accountVerificationToken', 'password']);
$user = $this->auth->getUser();
if ($user[$params['type']]) {
if (!Token::check($params['accountVerificationToken'], $params['type'] . '-pass', $user->id)) {
$this->error(__('You need to verify your account before modifying the binding information'));
}
} elseif (!isset($params['password']) || !verify_password($params['password'], $user->password, ['salt' => $user->salt])) {
$this->error(__('Password error'));
}
// 检查验证码
if ($captcha->check($params['captcha'], $params[$params['type']] . "user_change_{$params['type']}")) {
if ($params['type'] == 'email') {
$validate = Validate::rule(['email' => 'require|email|unique:user'])->message([
'email.require' => 'email format error',
'email.email' => 'email format error',
'email.unique' => 'email is occupied',
]);
if (!$validate->check(['email' => $params['email']])) {
$this->error(__($validate->getError()));
}
$user->email = $params['email'];
} elseif ($params['type'] == 'mobile') {
$validate = Validate::rule(['mobile' => 'require|mobile|unique:user'])->message([
'mobile.require' => 'mobile format error',
'mobile.mobile' => 'mobile format error',
'mobile.unique' => 'mobile is occupied',
]);
if (!$validate->check(['mobile' => $params['mobile']])) {
$this->error(__($validate->getError()));
}
$user->mobile = $params['mobile'];
}
Token::delete($params['accountVerificationToken']);
$user->save();
$this->success();
}
$this->error(__('Please enter the correct verification code'));
}
public function changePassword(): void
{
if ($this->request->isPost()) {
$model = $this->auth->getUser();
$params = $this->request->only(['oldPassword', 'newPassword']);
if (!verify_password($params['oldPassword'], $model->password, ['salt' => $model->salt])) {
$this->error(__('Old password error'));
}
$model->startTrans();
try {
$validate = new AccountValidate();
$validate->scene('changePassword')->check(['password' => $params['newPassword']]);
$model->resetPassword($this->auth->id, $params['newPassword']);
$model->commit();
} catch (Throwable $e) {
$model->rollback();
$this->error($e->getMessage());
}
$this->auth->logout();
$this->success(__('Password has been changed, please login again~'));
}
}
/**
* 积分日志
* @throws Throwable
*/
public function integral(): void
{
$limit = $this->request->request('limit');
$integralModel = new UserScoreLog();
$res = $integralModel->where('user_id', $this->auth->id)
->order('create_time desc')
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 余额日志
* @throws Throwable
*/
public function balance(): void
{
$limit = $this->request->request('limit');
$moneyModel = new UserMoneyLog();
$res = $moneyModel->where('user_id', $this->auth->id)
->order('create_time desc')
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 找回密码
* @throws Throwable
*/
public function retrievePassword(): void
{
$params = $this->request->only(['type', 'account', 'captcha', 'password']);
try {
$validate = new AccountValidate();
$validate->scene('retrievePassword')->check($params);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
if ($params['type'] == 'email') {
$user = User::where('email', $params['account'])->find();
} else {
$user = User::where('mobile', $params['account'])->find();
}
if (!$user) {
$this->error(__('Account does not exist~'));
}
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params['account'] . 'user_retrieve_pwd')) {
$this->error(__('Please enter the correct verification code'));
}
if ($user->resetPassword($user->id, $params['password'])) {
$this->success(__('Password has been changed~'));
} else {
$this->error(__('Failed to modify password, please try again later~'));
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace app\api\controller;
use Throwable;
use think\Response;
use app\common\library\Upload;
use app\common\controller\Frontend;
class Ajax extends Frontend
{
protected array $noNeedLogin = ['area', 'buildSuffixSvg'];
protected array $noNeedPermission = ['upload'];
public function initialize(): void
{
parent::initialize();
}
public function upload(): void
{
$file = $this->request->file('file');
$driver = $this->request->param('driver', 'local');
$topic = $this->request->param('topic', 'default');
try {
$upload = new Upload();
$attachment = $upload
->setFile($file)
->setDriver($driver)
->setTopic($topic)
->upload(null, 0, $this->auth->id);
unset($attachment['create_time'], $attachment['quote']);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
/**
* 省份地区数据
* @throws Throwable
*/
public function area(): void
{
$this->success('', get_area());
}
public function buildSuffixSvg(): Response
{
$suffix = $this->request->param('suffix', 'file');
$background = $this->request->param('background');
$content = build_suffix_svg((string)$suffix, (string)$background);
return response($content, 200, ['Content-Length' => strlen($content)])->contentType('image/svg+xml');
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\api\controller;
use ba\Random;
use Throwable;
use ba\Captcha;
use think\Response;
use ba\ClickCaptcha;
use think\facade\Config;
use app\common\facade\Token;
use app\common\controller\Api;
use app\admin\library\Auth as AdminAuth;
use app\common\library\Auth as UserAuth;
class Common extends Api
{
/**
* 图形验证码
* @throws Throwable
*/
public function captcha(): Response
{
$captchaId = $this->request->request('id');
$config = array(
'codeSet' => '123456789', // 验证码字符集合
'fontSize' => 22, // 验证码字体大小(px)
'useCurve' => false, // 是否画混淆曲线
'useNoise' => true, // 是否添加杂点
'length' => 4, // 验证码位数
'bg' => array(255, 255, 255), // 背景颜色
);
$captcha = new Captcha($config);
return $captcha->entry($captchaId);
}
/**
* 点选验证码
*/
public function clickCaptcha(): void
{
$id = $this->request->request('id/s');
$captcha = new ClickCaptcha();
$this->success('', $captcha->creat($id));
}
/**
* 点选验证码检查
* @throws Throwable
*/
public function checkClickCaptcha(): void
{
$id = $this->request->post('id/s');
$info = $this->request->post('info/s');
$unset = $this->request->post('unset/b', false);
$captcha = new ClickCaptcha();
if ($captcha->check($id, $info, $unset)) $this->success();
$this->error();
}
/**
* 刷新 token
* 无需主动删除原 token由 token 驱动自行实现过期 token 清理,可避免并发场景下无法获取到过期 token 数据
*/
public function refreshToken(): void
{
$refreshToken = $this->request->post('refreshToken');
$refreshToken = Token::get($refreshToken);
if (!$refreshToken || $refreshToken['expire_time'] < time()) {
$this->error(__('Login expired, please login again.'));
}
$newToken = Random::uuid();
// 管理员token刷新
if ($refreshToken['type'] == AdminAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, AdminAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)Config::get('buildadmin.admin_token_keep_time'));
}
// 会员token刷新
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, UserAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)Config::get('buildadmin.user_token_keep_time'));
}
$this->success('', [
'type' => $refreshToken['type'],
'token' => $newToken
]);
}
}

108
app/api/controller/Ems.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace app\api\controller;
use Throwable;
use ba\Captcha;
use ba\ClickCaptcha;
use think\facade\Validate;
use app\common\model\User;
use app\common\library\Email;
use app\common\controller\Frontend;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
class Ems extends Frontend
{
protected array $noNeedLogin = ['send'];
public function initialize(): void
{
parent::initialize();
}
/**
* 发送邮件
* event 事件:user_register=用户注册,user_change_email=用户修改邮箱,user_retrieve_pwd=用户找回密码,user_email_verify=验证账户
* 不同的事件,会自动做各种必要检查,其中 验证账户 要求用户输入当前密码才能发送验证码邮件
* @throws Throwable
*/
public function send(): void
{
$params = $this->request->post(['email', 'event', 'captchaId', 'captchaInfo']);
$mail = new Email();
if (!$mail->configured) {
$this->error(__('Mail sending service unavailable'));
}
$validate = Validate::rule([
'email' => 'require|email',
'event' => 'require',
'captchaId' => 'require',
'captchaInfo' => 'require'
])->message([
'email' => 'email format error',
'event' => 'Parameter error',
'captchaId' => 'Captcha error',
'captchaInfo' => 'Captcha error'
]);
if (!$validate->check($params)) {
$this->error(__($validate->getError()));
}
// 检查验证码
$captchaObj = new Captcha();
$clickCaptcha = new ClickCaptcha();
if (!$clickCaptcha->check($params['captchaId'], $params['captchaInfo'])) {
$this->error(__('Captcha error'));
}
// 检查频繁发送
$captcha = $captchaObj->getCaptchaData($params['email'] . $params['event']);
if ($captcha && time() - $captcha['create_time'] < 60) {
$this->error(__('Frequent email sending'));
}
// 检查邮箱
$userInfo = User::where('email', $params['email'])->find();
if ($params['event'] == 'user_register' && $userInfo) {
$this->error(__('Email has been registered, please log in directly'));
} elseif ($params['event'] == 'user_change_email' && $userInfo) {
$this->error(__('The email has been occupied'));
} elseif (in_array($params['event'], ['user_retrieve_pwd', 'user_email_verify']) && !$userInfo) {
$this->error(__('Email not registered'));
}
// 通过邮箱验证账户
if ($params['event'] == 'user_email_verify') {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'));
}
if ($this->auth->email != $params['email']) {
$this->error(__('Please use the account registration email to send the verification code'));
}
// 验证账户密码
$password = $this->request->post('password');
if (!verify_password($password, $this->auth->password, ['salt' => $this->auth->salt])) {
$this->error(__('Password error'));
}
}
// 生成一个验证码
$code = $captchaObj->create($params['email'] . $params['event']);
$subject = __($params['event']) . '-' . get_sys_config('site_name');
$body = __('Your verification code is: %s', [$code]);
try {
$mail->isSMTP();
$mail->addAddress($params['email']);
$mail->isHTML();
$mail->setSubject($subject);
$mail->Body = $body;
$mail->send();
} catch (PHPMailerException) {
$this->error($mail->ErrorInfo);
}
$this->success(__('Mail sent successfully~'));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace app\api\controller;
use ba\Tree;
use Throwable;
use think\facade\Db;
use think\facade\Config;
use app\common\controller\Frontend;
use app\common\library\token\TokenExpirationException;
class Index extends Frontend
{
protected array $noNeedLogin = ['index'];
public function initialize(): void
{
parent::initialize();
}
/**
* 前台和会员中心的初始化请求
* @throws Throwable
*/
public function index(): void
{
$menus = [];
if ($this->auth->isLogin()) {
$rules = [];
$userMenus = $this->auth->getMenus();
// 首页加载的规则,验权,但过滤掉会员中心菜单
foreach ($userMenus as $item) {
if ($item['type'] == 'menu_dir') {
$menus[] = $item;
} elseif ($item['type'] != 'menu') {
$rules[] = $item;
}
}
$rules = array_values($rules);
} else {
// 若是从前台会员中心内发出的请求,要求必须登录,否则会员中心异常
$requiredLogin = $this->request->get('requiredLogin/b', false);
if ($requiredLogin) {
// 触发可能的 token 过期异常
try {
$token = get_auth_token(['ba', 'user', 'token']);
$this->auth->init($token);
} catch (TokenExpirationException) {
$this->error(__('Token expiration'), [], 409);
}
$this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$rules = Db::name('user_rule')
->where('status', 1)
->where('no_login_valid', 1)
->where('type', 'in', ['route', 'nav', 'button'])
->order('weigh', 'desc')
->select()
->toArray();
$rules = Tree::instance()->assembleChild($rules);
}
$this->success('', [
'site' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'cdnUrl' => full_url(),
'upload' => keys_to_camel_case(get_upload_config(), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'recordNumber' => get_sys_config('record_number'),
'cdnUrlParams' => Config::get('buildadmin.cdn_url_params'),
],
'openMemberCenter' => Config::get('buildadmin.open_member_center'),
'userInfo' => $this->auth->getUserInfo(),
'rules' => $rules,
'menus' => $menus,
]);
}
}

View File

@@ -0,0 +1,671 @@
<?php
declare (strict_types=1);
namespace app\api\controller;
use Throwable;
use ba\Random;
use ba\Version;
use think\App;
use ba\Terminal;
use ba\Filesystem;
use think\facade\Db;
use think\facade\Config;
use app\common\controller\Api;
use think\db\exception\PDOException;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
/**
* 安装控制器
*/
class Install extends Api
{
public const X64 = 'x64';
public const X86 = 'x86';
protected bool $useSystemSettings = false;
/**
* 环境检查状态
*/
static string $ok = 'ok';
static string $fail = 'fail';
static string $warn = 'warn';
/**
* 安装锁文件名称
*/
static string $lockFileName = 'install.lock';
/**
* 配置文件
*/
static string $dbConfigFileName = 'database.php';
static string $buildConfigFileName = 'buildadmin.php';
/**
* 自动构建的前端文件的 outDir 相对于根目录
*/
static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
/**
* 需要的依赖版本
*/
static array $needDependentVersion = [
'php' => '8.2.0',
'npm' => '9.8.1',
'cnpm' => '7.1.0',
'node' => '20.14.0',
'yarn' => '1.2.0',
'pnpm' => '6.32.13',
];
/**
* 安装完成标记
* 配置完成则建立lock文件
* 执行命令成功执行再写入标记到lock文件
* 实现命令执行失败,重载页面可重新执行
*/
static string $InstallationCompletionMark = 'install-end';
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app)
{
parent::__construct($app);
}
/**
* 命令执行窗口
* @throws Throwable
*/
public function terminal(): void
{
if ($this->isInstallComplete()) {
return;
}
(new Terminal())->exec(false);
}
public function changePackageManager(): void
{
if ($this->isInstallComplete()) {
return;
}
$newPackageManager = request()->post('manager', Config::get('terminal.npm_package_manager'));
if (Terminal::changeTerminalConfig()) {
$this->success('', [
'manager' => $newPackageManager
]);
} else {
$this->error(__('Failed to switch package manager. Please modify the configuration file manually:%s', ['根目录/config/buildadmin.php']));
}
}
/**
* 环境基础检查
*/
public function envBaseCheck(): void
{
if ($this->isInstallComplete()) {
$this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]), []);
}
if (env('database.type')) {
$this->error(__('The .env file with database configuration was detected. Please clean up and try again!'));
}
// php版本-start
$phpVersion = phpversion();
$phpBit = PHP_INT_SIZE == 8 ? self::X64 : self::X86;
$phpVersionCompare = Version::compare(self::$needDependentVersion['php'], $phpVersion);
if (!$phpVersionCompare) {
$phpVersionLink = [
[
// 需要PHP版本
'name' => __('need') . ' >= ' . self::$needDependentVersion['php'],
'type' => 'text'
],
[
// 如何解决
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
} elseif ($phpBit != self::X64) {
$phpVersionLink = [
[
// 需要 64 位 PHP
'name' => __('need') . ' x64 PHP',
'type' => 'text'
],
[
// 如何解决
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
}
// php版本-end
// 配置文件-start
$dbConfigFile = config_path() . self::$dbConfigFileName;
$configIsWritable = Filesystem::pathIsWritable(config_path()) && Filesystem::pathIsWritable($dbConfigFile);
if (!$configIsWritable) {
$configIsWritableLink = [
[
// 查看原因
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// 配置文件-end
// public-start
$publicIsWritable = Filesystem::pathIsWritable(public_path());
if (!$publicIsWritable) {
$publicIsWritableLink = [
[
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// public-end
// PDO-start
$phpPdo = extension_loaded("PDO") && extension_loaded('pdo_mysql');
if (!$phpPdo) {
$phpPdoLink = [
[
'name' => __('PDO extensions need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/missingExtension.html'
]
];
}
// PDO-end
// GD2和freeType-start
$phpGd2 = extension_loaded('gd') && function_exists('imagettftext');
if (!$phpGd2) {
$phpGd2Link = [
[
'name' => __('The gd extension and freeType library need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/gdFail.html'
]
];
}
// GD2和freeType-end
// proc_open
$phpProc = function_exists('proc_open') && function_exists('proc_close') && function_exists('proc_get_status');
if (!$phpProc) {
$phpProcLink = [
[
'name' => __('View reason'),
'title' => __('proc_open or proc_close functions in PHP Ini is disabled'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('How to modify'),
'title' => __('Click to view how to modify'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('Security assurance?'),
'title' => __('Using the installation service correctly will not cause any potential security problems. Click to view the details'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/senior.html'
],
];
}
// proc_open-end
$this->success('', [
'php_version' => [
'describe' => $phpVersion . " ($phpBit)",
'state' => $phpVersionCompare && $phpBit == self::X64 ? self::$ok : self::$fail,
'link' => $phpVersionLink ?? [],
],
'config_is_writable' => [
'describe' => self::writableStateDescribe($configIsWritable),
'state' => $configIsWritable ? self::$ok : self::$fail,
'link' => $configIsWritableLink ?? []
],
'public_is_writable' => [
'describe' => self::writableStateDescribe($publicIsWritable),
'state' => $publicIsWritable ? self::$ok : self::$fail,
'link' => $publicIsWritableLink ?? []
],
'php_pdo' => [
'describe' => $phpPdo ? __('already installed') : __('Not installed'),
'state' => $phpPdo ? self::$ok : self::$fail,
'link' => $phpPdoLink ?? []
],
'php_gd2' => [
'describe' => $phpGd2 ? __('already installed') : __('Not installed'),
'state' => $phpGd2 ? self::$ok : self::$fail,
'link' => $phpGd2Link ?? []
],
'php_proc' => [
'describe' => $phpProc ? __('Allow execution') : __('disabled'),
'state' => $phpProc ? self::$ok : self::$warn,
'link' => $phpProcLink ?? []
],
]);
}
/**
* npm环境检查
*/
public function envNpmCheck(): void
{
if ($this->isInstallComplete()) {
$this->error('', [], 2);
}
$packageManager = request()->post('manager', 'none');
// npm
$npmVersion = Version::getVersion('npm');
$npmVersionCompare = Version::compare(self::$needDependentVersion['npm'], $npmVersion);
if (!$npmVersionCompare || !$npmVersion) {
$npmVersionLink = [
[
// 需要版本
'name' => __('need') . ' >= ' . self::$needDependentVersion['npm'],
'type' => 'text'
],
[
// 如何解决
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNpm.html'
]
];
}
// 包管理器
if (in_array($packageManager, ['npm', 'cnpm', 'pnpm', 'yarn'])) {
$pmVersion = Version::getVersion($packageManager);
$pmVersionCompare = Version::compare(self::$needDependentVersion[$packageManager], $pmVersion);
if (!$pmVersion) {
// 安装
$pmVersionLink[] = [
// 需要版本
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
if ($npmVersionCompare) {
$pmVersionLink[] = [
// 点击安装
'name' => __('Click Install %s', [$packageManager]),
'title' => '',
'type' => 'install-package-manager'
];
} else {
$pmVersionLink[] = [
// 请先安装npm
'name' => __('Please install NPM first'),
'type' => 'text'
];
}
} elseif (!$pmVersionCompare) {
// 版本不足
$pmVersionLink[] = [
// 需要版本
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
$pmVersionLink[] = [
// 请升级
'name' => __('Please upgrade %s version', [$packageManager]),
'type' => 'text'
];
}
} elseif ($packageManager == 'ni') {
$pmVersion = __('nothing');
$pmVersionCompare = true;
} else {
$pmVersion = __('nothing');
$pmVersionCompare = false;
}
// nodejs
$nodejsVersion = Version::getVersion('node');
$nodejsVersionCompare = Version::compare(self::$needDependentVersion['node'], $nodejsVersion);
if (!$nodejsVersionCompare || !$nodejsVersion) {
$nodejsVersionLink = [
[
// 需要版本
'name' => __('need') . ' >= ' . self::$needDependentVersion['node'],
'type' => 'text'
],
[
// 如何解决
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNodeJs.html'
]
];
}
$this->success('', [
'npm_version' => [
'describe' => $npmVersion ?: __('Acquisition failed'),
'state' => $npmVersionCompare ? self::$ok : self::$warn,
'link' => $npmVersionLink ?? [],
],
'nodejs_version' => [
'describe' => $nodejsVersion ?: __('Acquisition failed'),
'state' => $nodejsVersionCompare ? self::$ok : self::$warn,
'link' => $nodejsVersionLink ?? []
],
'npm_package_manager' => [
'describe' => $pmVersion ?: __('Acquisition failed'),
'state' => $pmVersionCompare ? self::$ok : self::$warn,
'link' => $pmVersionLink ?? [],
]
]);
}
/**
* 测试数据库连接
*/
public function testDatabase(): void
{
$database = [
'hostname' => $this->request->post('hostname'),
'username' => $this->request->post('username'),
'password' => $this->request->post('password'),
'hostport' => $this->request->post('hostport'),
'database' => '',
];
$conn = $this->connectDb($database);
if ($conn['code'] == 0) {
$this->error($conn['msg']);
} else {
$this->success('', [
'databases' => $conn['databases']
]);
}
}
/**
* 系统基础配置
* post请求=开始安装
*/
public function baseConfig(): void
{
if ($this->isInstallComplete()) {
$this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$envOk = $this->commandExecutionCheck();
$rootPath = str_replace('\\', '/', root_path());
if ($this->request->isGet()) {
$this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk
]);
}
$connectData = $databaseParam = $this->request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']);
// 数据库配置测试
$connectData['database'] = '';
$connect = $this->connectDb($connectData, true);
if ($connect['code'] == 0) {
$this->error($connect['msg']);
}
// 建立数据库
if (!in_array($databaseParam['database'], $connect['databases'])) {
$sql = "CREATE DATABASE IF NOT EXISTS `{$databaseParam['database']}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
$connect['pdo']->exec($sql);
}
// 写入数据库配置文件
$dbConfigFile = config_path() . self::$dbConfigFileName;
$dbConfigContent = @file_get_contents($dbConfigFile);
$callback = function ($matches) use ($databaseParam) {
$value = $databaseParam[$matches[1]] ?? '';
return "'$matches[1]'$matches[2]=>$matches[3]env('database.$matches[1]', '$value'),";
};
$dbConfigText = preg_replace_callback("/'(hostname|database|username|password|hostport|prefix)'(\s+)=>(\s+)env\('database\.(.*)',\s+'(.*)'\),/", $callback, $dbConfigContent);
$result = @file_put_contents($dbConfigFile, $dbConfigText);
if (!$result) {
$this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName]));
}
// 写入.env-example文件
$envFile = root_path() . '.env-example';
$envFileContent = @file_get_contents($envFile);
if ($envFileContent) {
$databasePos = stripos($envFileContent, '[DATABASE]');
if ($databasePos !== false) {
// 清理已有数据库配置
$envFileContent = substr($envFileContent, 0, $databasePos);
}
$envFileContent .= "\n" . '[DATABASE]' . "\n";
$envFileContent .= 'TYPE = mysql' . "\n";
$envFileContent .= 'HOSTNAME = ' . $databaseParam['hostname'] . "\n";
$envFileContent .= 'DATABASE = ' . $databaseParam['database'] . "\n";
$envFileContent .= 'USERNAME = ' . $databaseParam['username'] . "\n";
$envFileContent .= 'PASSWORD = ' . $databaseParam['password'] . "\n";
$envFileContent .= 'HOSTPORT = ' . $databaseParam['hostport'] . "\n";
$envFileContent .= 'PREFIX = ' . $databaseParam['prefix'] . "\n";
$envFileContent .= 'CHARSET = utf8mb4' . "\n";
$envFileContent .= 'DEBUG = true' . "\n";
$result = @file_put_contents($envFile, $envFileContent);
if (!$result) {
$this->error(__('File has no write permission:%s', ['/' . $envFile]));
}
}
// 设置新的Token随机密钥key
$oldTokenKey = Config::get('buildadmin.token.key');
$newTokenKey = Random::build('alnum', 32);
$buildConfigFile = config_path() . self::$buildConfigFileName;
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'key'(\s+)=>(\s+)'$oldTokenKey'/", "'key'\$1=>\$2'$newTokenKey'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
if (!$result) {
$this->error(__('File has no write permission:%s', ['config/' . self::$buildConfigFileName]));
}
// 建立安装锁文件
$result = @file_put_contents(public_path() . self::$lockFileName, date('Y-m-d H:i:s'));
if (!$result) {
$this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
$this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk
]);
}
protected function isInstallComplete(): bool
{
if (is_file(public_path() . self::$lockFileName)) {
$contents = @file_get_contents(public_path() . self::$lockFileName);
if ($contents == self::$InstallationCompletionMark) {
return true;
}
}
return false;
}
/**
* 标记命令执行完毕
* @throws Throwable
*/
public function commandExecComplete(): void
{
if ($this->isInstallComplete()) {
$this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$param = $this->request->only(['type', 'adminname', 'adminpassword', 'sitename']);
if ($param['type'] == 'web') {
$result = @file_put_contents(public_path() . self::$lockFileName, self::$InstallationCompletionMark);
if (!$result) {
$this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
} else {
// 管理员配置入库
$adminModel = new AdminModel();
$defaultAdmin = $adminModel->where('username', 'admin')->find();
$defaultAdmin->username = $param['adminname'];
$defaultAdmin->nickname = ucfirst($param['adminname']);
$defaultAdmin->save();
if (isset($param['adminpassword']) && $param['adminpassword']) {
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
}
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称
\app\admin\model\Config::where('name', 'site_name')->update([
'value' => $param['sitename']
]);
}
$this->success();
}
/**
* 获取命令执行检查的结果
* @return bool 是否拥有执行命令的条件
*/
private function commandExecutionCheck(): bool
{
$pm = Config::get('terminal.npm_package_manager');
if ($pm == 'none') {
return false;
}
$check['phpPopen'] = function_exists('proc_open') && function_exists('proc_close');
$check['npmVersionCompare'] = Version::compare(self::$needDependentVersion['npm'], Version::getVersion('npm'));
$check['pmVersionCompare'] = Version::compare(self::$needDependentVersion[$pm], Version::getVersion($pm));
$check['nodejsVersionCompare'] = Version::compare(self::$needDependentVersion['node'], Version::getVersion('node'));
$envOk = true;
foreach ($check as $value) {
if (!$value) {
$envOk = false;
break;
}
}
return $envOk;
}
/**
* 安装指引
*/
public function manualInstall(): void
{
$this->success('', [
'webPath' => str_replace('\\', '/', root_path() . 'web')
]);
}
public function mvDist(): void
{
if (!is_file(root_path() . self::$distDir . DIRECTORY_SEPARATOR . 'index.html')) {
$this->error(__('No built front-end file found, please rebuild manually!'));
}
if (Terminal::mvDist()) {
$this->success();
} else {
$this->error(__('Failed to move the front-end file, please move it manually!'));
}
}
/**
* 目录是否可写
* @param $writable
* @return string
*/
private static function writableStateDescribe($writable): string
{
return $writable ? __('Writable') : __('No write permission');
}
/**
* 数据库连接-获取数据表列表
* @param array $database
* @param bool $returnPdo
* @return array
*/
private function connectDb(array $database, bool $returnPdo = false): array
{
try {
$dbConfig = Config::get('database');
$dbConfig['connections']['mysql'] = array_merge($dbConfig['connections']['mysql'], $database);
Config::set(['connections' => $dbConfig['connections']], 'database');
$connect = Db::connect('mysql');
$connect->execute("SELECT 1");
} catch (PDOException $e) {
$errorMsg = $e->getMessage();
return [
'code' => 0,
'msg' => __('Database connection failed:%s', [mb_convert_encoding($errorMsg ?: 'unknown', 'UTF-8', 'UTF-8,GBK,GB2312,BIG5')])
];
}
$databases = [];
// 不需要的数据表
$databasesExclude = ['information_schema', 'mysql', 'performance_schema', 'sys'];
$res = $connect->query("SHOW DATABASES");
foreach ($res as $row) {
if (!in_array($row['Database'], $databasesExclude)) {
$databases[] = $row['Database'];
}
}
return [
'code' => 1,
'msg' => '',
'databases' => $databases,
'pdo' => $returnPdo ? $connect->getPdo() : '',
];
}
}

100
app/api/controller/User.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace app\api\controller;
use Throwable;
use ba\Captcha;
use ba\ClickCaptcha;
use think\facade\Config;
use app\common\facade\Token;
use app\common\controller\Frontend;
use app\api\validate\User as UserValidate;
class User extends Frontend
{
protected array $noNeedLogin = ['checkIn', 'logout'];
public function initialize(): void
{
parent::initialize();
}
/**
* 会员签入(登录和注册)
* @throws Throwable
*/
public function checkIn(): void
{
$openMemberCenter = Config::get('buildadmin.open_member_center');
if (!$openMemberCenter) {
$this->error(__('Member center disabled'));
}
// 检查登录态
if ($this->auth->isLogin()) {
$this->success(__('You have already logged in. There is no need to log in again~'), [
'type' => $this->auth::LOGGED_IN
], $this->auth::LOGIN_RESPONSE_CODE);
}
$userLoginCaptchaSwitch = Config::get('buildadmin.user_login_captcha');
if ($this->request->isPost()) {
$params = $this->request->post(['tab', 'email', 'mobile', 'username', 'password', 'keep', 'captcha', 'captchaId', 'captchaInfo', 'registerType']);
// 提前检查 tab ,然后将以 tab 值作为数据验证场景
if (!in_array($params['tab'] ?? '', ['login', 'register'])) {
$this->error(__('Unknown operation'));
}
$validate = new UserValidate();
try {
$validate->scene($params['tab'])->check($params);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
if ($params['tab'] == 'login') {
if ($userLoginCaptchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($params['captchaId'], $params['captchaInfo'])) {
$this->error(__('Captcha error'));
}
}
$res = $this->auth->login($params['username'], $params['password'], !empty($params['keep']));
} elseif ($params['tab'] == 'register') {
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
$this->error(__('Please enter the correct verification code'));
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email']);
}
if (isset($res) && $res === true) {
$this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getUserInfo(),
'routePath' => '/user'
]);
} else {
$msg = $this->auth->getError();
$msg = $msg ?: __('Check in failed, please try again or contact the website administrator~');
$this->error($msg);
}
}
$this->success('', [
'userLoginCaptchaSwitch' => $userLoginCaptchaSwitch,
'accountVerificationType' => get_account_verification_type()
]);
}
public function logout(): void
{
if ($this->request->isPost()) {
$refreshToken = $this->request->post('refreshToken', '');
if ($refreshToken) Token::delete((string)$refreshToken);
$this->auth->logout();
$this->success();
}
}
}

15
app/api/lang/en.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
return [
'Login expired, please login again.' => 'Login expired, please login again.',
'Account not exist' => 'Account does not exist',
'Account disabled' => 'Account is disabled',
'Token login failed' => 'Token login failed',
'Please try again after 1 day' => 'The number of failed login attempts has exceeded the limit, please try again after 24 hours.',
'Password is incorrect' => 'Incorrect password',
'You are not logged in' => 'You are not logged in.',
'Unknown operation' => 'Unknown operation',
'No action available, please contact the administrator~' => 'There is no action available, please contact the administrator~',
'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!',
];

View File

@@ -0,0 +1,16 @@
<?php
return [
'nickname' => 'Nickname',
'birthday' => 'Birthday',
'captcha' => 'Captcha',
'Old password error' => 'Old password error',
'Data updated successfully~' => 'Data updated successfully',
'Please input correct password' => 'Please enter the correct password',
'nicknameChsDash' => 'Usernames can only be Chinese characters, letters, numbers, underscores_ and dashes-.',
'Password has been changed~' => 'Password has been changed~',
'Password has been changed, please login again~' => 'Password has been changed, please login again~',
'Account does not exist~' => 'Account does not exist',
'Failed to modify password, please try again later~' => 'Failed to modify password, please try again later~',
'Please enter the correct verification code' => 'Please enter the correct Captcha',
'%s has been registered' => '%s has been registered, please login directly.',
];

16
app/api/lang/en/ems.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
return [
'email format error' => 'email format error',
'user_register' => 'Member registration verification',
'user_retrieve_pwd' => 'Retrieve password verification',
'user_change_email' => 'Modify mailbox validation',
'user_email_verify' => 'Member Email Verification',
'Your verification code is: %s' => 'Your Captcha is: %svalid for 10 minutes~',
'Mail sent successfully~' => 'Mail sent successfully',
'Account does not exist~' => 'Account does not exist',
'Mail sending service unavailable' => 'The mail sending service is not working, please contact the webmaster to configure it.',
'Frequent email sending' => 'Frequent email sending',
'Email has been registered, please log in directly' => 'Email has been registered, please log in directly~',
'The email has been occupied' => 'The email has been occupied',
'Email not registered' => 'Email not registered',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'Install the controller' => 'Install the controller',
'need' => 'Need',
'Click to see how to solve it' => 'Click to see how to solve.',
'Please check the config directory permissions' => 'Please check the Config directory permissions',
'Please check the public directory permissions' => 'Please check the Public directory permissions',
'open' => 'Open',
'close' => 'Close',
'The installation can continue, and some operations need to be completed manually' => 'You can continue to install, and some operations need to be completed manually ',
'Allow execution' => 'Allow execution',
'disabled' => 'Disabled',
'Allow operation' => 'Allow operation',
'Acquisition failed' => 'Access failed',
'Click Install %s' => 'Click Install %s',
'Writable' => 'Writable',
'No write permission' => 'No write permissions',
'already installed' => 'Installed',
'Not installed' => 'Not installed',
'File has no write permission:%s' => 'File has no write permission:%s',
'The system has completed installation. If you need to reinstall, please delete the %s file first' => 'The system has been installed, if you need to reinstall, please delete the %s file first.',
'Database connection failed:%s' => 'Database connection failure%s',
'Failed to install SQL execution:%s' => 'Installation SQL execution failed%s',
'unknown' => 'Unknown',
'Database does not exist' => 'Database does not exist!',
'No built front-end file found, please rebuild manually!' => 'No built front-end file found, please rebuild manually.',
'Failed to move the front-end file, please move it manually!' => 'Failed to move the front-end file, please move manually',
'How to solve?' => 'How to solve?',
'View reason' => 'View reasons',
'Click to view the reason' => 'Click to see the reason',
'PDO extensions need to be installed' => 'pdo_mysql extensions need to be installed.',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open and proc_close functions in PHP.Ini is disabled.',
'How to modify' => 'How to modify?',
'Click to view how to modify' => 'Click to see how to modify.',
'Security assurance?' => 'Security assurance?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => 'The correct use of the installation service will not cause any potential security issues. Click to view the details.',
'Please install NPM first' => 'Please install NPM first.',
'Installation error:%s' => 'Installation error%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => 'Package manager switch failed, please modify the configuration file manually%s.',
'Please upgrade %s version' => 'Please upgrade the %s version',
'nothing' => 'Nothing',
'The gd extension and freeType library need to be installed' => 'The gd2 extension and freeType library need to be installed',
'The .env file with database configuration was detected. Please clean up and try again!' => 'The .env file with database configuration was detected. Please clean up and try again!',
];

13
app/api/lang/en/user.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
return [
'captcha' => 'Captcha',
'captchaId' => 'Captcha ID',
'Please input correct username' => 'Please enter the correct username.',
'Please input correct password' => 'Please enter the correct password.',
'Registration parameter error' => 'Wrong registration parameter',
'Login succeeded!' => 'Login succeeded!',
'Please enter the correct verification code' => 'Please enter the correct Captcha.',
'You have already logged in. There is no need to log in again~' => 'You have already logged in, no need to log in again.',
'Check in failed, please try again or contact the website administrator~' => 'Check in failedplease try again or contact the webmaster.',
'Member center disabled' => 'The member centre has been disabled, please contact the webmaster to turn it on.',
];

47
app/api/lang/zh-cn.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
return [
// 时间格式化-s
'%d second%s ago' => '%d秒前',
'%d minute%s ago' => '%d分钟前',
'%d hour%s ago' => '%d小时前',
'%d day%s ago' => '%d天前',
'%d week%s ago' => '%d周前',
'%d month%s ago' => '%d月前',
'%d year%s ago' => '%d年前',
'%d second%s after' => '%d秒后',
'%d minute%s after' => '%d分钟后',
'%d hour%s after' => '%d小时后',
'%d day%s after' => '%d天后',
'%d week%s after' => '%d周后',
'%d month%s after' => '%d月后',
'%d year%s after' => '%d年后',
// 时间格式化-e
// 文件上传-s
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
// 文件上传-e
'Username' => '用户名',
'Email' => '邮箱',
'Mobile' => '手机号',
'Password' => '密码',
'Login expired, please login again.' => '登录过期,请重新登录。',
'Account not exist' => '帐户不存在',
'Account disabled' => '帐户已禁用',
'Token login failed' => '令牌登录失败',
'Please try again after 1 day' => '登录失败次数超限请在1天后再试',
'Password is incorrect' => '密码不正确',
'You are not logged in' => '你没有登录',
'Unknown operation' => '未知操作',
'No action available, please contact the administrator~' => '没有可用操作,请联系管理员~',
'Please login first' => '请先登录!',
'You have no permission' => '没有权限操作!',
'Parameter error' => '参数错误!',
'Token expiration' => '登录态过期,请重新登录!',
'Captcha error' => '验证码错误!',
];

View File

@@ -0,0 +1,22 @@
<?php
return [
'nickname' => '昵称',
'birthday' => '生日',
'captcha' => '验证码',
'Old password error' => '旧密码错误',
'Data updated successfully~' => '资料更新成功~',
'Please input correct password' => '请输入正确的密码',
'nicknameChsDash' => '用户名只能是汉字、字母、数字和下划线_及破折号-',
'Password has been changed~' => '密码已修改~',
'Password has been changed, please login again~' => '密码已修改,请重新登录~',
'Account does not exist~' => '账户不存在~',
'Failed to modify password, please try again later~' => '修改密码失败,请稍后重试~',
'Please enter the correct verification code' => '请输入正确的验证码!',
'%s has been registered' => '%s已被注册请直接登录~',
'email format error' => '电子邮箱格式错误!',
'mobile format error' => '手机号格式错误!',
'You need to verify your account before modifying the binding information' => '您需要先通过账户验证才能修改绑定信息!',
'Password error' => '密码错误!',
'email is occupied' => '电子邮箱地址已被占用!',
'mobile is occupied' => '手机号已被占用!',
];

View File

@@ -0,0 +1,18 @@
<?php
return [
'email format error' => '电子邮箱格式错误',
'user_register' => '会员注册验证',
'user_change_email' => '修改邮箱验证',
'user_retrieve_pwd' => '找回密码验证',
'user_email_verify' => '会员身份验证',
'Your verification code is: %s' => '您的验证码是:%s十分钟内有效~',
'Mail sent successfully~' => '邮件发送成功~',
'Account does not exist~' => '账户不存在~',
'Mail sending service unavailable' => '邮件发送服务不可用,请联系网站管理员进行配置~',
'Frequent email sending' => '频繁发送电子邮件',
'Email has been registered, please log in directly' => '电子邮箱已注册,请直接登录~',
'The email has been occupied' => '电子邮箱已被占用!',
'Email not registered' => '电子邮箱未注册',
'Please use the account registration email to send the verification code' => '请使用账户注册邮箱发送验证码!',
'Password error' => '密码错误!',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'Install the controller' => '安装控制器',
'need' => '需要',
'Click to see how to solve it' => '点击查看如何解决',
'Please check the config directory permissions' => '请检查 config 目录权限',
'Please check the public directory permissions' => '请检查 public 目录权限',
'open' => '开启',
'close' => '关闭',
'The installation can continue, and some operations need to be completed manually' => '可以继续安装,部分操作需手动完成',
'Allow execution' => '允许执行',
'disabled' => '已禁用',
'Allow operation' => '允许操作',
'Acquisition failed' => '获取失败',
'Click Install %s' => '点击安装%s',
'Writable' => '可写',
'No write permission' => '无写权限',
'already installed' => '已安装',
'Not installed' => '未安装',
'File has no write permission:%s' => '文件无写入权限:%s',
'The system has completed installation. If you need to reinstall, please delete the %s file first' => '系统已完成安装。如果需要重新安装,请先删除 %s 文件',
'Database connection failed:%s' => '数据库连接失败:%s',
'Failed to install SQL execution:%s' => '安装SQL执行失败%s',
'unknown' => '未知',
'Database does not exist' => '数据库不存在!',
'No built front-end file found, please rebuild manually!' => '没有找到构建好的前端文件,请手动重新构建!',
'Failed to move the front-end file, please move it manually!' => '移动前端文件失败,请手动移动!',
'How to solve?' => '如何解决?',
'View reason' => '查看原因',
'Click to view the reason' => '点击查看原因',
'PDO extensions need to be installed' => '需要安装 pdo_mysql 扩展',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open和proc_close函数在php.ini中被禁用掉了',
'How to modify' => '如何修改',
'Click to view how to modify' => '点击查看如何修改',
'Security assurance?' => '安全保证?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => '安装服务使用正确不会造成任何潜在安全问题,点击查看详情',
'Please install NPM first' => '请先安装npm',
'Installation error:%s' => '安装出错:%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => '包管理器切换失败,请手动修改配置文件:%s',
'Please upgrade %s version' => '请升级%s版本',
'nothing' => '无',
'The gd extension and freeType library need to be installed' => '需要gd2扩展和freeType库',
'The .env file with database configuration was detected. Please clean up and try again!' => '检测到带有数据库配置的 .env 文件。请清理后再试一次!',
];

View File

@@ -0,0 +1,14 @@
<?php
return [
'captcha' => '验证码',
'captchaId' => '验证码标识',
'Register type' => '注册类型',
'Please input correct username' => '请输入正确的用户名',
'Please input correct password' => '请输入正确的密码',
'Registration parameter error' => '注册参数错误',
'Login succeeded!' => '登录成功',
'Please enter the correct verification code' => '请输入正确的验证码',
'You have already logged in. There is no need to log in again~' => '您已经登录过了,无需重复登录~',
'Check in failed, please try again or contact the website administrator~' => '签入失败,请重试或联系网站管理员~',
'Member center disabled' => '会员中心已禁用,请联系网站管理员开启。',
];

5
app/api/middleware.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
\app\common\middleware\AllowCrossDomain::class,
\think\middleware\LoadLangPack::class,
];

View File

@@ -0,0 +1,47 @@
<?php
namespace app\api\validate;
use think\Validate;
class Account extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'nickname' => 'require|chsDash',
'birthday' => 'date',
'email' => 'require|email|unique:user',
'mobile' => 'require|mobile|unique:user',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'account' => 'require',
'captcha' => 'require',
];
/**
* 验证场景
*/
protected $scene = [
'edit' => ['username', 'nickname', 'birthday'],
'changePassword' => ['password'],
'retrievePassword' => ['account', 'captcha', 'password'],
];
public function __construct()
{
$this->field = [
'username' => __('Username'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'password' => __('Password'),
'nickname' => __('nickname'),
'birthday' => __('birthday'),
];
$this->message = array_merge($this->message, [
'nickname.chsDash' => __('nicknameChsDash'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

67
app/api/validate/User.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace app\api\validate;
use think\Validate;
use think\facade\Config;
class User extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'password' => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
'registerType' => 'require|in:email,mobile',
'email' => 'email|unique:user|requireIf:registerType,email',
'mobile' => 'mobile|unique:user|requireIf:registerType,mobile',
// 注册邮箱或手机验证码
'captcha' => 'require',
// 登录点选验证码
'captchaId' => 'require',
'captchaInfo' => 'require',
];
/**
* 验证场景
*/
protected $scene = [
'register' => ['username', 'password', 'registerType', 'email', 'mobile', 'captcha'],
];
/**
* 登录验证场景
*/
public function sceneLogin(): User
{
$fields = ['username', 'password'];
// 根据系统配置的登录验证码开关调整验证场景的字段
$userLoginCaptchaSwitch = Config::get('buildadmin.user_login_captcha');
if ($userLoginCaptchaSwitch) {
$fields[] = 'captchaId';
$fields[] = 'captchaInfo';
}
return $this->only($fields)->remove('username', ['regex', 'unique']);
}
public function __construct()
{
$this->field = [
'username' => __('Username'),
'email' => __('Email'),
'mobile' => __('Mobile'),
'password' => __('Password'),
'captcha' => __('captcha'),
'captchaId' => __('captchaId'),
'captchaInfo' => __('captcha'),
'registerType' => __('Register type'),
];
$this->message = array_merge($this->message, [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password')
]);
parent::__construct();
}
}

508
app/common.php Normal file
View File

@@ -0,0 +1,508 @@
<?php
// 应用公共文件
use think\App;
use ba\Filesystem;
use think\Response;
use think\facade\Db;
use think\facade\Lang;
use think\facade\Event;
use think\facade\Config;
use voku\helper\AntiXSS;
use app\admin\model\Config as configModel;
use think\exception\HttpResponseException;
use Symfony\Component\HttpFoundation\IpUtils;
if (!function_exists('__')) {
/**
* 语言翻译
* @param string $name 被翻译字符
* @param array $vars 替换字符数组
* @param string $lang 翻译语言
* @return mixed
*/
function __(string $name, array $vars = [], string $lang = ''): mixed
{
if (is_numeric($name) || !$name) {
return $name;
}
return Lang::get($name, $vars, $lang);
}
}
if (!function_exists('filter')) {
/**
* 输入过滤
* 富文本反XSS请使用 clean_xss也就不需要及不能再 filter 了
* @param string $string 要过滤的字符串
* @return string
*/
function filter(string $string): string
{
// 去除字符串两端空格(对防代码注入有一定作用)
$string = trim($string);
// 过滤html和php标签
$string = strip_tags($string);
// 特殊字符转实体
return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
}
}
if (!function_exists('clean_xss')) {
/**
* 清理XSS
* 通常只用于富文本,比 filter 慢
* @param string $string
* @return string
*/
function clean_xss(string $string): string
{
$antiXss = new AntiXSS();
// 允许 style 属性style="list-style-image: url(javascript:alert(0))" 任然可被正确过滤)
$antiXss->removeEvilAttributes(['style']);
// 检查到 xss 代码之后使用 cleanXss 替换它
$antiXss->setReplacement('cleanXss');
return $antiXss->xss_clean($string);
}
}
if (!function_exists('htmlspecialchars_decode_improve')) {
/**
* html解码增强
* 被 filter函数 内的 htmlspecialchars 编码的字符串,需要用此函数才能完全解码
* @param string $string
* @param int $flags
* @return string
*/
function htmlspecialchars_decode_improve(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401): string
{
return htmlspecialchars_decode($string, $flags);
}
}
if (!function_exists('get_sys_config')) {
/**
* 获取站点的系统配置,不传递参数则获取所有配置项
* @param string $name 变量名
* @param string $group 变量分组,传递此参数来获取某个分组的所有配置项
* @param bool $concise 是否开启简洁模式,简洁模式下,获取多项配置时只返回配置的键值对
* @return mixed
* @throws Throwable
*/
function get_sys_config(string $name = '', string $group = '', bool $concise = true): mixed
{
if ($name) {
// 直接使用->value('value')不能使用到模型的类型格式化
$config = configModel::cache($name, null, configModel::$cacheTag)->where('name', $name)->find();
if ($config) $config = $config['value'];
} else {
if ($group) {
$temp = configModel::cache('group' . $group, null, configModel::$cacheTag)->where('group', $group)->select()->toArray();
} else {
$temp = configModel::cache('sys_config_all', null, configModel::$cacheTag)->order('weigh desc')->select()->toArray();
}
if ($concise) {
$config = [];
foreach ($temp as $item) {
$config[$item['name']] = $item['value'];
}
} else {
$config = $temp;
}
}
return $config;
}
}
if (!function_exists('get_route_remark')) {
/**
* 获取当前路由后台菜单规则的备注信息
* @return string
*/
function get_route_remark(): string
{
$controllerName = request()->controller(true);
$actionName = request()->action(true);
$path = str_replace('.', '/', $controllerName);
$remark = Db::name('admin_rule')
->where('name', $path)
->whereOr('name', $path . '/' . $actionName)
->value('remark');
return __((string)$remark);
}
}
if (!function_exists('full_url')) {
/**
* 获取资源完整url地址若安装了云存储或 config/buildadmin.php 配置了CdnUrl则自动使用对应的CdnUrl
* @param string $relativeUrl 资源相对地址 不传入则获取域名
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
function full_url(string $relativeUrl = '', string|bool $domain = true, string $default = ''): string
{
// 存储/上传资料配置
Event::trigger('uploadConfigInit', App::getInstance());
$cdnUrl = Config::get('buildadmin.cdn_url');
if (!$cdnUrl) {
$cdnUrl = request()->upload['cdn'] ?? '//' . request()->host();
}
if ($domain === true) {
$domain = $cdnUrl;
} elseif ($domain === false) {
$domain = '';
}
$relativeUrl = $relativeUrl ?: $default;
if (!$relativeUrl) return $domain;
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
if (preg_match('/^http(s)?:\/\//', $relativeUrl) || preg_match($regex, $relativeUrl) || $domain === false) {
return $relativeUrl;
}
$url = $domain . $relativeUrl;
$cdnUrlParams = Config::get('buildadmin.cdn_url_params');
if ($domain === $cdnUrl && $cdnUrlParams) {
$separator = str_contains($url, '?') ? '&' : '?';
$url .= $separator . $cdnUrlParams;
}
return $url;
}
}
if (!function_exists('encrypt_password')) {
/**
* 加密密码
* @deprecated 使用 hash_password 代替
*/
function encrypt_password($password, $salt = '', $encrypt = 'md5')
{
return $encrypt($encrypt($password) . $salt);
}
}
if (!function_exists('hash_password')) {
/**
* 创建密码散列hash
*/
function hash_password(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}
}
if (!function_exists('verify_password')) {
/**
* 验证密码是否和散列值匹配
* @param string $password 密码
* @param string $hash 散列值
* @param array $extend 扩展数据
*/
function verify_password(string $password, string $hash, array $extend = []): bool
{
// 第一个表达式直接检查是否为 password_hash 函数创建的 hash 的典型格式,即:$algo$cost$salt.hash
if (str_starts_with($hash, '$') || password_get_info($hash)['algoName'] != 'unknown') {
return password_verify($password, $hash);
} else {
// 兼容旧版 md5 加密的密码
return encrypt_password($password, $extend['salt'] ?? '') === $hash;
}
}
}
if (!function_exists('str_attr_to_array')) {
/**
* 将字符串属性列表转为数组
* @param string $attr 属性一行一个无需引号比如class=input-class
* @return array
*/
function str_attr_to_array(string $attr): array
{
if (!$attr) return [];
$attr = explode("\n", trim(str_replace("\r\n", "\n", $attr)));
$attrTemp = [];
foreach ($attr as $item) {
$item = explode('=', $item);
if (isset($item[0]) && isset($item[1])) {
$attrVal = $item[1];
if ($item[1] === 'false' || $item[1] === 'true') {
$attrVal = !($item[1] === 'false');
} elseif (is_numeric($item[1])) {
$attrVal = (float)$item[1];
}
if (strpos($item[0], '.')) {
$attrKey = explode('.', $item[0]);
if (isset($attrKey[0]) && isset($attrKey[1])) {
$attrTemp[$attrKey[0]][$attrKey[1]] = $attrVal;
continue;
}
}
$attrTemp[$item[0]] = $attrVal;
}
}
return $attrTemp;
}
}
if (!function_exists('action_in_arr')) {
/**
* 检测一个方法是否在传递的数组内
* @param array $arr
* @return bool
*/
function action_in_arr(array $arr = []): bool
{
$arr = is_array($arr) ? $arr : explode(',', $arr);
if (!$arr) {
return false;
}
$arr = array_map('strtolower', $arr);
if (in_array(strtolower(request()->action()), $arr) || in_array('*', $arr)) {
return true;
}
return false;
}
}
if (!function_exists('build_suffix_svg')) {
/**
* 构建文件后缀的svg图片
* @param string $suffix 文件后缀
* @param ?string $background 背景颜色rgb(255,255,255)
* @return string
*/
function build_suffix_svg(string $suffix = 'file', ?string $background = null): string
{
$suffix = mb_substr(strtoupper($suffix), 0, 4);
$total = unpack('L', hash('adler32', $suffix, true))[1];
$hue = $total % 360;
[$r, $g, $b] = hsv2rgb($hue / 360, 0.3, 0.9);
$background = $background ?: "rgb($r,$g,$b)";
return '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<path style="fill:#E2E5E7;" d="M128,0c-17.6,0-32,14.4-32,32v448c0,17.6,14.4,32,32,32h320c17.6,0,32-14.4,32-32V128L352,0H128z"/>
<path style="fill:#B0B7BD;" d="M384,128h96L352,0v96C352,113.6,366.4,128,384,128z"/>
<polygon style="fill:#CAD1D8;" points="480,224 384,128 480,128 "/>
<path style="fill:' . $background . ';" d="M416,416c0,8.8-7.2,16-16,16H48c-8.8,0-16-7.2-16-16V256c0-8.8,7.2-16,16-16h352c8.8,0,16,7.2,16,16 V416z"/>
<path style="fill:#CAD1D8;" d="M400,432H96v16h304c8.8,0,16-7.2,16-16v-16C416,424.8,408.8,432,400,432z"/>
<g><text><tspan x="220" y="380" font-size="124" font-family="Verdana, Helvetica, Arial, sans-serif" fill="white" text-anchor="middle">' . $suffix . '</tspan></text></g>
</svg>';
}
}
if (!function_exists('get_area')) {
/**
* 获取省份地区数据
* @throws Throwable
*/
function get_area(): array
{
$province = request()->get('province', '');
$city = request()->get('city', '');
$where = ['pid' => 0, 'level' => 1];
if ($province !== '') {
$where['pid'] = $province;
$where['level'] = 2;
if ($city !== '') {
$where['pid'] = $city;
$where['level'] = 3;
}
}
return Db::name('area')
->where($where)
->field('id as value,name as label')
->select()
->toArray();
}
}
if (!function_exists('hsv2rgb')) {
function hsv2rgb($h, $s, $v): array
{
$r = $g = $b = 0;
$i = floor($h * 6);
$f = $h * 6 - $i;
$p = $v * (1 - $s);
$q = $v * (1 - $f * $s);
$t = $v * (1 - (1 - $f) * $s);
switch ($i % 6) {
case 0:
$r = $v;
$g = $t;
$b = $p;
break;
case 1:
$r = $q;
$g = $v;
$b = $p;
break;
case 2:
$r = $p;
$g = $v;
$b = $t;
break;
case 3:
$r = $p;
$g = $q;
$b = $v;
break;
case 4:
$r = $t;
$g = $p;
$b = $v;
break;
case 5:
$r = $v;
$g = $p;
$b = $q;
break;
}
return [
floor($r * 255),
floor($g * 255),
floor($b * 255)
];
}
}
if (!function_exists('ip_check')) {
/**
* IP检查
* @throws Throwable
*/
function ip_check($ip = null): void
{
$ip = is_null($ip) ? request()->ip() : $ip;
$noAccess = get_sys_config('no_access_ip');
$noAccess = !$noAccess ? [] : array_filter(explode("\n", str_replace("\r\n", "\n", $noAccess)));
if ($noAccess && IpUtils::checkIp($ip, $noAccess)) {
$response = Response::create(['msg' => 'No permission request'], 'json', 403);
throw new HttpResponseException($response);
}
}
}
if (!function_exists('set_timezone')) {
/**
* 设置时区
* @throws Throwable
*/
function set_timezone($timezone = null): void
{
$defaultTimezone = Config::get('app.default_timezone');
$timezone = is_null($timezone) ? get_sys_config('time_zone') : $timezone;
if ($timezone && $defaultTimezone != $timezone) {
Config::set([
'app.default_timezone' => $timezone
]);
date_default_timezone_set($timezone);
}
}
}
if (!function_exists('get_upload_config')) {
/**
* 获取上传配置
* @return array
*/
function get_upload_config(): array
{
// 存储/上传资料配置
Event::trigger('uploadConfigInit', App::getInstance());
$uploadConfig = Config::get('upload');
$uploadConfig['max_size'] = Filesystem::fileUnitToByte($uploadConfig['max_size']);
$upload = request()->upload;
if (!$upload) {
$uploadConfig['mode'] = 'local';
return $uploadConfig;
}
unset($upload['cdn']);
return array_merge($upload, $uploadConfig);
}
}
if (!function_exists('get_auth_token')) {
/**
* 获取鉴权 token
* @param array $names
* @return string
*/
function get_auth_token(array $names = ['ba', 'token']): string
{
$separators = [
'header' => ['', '-'], // batoken、ba-token【ba_token 不在 header 的接受列表内因为兼容性不高,改用 http_ba_token】
'param' => ['', '-', '_'], // batoken、ba-token、ba_token
'server' => ['_'], // http_ba_token
];
$tokens = [];
$request = request();
foreach ($separators as $fun => $sps) {
foreach ($sps as $sp) {
$tokens[] = $request->$fun(($fun == 'server' ? 'http_' : '') . implode($sp, $names));
}
}
$tokens = array_filter($tokens);
return array_values($tokens)[0] ?? '';
}
}
if (!function_exists('keys_to_camel_case')) {
/**
* 将数组 key 的命名方式转换为小写驼峰
* @param array $array 被转换的数组
* @param array $keys 要转换的 key默认所有
* @return array
*/
function keys_to_camel_case(array $array, array $keys = []): array
{
$result = [];
foreach ($array as $key => $value) {
// 将键名转换为驼峰命名
$camelCaseKey = $keys && in_array($key, $keys) ? parse_name($key, 1, false) : $key;
if (is_array($value)) {
// 如果值是数组,递归转换
$result[$camelCaseKey] = keys_to_camel_case($value);
} else {
$result[$camelCaseKey] = $value;
}
}
return $result;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace app\common\controller;
use Throwable;
use think\App;
use think\Response;
use think\facade\Db;
use app\BaseController;
use think\db\exception\PDOException;
use think\exception\HttpResponseException;
/**
* API控制器基类
*/
class Api extends BaseController
{
/**
* 默认响应输出类型,支持json/xml/jsonp
* @var string
*/
protected string $responseType = 'json';
/**
* 应用站点系统设置
* @var bool
*/
protected bool $useSystemSettings = true;
public function __construct(App $app)
{
parent::__construct($app);
}
/**
* 控制器初始化方法
* @access protected
* @throws Throwable
*/
protected function initialize(): void
{
// 系统站点配置
if ($this->useSystemSettings) {
// 检查数据库连接
try {
Db::execute("SELECT 1");
} catch (PDOException $e) {
$this->error(mb_convert_encoding($e->getMessage(), 'UTF-8', 'UTF-8,GBK,GB2312,BIG5'));
}
ip_check(); // ip检查
set_timezone(); // 时区设定
}
parent::initialize();
// 加载控制器语言包
$langSet = $this->app->lang->getLangSet();
$this->app->lang->load([
app_path() . 'lang' . DIRECTORY_SEPARATOR . $langSet . DIRECTORY_SEPARATOR . (str_replace('/', DIRECTORY_SEPARATOR, $this->app->request->controllerPath)) . '.php'
]);
}
/**
* 操作成功
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function success(string $msg = '', mixed $data = null, int $code = 1, ?string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
/**
* 操作失败
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function error(string $msg = '', mixed $data = null, int $code = 0, ?string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
/**
* 返回 API 数据
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
public function result(string $msg, mixed $data = null, int $code = 0, ?string $type = null, array $header = [], array $options = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => $this->request->server('REQUEST_TIME'),
'data' => $data,
];
$type = $type ?: $this->responseType;
$code = $header['statusCode'] ?? 200;
$response = Response::create($result, $type, $code)->header($header)->options($options);
throw new HttpResponseException($response);
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace app\common\controller;
use Throwable;
use think\Model;
use think\facade\Event;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
class Backend extends Api
{
/**
* 无需登录的方法,访问本控制器的此方法,无需管理员登录
* @var array
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
* @var array
*/
protected array $noNeedPermission = [];
/**
* 新增/编辑时,对前端发送的字段进行排除(忽略不入库)
* @var array|string
*/
protected array|string $preExcludeFields = [];
/**
* 权限类实例
* @var Auth
*/
protected Auth $auth;
/**
* 模型类实例
* @var object
* @phpstan-var Model
*/
protected object $model;
/**
* 权重字段
* @var string
*/
protected string $weighField = 'weigh';
/**
* 默认排序
* @var string|array id,desc 或 ['id' => 'desc']
*/
protected string|array $defaultSortField = [];
/**
* 有序保证
* 查询数据时总是需要指定 ORDER BY 子句,否则 MySQL 不保证排序,即先查到哪行就输出哪行且不保证多次查询中的输出顺序
* 将以下配置作为数据有序保证(用于无排序字段时、默认排序字段相同时继续保持数据有序),不设置将自动使用 pk 字段
* @var string|array id,desc 或 ['id' => 'desc'](有更方便的格式,此处为了保持和 $defaultSortField 属性的配置格式一致)
*/
protected string|array $orderGuarantee = [];
/**
* 快速搜索字段
* @var string|array
*/
protected string|array $quickSearchField = 'id';
/**
* 是否开启模型验证
* @var bool
*/
protected bool $modelValidate = true;
/**
* 是否开启模型场景验证
* @var bool
*/
protected bool $modelSceneValidate = false;
/**
* 关联查询方法名,方法应定义在模型中
* @var array
*/
protected array $withJoinTable = [];
/**
* 关联查询JOIN方式
* @var string
*/
protected string $withJoinType = 'LEFT';
/**
* 开启数据限制
* false=关闭
* personal=仅限个人
* allAuth=拥有某管理员所有的权限时
* allAuthAndOthers=拥有某管理员所有的权限并且还有其他权限时
* parent=上级分组中的管理员可查
* 指定分组中的管理员可查,比如 $dataLimit = 2;
* 启用请确保数据表内存在 admin_id 字段,可以查询/编辑数据的管理员为admin_id对应的管理员+数据限制所表示的管理员们
* @var bool|string|int
*/
protected bool|string|int $dataLimit = false;
/**
* 数据限制字段
* @var string
*/
protected string $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充字段值为当前管理员id
* @var bool
*/
protected bool $dataLimitFieldAutoFill = true;
/**
* 查看请求返回的主表字段控制
* @var string|array
*/
protected string|array $indexField = ['*'];
/**
* 引入traits
* traits内实现了index、add、edit等方法
*/
use \app\admin\library\traits\Backend;
/**
* 初始化
* @throws Throwable
*/
public function initialize(): void
{
parent::initialize();
$needLogin = !action_in_arr($this->noNeedLogin);
try {
// 初始化管理员鉴权实例
$this->auth = Auth::instance();
$token = get_auth_token();
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) {
$this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission)) {
$routePath = ($this->app->request->controllerPath ?? '') . '/' . $this->request->action(true);
if (!$this->auth->check($routePath)) {
$this->error(__('You have no permission'), [], 401);
}
}
}
// 管理员验权和登录标签位
Event::trigger('backendInit', $this->auth);
}
/**
* 查询参数构建器
* @throws Throwable
*/
public function queryBuilder(): array
{
if (empty($this->model)) {
return [];
}
$pk = $this->model->getPk();
$quickSearch = $this->request->get("quickSearch/s", '');
$limit = $this->request->get("limit/d", 10);
$search = $this->request->get("search/a", []);
$initKey = $this->request->get("initKey/s", $pk);
$initValue = $this->request->get("initValue", '');
$initOperator = $this->request->get("initOperator/s", 'in');
$where = [];
$modelTable = strtolower($this->model->getTable());
$alias[$modelTable] = parse_name(basename(str_replace('\\', '/', get_class($this->model))));
$mainTableAlias = $alias[$modelTable] . '.';
// 快速搜索
if ($quickSearch) {
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', $this->quickSearchField);
foreach ($quickSearchArr as $k => $v) {
$quickSearchArr[$k] = str_contains($v, '.') ? $v : $mainTableAlias . $v;
}
$where[] = [implode("|", $quickSearchArr), "LIKE", '%' . str_replace('%', '\%', $quickSearch) . '%'];
}
if ($initValue) {
$where[] = [$initKey, $initOperator, $initValue];
$limit = 999999;
}
// 通用搜索组装
foreach ($search as $field) {
if (!is_array($field) || !isset($field['operator']) || !isset($field['field']) || !isset($field['val'])) {
continue;
}
$field['operator'] = $this->getOperatorByAlias($field['operator']);
// 查询关联表字段,转换表别名(驼峰转小写下划线)
if (str_contains($field['field'], '.')) {
$fieldNameParts = explode('.', $field['field']);
$fieldNamePartsLastKey = array_key_last($fieldNameParts);
// 忽略最后一个元素(字段名)
foreach ($fieldNameParts as $fieldNamePartsKey => $fieldNamePart) {
if ($fieldNamePartsKey !== $fieldNamePartsLastKey) {
$fieldNameParts[$fieldNamePartsKey] = parse_name($fieldNamePart);
}
}
$fieldName = implode('.', $fieldNameParts);
} else {
$fieldName = $mainTableAlias . $field['field'];
}
// 日期时间
if (isset($field['render']) && $field['render'] == 'datetime') {
if ($field['operator'] == 'RANGE') {
$datetimeArr = explode(',', $field['val']);
if (!isset($datetimeArr[1])) {
continue;
}
$datetimeArr = array_filter(array_map("strtotime", $datetimeArr));
$where[] = [$fieldName, str_replace('RANGE', 'BETWEEN', $field['operator']), $datetimeArr];
continue;
}
$where[] = [$fieldName, '=', strtotime($field['val'])];
continue;
}
// 范围查询
if ($field['operator'] == 'RANGE' || $field['operator'] == 'NOT RANGE') {
$arr = explode(',', $field['val']);
// 重新确定操作符
if (!isset($arr[0]) || $arr[0] === '') {
$operator = $field['operator'] == 'RANGE' ? '<=' : '>';
$arr = $arr[1];
} elseif (!isset($arr[1]) || $arr[1] === '') {
$operator = $field['operator'] == 'RANGE' ? '>=' : '<';
$arr = $arr[0];
} else {
$operator = str_replace('RANGE', 'BETWEEN', $field['operator']);
}
$where[] = [$fieldName, $operator, $arr];
continue;
}
switch ($field['operator']) {
case '=':
case '<>':
$where[] = [$fieldName, $field['operator'], (string)$field['val']];
break;
case 'LIKE':
case 'NOT LIKE':
$where[] = [$fieldName, $field['operator'], '%' . str_replace('%', '\%', $field['val']) . '%'];
break;
case '>':
case '>=':
case '<':
case '<=':
$where[] = [$fieldName, $field['operator'], intval($field['val'])];
break;
case 'FIND_IN_SET':
if (is_array($field['val'])) {
foreach ($field['val'] as $val) {
$where[] = [$fieldName, 'find in set', $val];
}
} else {
$where[] = [$fieldName, 'find in set', $field['val']];
}
break;
case 'IN':
case 'NOT IN':
$where[] = [$fieldName, $field['operator'], is_array($field['val']) ? $field['val'] : explode(',', $field['val'])];
break;
case 'NULL':
case 'NOT NULL':
$where[] = [$fieldName, strtolower($field['operator']), ''];
break;
}
}
// 数据权限
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$mainTableAlias . $this->dataLimitField, 'in', $dataLimitAdminIds];
}
return [$where, $alias, $limit, $this->queryOrderBuilder()];
}
/**
* 查询的排序参数构建器
*/
public function queryOrderBuilder()
{
$pk = $this->model->getPk();
$order = $this->request->get("order/s") ?: $this->defaultSortField;
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!$this->orderGuarantee) {
$this->orderGuarantee = [$pk => 'desc'];
} elseif (is_string($this->orderGuarantee)) {
$this->orderGuarantee = explode(',', $this->orderGuarantee);
$this->orderGuarantee = [$this->orderGuarantee[0] => $this->orderGuarantee[1] ?? 'asc'];
}
$orderGuaranteeKey = array_key_first($this->orderGuarantee);
if (!array_key_exists($orderGuaranteeKey, $order)) {
$order[$orderGuaranteeKey] = $this->orderGuarantee[$orderGuaranteeKey];
}
return $order;
}
/**
* 数据权限控制-获取有权限访问的管理员Ids
* @throws Throwable
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || $this->auth->isSuperAdmin()) {
return [];
}
$adminIds = [];
if ($this->dataLimit == 'parent') {
// 取得当前管理员的下级分组们
$parentGroups = $this->auth->getAdminChildGroups();
if ($parentGroups) {
// 取得分组内的所有管理员
$adminIds = $this->auth->getGroupAdmins($parentGroups);
}
} elseif (is_numeric($this->dataLimit) && $this->dataLimit > 0) {
// 在组内,可查看所有,不在组内,可查看自己的
$adminIds = $this->auth->getGroupAdmins([$this->dataLimit]);
return in_array($this->auth->id, $adminIds) ? [] : [$this->auth->id];
} elseif ($this->dataLimit == 'allAuth' || $this->dataLimit == 'allAuthAndOthers') {
// 取得拥有他所有权限的分组
$allAuthGroups = $this->auth->getAllAuthGroups($this->dataLimit);
// 取得分组内的所有管理员
$adminIds = $this->auth->getGroupAdmins($allAuthGroups);
}
$adminIds[] = $this->auth->id;
return array_unique($adminIds);
}
/**
* 从别名获取原始的逻辑运算符
* @param string $operator 逻辑运算符别名
* @return string 原始的逻辑运算符,无别名则原样返回
*/
protected function getOperatorByAlias(string $operator): string
{
$alias = [
'ne' => '<>',
'eq' => '=',
'gt' => '>',
'egt' => '>=',
'lt' => '<',
'elt' => '<=',
];
return $alias[$operator] ?? $operator;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace app\common\controller;
use Throwable;
use think\facade\Event;
use app\common\library\Auth;
use think\exception\HttpResponseException;
use app\common\library\token\TokenExpirationException;
class Frontend extends Api
{
/**
* 无需登录的方法
* 访问本控制器的此方法,无需会员登录
* @var array
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
* @var array
*/
protected array $noNeedPermission = [];
/**
* 权限类实例
* @var Auth
*/
protected Auth $auth;
/**
* 初始化
* @throws Throwable
* @throws HttpResponseException
*/
public function initialize(): void
{
parent::initialize();
$needLogin = !action_in_arr($this->noNeedLogin);
try {
// 初始化会员鉴权实例
$this->auth = Auth::instance();
$token = get_auth_token(['ba', 'user', 'token']);
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) {
$this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission)) {
$routePath = ($this->app->request->controllerPath ?? '') . '/' . $this->request->action(true);
if (!$this->auth->check($routePath)) {
$this->error(__('You have no permission'), [], 401);
}
}
}
// 会员验权和登录标签位
Event::trigger('frontendInit', $this->auth);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace app\common\event;
use Throwable;
use think\Request;
use ba\TableManager;
use think\facade\Db;
use think\facade\Log;
use app\admin\library\Auth;
use app\admin\model\SensitiveDataLog;
use app\admin\model\DataRecycle;
use app\admin\model\DataRecycleLog;
use app\admin\model\SensitiveData;
class Security
{
protected array $listenAction = ['edit', 'del'];
public function handle(Request $request): bool
{
$action = $request->action(true);
if (!in_array($action, $this->listenAction) || (!$request->isPost() && !$request->isDelete())) {
return true;
}
if ($action == 'del') {
$dataIds = $request->param('ids');
try {
$recycle = DataRecycle::where('status', 1)
->where('controller_as', $request->controllerPath)
->find();
if (!$recycle) {
return true;
}
$recycleData = Db::connect(TableManager::getConnection($recycle['connection']))
->name($recycle['data_table'])
->whereIn($recycle['primary_key'], $dataIds)
->select()
->toArray();
$recycleDataArr = [];
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
foreach ($recycleData as $recycleDatum) {
$recycleDataArr[] = [
'admin_id' => $adminId,
'recycle_id' => $recycle['id'],
'data' => json_encode($recycleDatum, JSON_UNESCAPED_UNICODE),
'connection' => $recycle['connection'],
'data_table' => $recycle['data_table'],
'primary_key' => $recycle['primary_key'],
'ip' => $request->ip(),
'useragent' => substr($request->server('HTTP_USER_AGENT'), 0, 255),
];
}
if (!$recycleDataArr) {
return true;
}
// saveAll 方法自带事务
$dataRecycleLogModel = new DataRecycleLog();
if (!$dataRecycleLogModel->saveAll($recycleDataArr)) {
Log::record('[ DataSecurity ] Failed to recycle data:' . var_export($recycleDataArr, true), 'warning');
}
} catch (Throwable $e) {
Log::record('[ DataSecurity ]' . $e->getMessage(), 'warning');
}
return true;
}
try {
$sensitiveData = SensitiveData::where('status', 1)
->where('controller_as', $request->controllerPath)
->find();
if (!$sensitiveData) {
return true;
}
$sensitiveData = $sensitiveData->toArray();
$dataId = $request->param($sensitiveData['primary_key']);
$editData = Db::connect(TableManager::getConnection($sensitiveData['connection']))
->name($sensitiveData['data_table'])
->field(array_keys($sensitiveData['data_fields']))
->where($sensitiveData['primary_key'], $dataId)
->find();
if (!$editData) {
return true;
}
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
$newData = $request->post();
foreach ($sensitiveData['data_fields'] as $field => $title) {
if (isset($editData[$field]) && isset($newData[$field]) && $editData[$field] != $newData[$field]) {
/*
* 其他跳过规则可添加至此处
* 1. 如果字段名中包含 password修改值为空则忽略修改值不为空则密码记录为 ******
*/
if (stripos('password', $field) !== false) {
if (!$newData[$field]) {
continue;
} else {
$newData[$field] = "******";
}
}
$sensitiveDataLog[] = [
'admin_id' => $adminId,
'sensitive_id' => $sensitiveData['id'],
'connection' => $sensitiveData['connection'],
'data_table' => $sensitiveData['data_table'],
'primary_key' => $sensitiveData['primary_key'],
'data_field' => $field,
'data_comment' => $title,
'id_value' => $dataId,
'before' => $editData[$field],
'after' => $newData[$field],
'ip' => $request->ip(),
'useragent' => substr($request->server('HTTP_USER_AGENT'), 0, 255),
];
}
}
if (!isset($sensitiveDataLog) || !$sensitiveDataLog) {
return true;
}
$sensitiveDataLogModel = new SensitiveDataLog();
if (!$sensitiveDataLogModel->saveAll($sensitiveDataLog)) {
Log::record('[ DataSecurity ] Sensitive data recording failed:' . var_export($sensitiveDataLog, true), 'warning');
}
} catch (Throwable $e) {
Log::record('[ DataSecurity ]' . $e->getMessage(), 'warning');
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\facade;
use think\Facade;
use app\common\library\token\Driver;
/**
* Token 门面类
* @see Driver
* @method array get(string $token) static 获取 token 的数据
* @method bool set(string $token, string $type, int $userId, int $expire = null) static 设置 token
* @method bool check(string $token, string $type, int $userId) static 检查token是否有效
* @method bool delete(string $token) static 删除一个token
* @method bool clear(string $type, int $userId) static 清理一个用户的所有token
* @method void tokenExpirationCheck(array $token) static 检查一个token是否过期过期则抛出token过期异常
*/
class Token extends Facade
{
protected static function getFacadeClass(): string
{
return 'app\common\library\Token';
}
}

562
app/common/library/Auth.php Normal file
View File

@@ -0,0 +1,562 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use think\facade\Db;
use think\facade\Event;
use think\facade\Config;
use app\common\model\User;
use think\facade\Validate;
use app\common\facade\Token;
/**
* 公共权限类(会员权限类)
* @property int $id 会员ID
* @property string $username 会员用户名
* @property string $nickname 会员昵称
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $password 密码密文
* @property string $salt 密码盐
*/
class Auth extends \ba\Auth
{
/**
* 需要登录时/无需登录时的响应状态代码
*/
public const LOGIN_RESPONSE_CODE = 303;
/**
* 需要登录标记 - 前台应清理 token、记录当前路由 path、跳转到登录页
*/
public const NEED_LOGIN = 'need login';
/**
* 已经登录标记 - 前台应跳转到基础路由
*/
public const LOGGED_IN = 'logged in';
/**
* token 入库 type
*/
public const TOKEN_TYPE = 'user';
/**
* 是否登录
* @var bool
*/
protected bool $loginEd = false;
/**
* 错误消息
* @var string
*/
protected string $error = '';
/**
* Model实例
* @var ?User
*/
protected ?User $model = null;
/**
* 令牌
* @var string
*/
protected string $token = '';
/**
* 刷新令牌
* @var string
*/
protected string $refreshToken = '';
/**
* 令牌默认有效期
* 可在 config/buildadmin.php 内修改默认值
* @var int
*/
protected int $keepTime = 86400;
/**
* 刷新令牌有效期
* @var int
*/
protected int $refreshTokenKeepTime = 2592000;
/**
* 允许输出的字段
* @var array
*/
protected array $allowFields = ['id', 'username', 'nickname', 'email', 'mobile', 'avatar', 'gender', 'birthday', 'money', 'score', 'join_time', 'motto', 'last_login_time', 'last_login_ip'];
public function __construct(array $config = [])
{
parent::__construct(array_merge([
'auth_group' => 'user_group', // 用户组数据表名
'auth_group_access' => '', // 用户-用户组关系表(关系字段)
'auth_rule' => 'user_rule', // 权限规则表
], $config));
$this->setKeepTime((int)Config::get('buildadmin.user_token_keep_time'));
}
/**
* 魔术方法-会员信息字段
* @param $name
* @return mixed 字段信息
*/
public function __get($name): mixed
{
return $this->model?->$name;
}
/**
* 初始化
* @access public
* @param array $options 传递给 /ba/Auth 的参数
* @return Auth
*/
public static function instance(array $options = []): Auth
{
$request = request();
if (!isset($request->userAuth)) {
$request->userAuth = new static($options);
}
return $request->userAuth;
}
/**
* 根据Token初始化会员登录态
* @param $token
* @return bool
* @throws Throwable
*/
public function init($token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
/**
* 过期检查,过期则抛出 @see TokenExpirationException
*/
Token::tokenExpirationCheck($tokenData);
$userId = intval($tokenData['user_id']);
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
$this->model = User::where('id', $userId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status != 'enable') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
return true;
}
}
$this->setError('Token login failed');
$this->reset();
return false;
}
/**
* 会员注册,可使用关键词参数方式调用:$auth->register('u18888888888', email: 'test@qq.com')
* @param string $username
* @param string $password
* @param string $mobile
* @param string $email
* @param int $group 会员分组 ID 号
* @param array $extend 扩展数据,如 ['status' => 'disable']
* @return bool
*/
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
{
$validate = Validate::rule([
'email|' . __('Email') => 'email|unique:user',
'mobile|' . __('Mobile') => 'mobile|unique:user',
'username|' . __('Username') => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'password|' . __('Password') => 'regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
]);
$params = [
'username' => $username,
'password' => $password,
'mobile' => $mobile,
'email' => $email,
];
if (!$validate->check($params)) {
$this->setError($validate->getError());
return false;
}
// 用户昵称
$nickname = preg_replace_callback('/1[3-9]\d{9}/', function ($matches) {
// 对 username 中出现的所有手机号进行脱敏处理
$mobile = $matches[0];
return substr($mobile, 0, 3) . '****' . substr($mobile, 7);
}, $username);
$ip = request()->ip();
$time = time();
$data = [
'group_id' => $group,
'nickname' => $nickname,
'join_ip' => $ip,
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable', // 状态:enable=启用,disable=禁用,使用 string 存储可以自定义其他状态
];
$data = array_merge($params, $data);
$data = array_merge($data, $extend);
Db::startTrans();
try {
$this->model = User::create($data);
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
Db::commit();
if ($password) {
$this->model->resetPassword($this->model->id, $password);
}
Event::trigger('userRegisterSuccess', $this->model);
} catch (Throwable $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
/**
* 会员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
public function login(string $username, string $password, bool $keep): bool
{
// 判断账户类型
$accountType = false;
$validate = Validate::rule([
'mobile' => 'mobile',
'email' => 'email',
'username' => 'regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$',
]);
if ($validate->check(['mobile' => $username])) $accountType = 'mobile';
if ($validate->check(['email' => $username])) $accountType = 'email';
if ($validate->check(['username' => $username])) $accountType = 'username';
if (!$accountType) {
$this->setError('Account not exist');
return false;
}
$this->model = User::where($accountType, $username)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status == 'disable') {
$this->setError('Account disabled');
return false;
}
// 登录失败重试检查
$userLoginRetry = Config::get('buildadmin.user_login_retry');
if ($userLoginRetry && $this->model->last_login_time) {
// 重置失败次数
if ($this->model->login_failure > 0 && time() - $this->model->last_login_time >= 86400) {
$this->model->login_failure = 0;
$this->model->save();
// 重获模型实例,避免单实例多次更新
$this->model = User::where($accountType, $username)->find();
}
if ($this->model->login_failure >= $userLoginRetry) {
$this->setError('Please try again after 1 day');
return false;
}
}
// 密码检查
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
}
// 清理 token
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
$this->loginSuccessful();
return true;
}
/**
* 直接登录会员账号
* @param int $userId 用户ID
* @return bool
* @throws Throwable
*/
public function direct(int $userId): bool
{
$this->model = User::find($userId);
if (!$this->model) return false;
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->loginSuccessful();
}
/**
* 检查旧密码是否正确
* @param $password
* @return bool
* @deprecated 请使用 verify_password 公共函数代替
*/
public function checkPassword($password): bool
{
return verify_password($password, $this->model->password, ['salt' => $this->model->salt]);
}
/**
* 登录成功
* @return bool
*/
public function loginSuccessful(): bool
{
if (!$this->model) {
return false;
}
$this->model->startTrans();
try {
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->loginEd = true;
if (!$this->token) {
$this->token = Random::uuid();
Token::set($this->token, self::TOKEN_TYPE, $this->model->id, $this->keepTime);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
/**
* 登录失败
* @return bool
*/
public function loginFailed(): bool
{
if (!$this->model) return false;
$this->model->startTrans();
try {
$this->model->login_failure++;
$this->model->last_login_time = time();
$this->model->last_login_ip = request()->ip();
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return $this->reset();
}
/**
* 退出登录
* @return bool
*/
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
/**
* 是否登录
* @return bool
*/
public function isLogin(): bool
{
return $this->loginEd;
}
/**
* 获取会员模型
* @return User
*/
public function getUser(): User
{
return $this->model;
}
/**
* 获取会员Token
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* 设置刷新Token
* @param int $keepTime
* @return void
*/
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
/**
* 获取会员刷新Token
* @return string
*/
public function getRefreshToken(): string
{
return $this->refreshToken;
}
/**
* 获取会员信息 - 只输出允许输出的字段
* @return array
*/
public function getUserInfo(): array
{
if (!$this->model) return [];
$info = $this->model->toArray();
$info = array_intersect_key($info, array_flip($this->getAllowFields()));
$info['token'] = $this->getToken();
$info['refresh_token'] = $this->getRefreshToken();
return $info;
}
/**
* 获取允许输出字段
* @return array
*/
public function getAllowFields(): array
{
return $this->allowFields;
}
/**
* 设置允许输出字段
* @param $fields
* @return void
*/
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
/**
* 设置Token有效期
* @param int $keepTime
* @return void
*/
public function setKeepTime(int $keepTime = 0): void
{
$this->keepTime = $keepTime;
}
public function check(string $name, int $uid = 0, string $relation = 'or', string $mode = 'url'): bool
{
return parent::check($name, $uid ?: $this->id, $relation, $mode);
}
public function getRuleList(int $uid = 0): array
{
return parent::getRuleList($uid ?: $this->id);
}
public function getRuleIds(int $uid = 0): array
{
return parent::getRuleIds($uid ?: $this->id);
}
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
}
/**
* 是否是拥有所有权限的会员
* @return bool
* @throws Throwable
*/
public function isSuperUser(): bool
{
return in_array('*', $this->getRuleIds());
}
/**
* 设置错误消息
* @param string $error
* @return Auth
*/
public function setError(string $error): Auth
{
$this->error = $error;
return $this;
}
/**
* 获取错误消息
* @return string
*/
public function getError(): string
{
return $this->error ? __($this->error) : '';
}
/**
* 属性重置(注销、登录失败、重新初始化等将单例数据销毁)
*/
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
Token::delete($this->token);
}
$this->token = '';
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->setError('');
$this->setKeepTime((int)Config::get('buildadmin.user_token_keep_time'));
return true;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace app\common\library;
use Throwable;
use think\facade\Lang;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件类
* 继承PHPMailer并初始化好了站点系统配置中的邮件配置信息
*/
class Email extends PHPMailer
{
/**
* 是否已在管理后台配置好邮件服务
* @var bool
*/
public bool $configured = false;
/**
* 默认配置
* @var array
*/
public array $options = [
'charset' => 'utf-8', //编码格式
'debug' => true, //调式模式
'lang' => 'zh_cn',
];
/**
* 构造函数
* @param array $options
* @throws Throwable
*/
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
parent::__construct($this->options['debug']);
$langSet = Lang::getLangSet();
if ($langSet == 'zh-cn' || !$langSet) $langSet = 'zh_cn';
$this->options['lang'] = $this->options['lang'] ?: $langSet;
$this->setLanguage($this->options['lang'], root_path() . 'vendor' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR);
$this->CharSet = $this->options['charset'];
$sysMailConfig = get_sys_config('', 'mail');
$this->configured = true;
foreach ($sysMailConfig as $item) {
if (!$item) {
$this->configured = false;
}
}
if ($this->configured) {
$this->Host = $sysMailConfig['smtp_server'];
$this->SMTPAuth = true;
$this->Username = $sysMailConfig['smtp_user'];
$this->Password = $sysMailConfig['smtp_pass'];
$this->SMTPSecure = $sysMailConfig['smtp_verification'] == 'SSL' ? self::ENCRYPTION_SMTPS : self::ENCRYPTION_STARTTLS;
$this->Port = $sysMailConfig['smtp_port'];
$this->setFrom($sysMailConfig['smtp_sender_mail'], $sysMailConfig['smtp_user']);
}
}
public function setSubject($subject): void
{
$this->Subject = "=?utf-8?B?" . base64_encode($subject) . "?=";
}
}

156
app/common/library/Menu.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
namespace app\common\library;
use Throwable;
use app\admin\model\AdminRule;
use app\admin\model\UserRule;
/**
* 菜单规则管理类
*/
class Menu
{
/**
* @param array $menu
* @param int|string $parent 父级规则name或id
* @param string $mode 添加模式(规则重复时):cover=覆盖旧菜单,rename=重命名新菜单,ignore=忽略
* @param string $position 位置:backend=后台,frontend=前台
* @return void
* @throws Throwable
*/
public static function create(array $menu, int|string $parent = 0, string $mode = 'cover', string $position = 'backend'): void
{
$pid = 0;
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$parentRule = $model->where((is_numeric($parent) ? 'id' : 'name'), $parent)->find();
if ($parentRule) {
$pid = $parentRule['id'];
}
foreach ($menu as $item) {
if (!self::requiredAttrCheck($item)) {
continue;
}
// 属性
$item['status'] = 1;
if (!isset($item['pid'])) {
$item['pid'] = $pid;
}
$sameOldMenu = $model->where('name', $item['name'])->find();
if ($sameOldMenu) {
// 存在相同名称的菜单规则
if ($mode == 'cover') {
$sameOldMenu->save($item);
} elseif ($mode == 'rename') {
$count = $model->where('name', $item['name'])->count();
$item['name'] = $item['name'] . '-CONFLICT-' . $count;
$item['path'] = $item['path'] . '-CONFLICT-' . $count;
$item['title'] = $item['title'] . '-CONFLICT-' . $count;
$sameOldMenu = $model->create($item);
} elseif ($mode == 'ignore') {
// 忽略同名菜单时,当前 pid 下没有同名菜单,则创建同名新菜单,以保证所有新增菜单的上下级结构
$sameOldMenu = $model
->where('name', $item['name'])
->where('pid', $item['pid'])
->find();
if (!$sameOldMenu) {
$sameOldMenu = $model->create($item);
}
}
} else {
$sameOldMenu = $model->create($item);
}
if (!empty($item['children'])) {
self::create($item['children'], $sameOldMenu['id'], $mode, $position);
}
}
}
/**
* 删菜单
* @param string|int $id 规则name或id
* @param bool $recursion 是否递归删除子级菜单、是否删除自身,是否删除上级空菜单
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function delete(string|int $id, bool $recursion = false, string $position = 'backend'): bool
{
if (!$id) {
return true;
}
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return true;
}
$children = $model->where('pid', $menuRule['id'])->select()->toArray();
if ($recursion && $children) {
foreach ($children as $child) {
self::delete($child['id'], true, $position);
}
}
if (!$children || $recursion) {
$menuRule->delete();
self::delete($menuRule->pid, false, $position);
}
return true;
}
/**
* 启用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function enable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 1;
$menuRule->save();
return true;
}
/**
* 禁用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
public static function disable(string|int $id, string $position = 'backend'): bool
{
$model = $position == 'backend' ? new AdminRule() : new UserRule();
$menuRule = $model->where((is_numeric($id) ? 'id' : 'name'), $id)->find();
if (!$menuRule) {
return false;
}
$menuRule->status = 0;
$menuRule->save();
return true;
}
public static function requiredAttrCheck($menu): bool
{
$attrs = ['type', 'title', 'name'];
foreach ($attrs as $attr) {
if (!array_key_exists($attr, $menu)) {
return false;
}
if (!$menu[$attr]) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace app\common\library;
/**
* 雪花ID生成类
*/
class SnowFlake
{
/**
* 起始时间戳
* @var int
*/
private const EPOCH = 1672502400000;
/**
* @var int
*/
private const max41bit = 1099511627775;
/**
* 机器节点 10bit
* @var int
*/
protected static int $machineId = 1;
/**
* 序列号
* @var int
*/
protected static int $count = 0;
/**
* 最后一次生成ID的时间偏移量
* @var int
*/
protected static int $last = 0;
/**
* 设置机器节点
* @param int $mId 机器节点id
* @return void
*/
public static function setMachineId(int $mId): void
{
self::$machineId = $mId;
}
/**
* 生成雪花ID
* @return float|int
*/
public static function generateParticle(): float|int
{
// 当前时间 42bit
$time = (int)floor(microtime(true) * 1000);
// 时间偏移量
$time -= self::EPOCH;
// 起始时间戳加上时间偏移量并转为二进制
$base = decbin(self::max41bit + $time);
// 追加节点机器id
if (!is_null(self::$machineId)) {
$machineId = str_pad(decbin(self::$machineId), 10, "0", STR_PAD_LEFT);
$base .= $machineId;
}
// 序列号
if ($time == self::$last) {
self::$count++;
} else {
self::$count = 0;
}
// 追加序列号部分
$sequence = str_pad(decbin(self::$count), 12, "0", STR_PAD_LEFT);
$base .= $sequence;
// 保存生成ID的时间偏移量
self::$last = $time;
// 返回64bit二进制数的十进制标识
return bindec($base);
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace app\common\library;
use think\helper\Arr;
use think\helper\Str;
use think\facade\Config;
use InvalidArgumentException;
use app\common\library\token\TokenExpirationException;
/**
* Token 管理类
*/
class Token
{
/**
* Token 实例
* @var array
* @uses Token 数组项
*/
public array $instance = [];
/**
* token驱动类句柄
* @var ?object
*/
public ?object $handler = null;
/**
* 驱动类命名空间
* @var string
*/
protected string $namespace = '\\app\\common\\library\\token\\driver\\';
/**
* 获取驱动句柄
* @param string|null $name
* @return object
*/
public function getDriver(?string $name = null): object
{
if (!is_null($this->handler)) {
return $this->handler;
}
$name = $name ?: $this->getDefaultDriver();
if (is_null($name)) {
throw new InvalidArgumentException(
sprintf(
'Unable to resolve NULL driver for [%s].',
static::class
)
);
}
return $this->createDriver($name);
}
/**
* 创建驱动句柄
* @param string $name
* @return object
*/
protected function createDriver(string $name): object
{
$type = $this->resolveType($name);
$method = 'create' . Str::studly($type) . 'Driver';
$params = $this->resolveParams($name);
if (method_exists($this, $method)) {
return $this->$method(...$params);
}
$class = $this->resolveClass($type);
if (isset($this->instance[$type])) {
return $this->instance[$type];
}
return new $class(...$params);
}
/**
* 默认驱动
* @return string
*/
protected function getDefaultDriver(): string
{
return $this->getConfig('default');
}
/**
* 获取驱动配置
* @param string|null $name 要获取的配置项不传递获取完整token配置
* @param mixed $default
* @return array|string
*/
protected function getConfig(?string $name = null, $default = null): array|string
{
if (!is_null($name)) {
return Config::get('buildadmin.token.' . $name, $default);
}
return Config::get('buildadmin.token');
}
/**
* 获取驱动配置参数
* @param $name
* @return array
*/
protected function resolveParams($name): array
{
$config = $this->getStoreConfig($name);
return [$config];
}
/**
* 获取驱动类
* @param string $type
* @return string
*/
protected function resolveClass(string $type): string
{
if ($this->namespace || str_contains($type, '\\')) {
$class = str_contains($type, '\\') ? $type : $this->namespace . Str::studly($type);
if (class_exists($class)) {
return $class;
}
}
throw new InvalidArgumentException("Driver [$type] not supported.");
}
/**
* 获取驱动配置
* @param string $store
* @param string|null $name
* @param mixed $default
* @return array|string
*/
protected function getStoreConfig(string $store, ?string $name = null, $default = null): array|string
{
if ($config = $this->getConfig("stores.$store")) {
return Arr::get($config, $name, $default);
}
throw new InvalidArgumentException("Store [$store] not found.");
}
/**
* 获取驱动类型
* @param string $name
* @return string
*/
protected function resolveType(string $name): string
{
return $this->getStoreConfig($name, 'type', 'Mysql');
}
/**
* 设置token
* @param string $token
* @param string $type
* @param int $user_id
* @param int|null $expire
* @return bool
*/
public function set(string $token, string $type, int $user_id, ?int $expire = null): bool
{
return $this->getDriver()->set($token, $type, $user_id, $expire);
}
/**
* 获取token
* @param string $token
* @param bool $expirationException
* @return array
*/
public function get(string $token, bool $expirationException = true): array
{
return $this->getDriver()->get($token, $expirationException);
}
/**
* 检查token
* @param string $token
* @param string $type
* @param int $user_id
* @param bool $expirationException
* @return bool
*/
public function check(string $token, string $type, int $user_id, bool $expirationException = true): bool
{
return $this->getDriver()->check($token, $type, $user_id, $expirationException);
}
/**
* 删除token
* @param string $token
* @return bool
*/
public function delete(string $token): bool
{
return $this->getDriver()->delete($token);
}
/**
* 清理指定用户token
* @param string $type
* @param int $user_id
* @return bool
*/
public function clear(string $type, int $user_id): bool
{
return $this->getDriver()->clear($type, $user_id);
}
/**
* Token过期检查
* @throws TokenExpirationException
*/
public function tokenExpirationCheck(array $token): void
{
if (isset($token['expire_time']) && $token['expire_time'] <= time()) {
throw new TokenExpirationException();
}
}
}

View File

@@ -0,0 +1,341 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use ba\Filesystem;
use think\Exception;
use think\helper\Str;
use think\facade\Config;
use think\facade\Validate;
use think\file\UploadedFile;
use InvalidArgumentException;
use think\validate\ValidateRule;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
/**
* 上传
*/
class Upload
{
/**
* 上传配置
*/
protected array $config = [];
/**
* 被上传文件
*/
protected ?UploadedFile $file = null;
/**
* 是否是图片
*/
protected bool $isImage = false;
/**
* 文件信息
*/
protected array $fileInfo;
/**
* 上传驱动
*/
protected array $driver = [
'name' => 'local', // 默认驱动:local=本地
'handler' => [], // 驱动句柄
'namespace' => '\\app\\common\\library\\upload\\driver\\', // 驱动类的命名空间
];
/**
* 存储子目录
*/
protected string $topic = 'default';
/**
* 构造方法
* @param ?UploadedFile $file 上传的文件
* @param array $config 配置
* @throws Throwable
*/
public function __construct(?UploadedFile $file = null, array $config = [])
{
$upload = Config::get('upload');
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
/**
* 设置上传文件
* @param ?UploadedFile $file
* @return Upload
* @throws Throwable
*/
public function setFile(?UploadedFile $file): Upload
{
if (empty($file)) {
throw new Exception(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$fileInfo['suffix'] = $suffix;
$fileInfo['type'] = $file->getMime();
$fileInfo['size'] = $file->getSize();
$fileInfo['name'] = $file->getOriginalName();
$fileInfo['sha1'] = $file->sha1();
$this->file = $file;
$this->fileInfo = $fileInfo;
return $this;
}
/**
* 设置上传驱动
*/
public function setDriver(string $driver): Upload
{
$this->driver['name'] = $driver;
return $this;
}
/**
* 获取上传驱动句柄
* @param ?string $driver 驱动名称
* @param bool $noDriveException 找不到驱动是否抛出异常
* @return bool|Driver
*/
public function getDriver(?string $driver = null, bool $noDriveException = true): bool|Driver
{
if (is_null($driver)) {
$driver = $this->driver['name'];
}
if (!isset($this->driver['handler'][$driver])) {
$class = $this->resolveDriverClass($driver);
if ($class) {
$this->driver['handler'][$driver] = new $class();
} elseif ($noDriveException) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
}
return $this->driver['handler'][$driver] ?? false;
}
/**
* 获取驱动类
*/
protected function resolveDriverClass(string $driver): bool|string
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . Str::studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
/**
* 设置存储子目录
*/
public function setTopic(string $topic): Upload
{
$this->topic = $topic;
return $this;
}
/**
* 检查是否是图片并设置好相关属性
* @return bool
* @throws Throwable
*/
protected function checkIsImage(): bool
{
if (in_array($this->fileInfo['type'], ['image/gif', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/png', 'image/webp']) || in_array($this->fileInfo['suffix'], ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
$imgInfo = getimagesize($this->file->getPathname());
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new Exception(__('The uploaded image file is not a valid image'));
}
$this->fileInfo['width'] = $imgInfo[0];
$this->fileInfo['height'] = $imgInfo[1];
$this->isImage = true;
return true;
}
return false;
}
/**
* 上传的文件是否为图片
* @return bool
*/
public function isImage(): bool
{
return $this->isImage;
}
/**
* 获取文件后缀
* @return string
*/
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?: 'file';
}
/**
* 获取文件保存路径和名称
* @param ?string $saveName
* @param ?string $filename
* @param ?string $sha1
* @return string
*/
public function getSaveName(?string $saveName = null, ?string $filename = null, ?string $sha1 = null): string
{
if ($filename) {
$suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
} else {
$suffix = $this->fileInfo['suffix'];
}
$filename = $filename ?: $this->fileInfo['name'];
$sha1 = $sha1 ?: $this->fileInfo['sha1'];
$replaceArr = [
'{topic}' => $this->topic,
'{year}' => date("Y"),
'{mon}' => date("m"),
'{day}' => date("d"),
'{hour}' => date("H"),
'{min}' => date("i"),
'{sec}' => date("s"),
'{random}' => Random::build(),
'{random32}' => Random::build('alnum', 32),
'{fileName}' => $this->getFileNameSubstr($filename, $suffix),
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{fileSha1}' => $sha1,
];
$saveName = $saveName ?: $this->config['save_name'];
return Filesystem::fsFit(str_replace(array_keys($replaceArr), array_values($replaceArr), $saveName));
}
/**
* 验证文件是否符合上传配置要求
* @throws Throwable
*/
public function validates(): void
{
if (empty($this->file)) {
throw new Exception(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size']);
$mime = $this->checkConfig($this->config['allowed_mime_types']);
$suffix = $this->checkConfig($this->config['allowed_suffixes']);
// 文件大小
$fileValidateRule = ValidateRule::fileSize($size, __('The uploaded file is too large (%sMiB), Maximum file size:%sMiB', [
round($this->fileInfo['size'] / pow(1024, 2), 2),
round($size / pow(1024, 2), 2)
]));
// 文件后缀
if ($suffix) {
$fileValidateRule->fileExt($suffix, __('The uploaded file format is not allowed'));
}
// 文件 MIME 类型
if ($mime) {
$fileValidateRule->fileMime($mime, __('The uploaded file format is not allowed'));
}
// 图片文件利用tp内置规则做一些额外检查
if ($this->checkIsImage()) {
$fileValidateRule->image("{$this->fileInfo['width']},{$this->fileInfo['height']}", __('The uploaded image file is not a valid image'));
}
Validate::failException()
->rule([
'file' => $fileValidateRule,
'topic' => ValidateRule::is('alphaDash', __('Topic format error')),
'driver' => ValidateRule::is('alphaDash', __('Driver %s not supported', [$this->driver['name']])),
])
->check([
'file' => $this->file,
'topic' => $this->topic,
'driver' => $this->driver['name'],
]);
}
/**
* 上传文件
* @param ?string $saveName
* @param int $adminId
* @param int $userId
* @return array
* @throws Throwable
*/
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
$saveName = $saveName ?: $this->getSaveName();
$params = [
'topic' => $this->topic,
'admin_id' => $adminId,
'user_id' => $userId,
'url' => $driver->url($saveName, false),
'width' => $this->fileInfo['width'] ?? 0,
'height' => $this->fileInfo['height'] ?? 0,
'name' => $this->getFileNameSubstr($this->fileInfo['name'], $this->fileInfo['suffix'], 100) . ".{$this->fileInfo['suffix']}",
'size' => $this->fileInfo['size'],
'mimetype' => $this->fileInfo['type'],
'storage' => $this->driver['name'],
'sha1' => $this->fileInfo['sha1']
];
// 附件数据入库 - 不依赖模型新增前事件,确保入库前文件已经移动完成
$attachment = Attachment::where('sha1', $params['sha1'])
->where('topic', $params['topic'])
->where('storage', $params['storage'])
->find();
if ($attachment && $driver->exists($attachment->url)) {
$attachment->quote++;
$attachment->last_upload_time = time();
} else {
$driver->save($this->file, $saveName);
$attachment = new Attachment();
$attachment->data(array_filter($params));
}
$attachment->save();
return $attachment->toArray();
}
/**
* 获取文件名称字符串的子串
*/
public function getFileNameSubstr(string $fileName, string $suffix, int $length = 15): string
{
// 对 $fileName 中不利于传输的字符串进行过滤
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
/**
* 检查配置项,将 string 类型的配置转换为 array并且将所有字母转换为小写
*/
protected function checkConfig($configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
} else {
return explode(',', strtolower($configItem));
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\common\library\token;
use think\facade\Config;
/**
* Token 驱动抽象类
*/
abstract class Driver
{
/**
* 具体驱动的句柄 Mysql|Redis
* @var object
*/
protected object $handler;
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 设置 token
* @param string $token Token
* @param string $type Type
* @param int $userId 用户ID
* @param ?int $expire 过期时间
* @return bool
*/
abstract public function set(string $token, string $type, int $userId, ?int $expire = null): bool;
/**
* 获取 token 的数据
* @param string $token Token
* @return array
*/
abstract public function get(string $token): array;
/**
* 检查token是否有效
* @param string $token
* @param string $type
* @param int $userId
* @return bool
*/
abstract public function check(string $token, string $type, int $userId): bool;
/**
* 删除一个token
* @param string $token
* @return bool
*/
abstract public function delete(string $token): bool;
/**
* 清理一个用户的所有token
* @param string $type
* @param int $userId
* @return bool
*/
abstract public function clear(string $type, int $userId): bool;
/**
* 返回句柄对象
* @access public
* @return object|null
*/
public function handler(): ?object
{
return $this->handler;
}
/**
* @param string $token
* @return string
*/
protected function getEncryptedToken(string $token): string
{
$config = Config::get('buildadmin.token');
return hash_hmac($config['algo'], $token, $config['key']);
}
/**
* @param int $expireTime
* @return int
*/
protected function getExpiredIn(int $expireTime): int
{
return $expireTime ? max(0, $expireTime - time()) : 365 * 86400;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\common\library\token;
use think\Exception;
/**
* Token过期异常
*/
class TokenExpirationException extends Exception
{
public function __construct(protected $message = '', protected $code = 409, protected $data = [])
{
parent::__construct($message, $code);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use think\facade\Db;
use think\facade\Cache;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Mysql extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* 构造函数
* @access public
* @param array $options 参数
*/
public function __construct(array $options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if ($this->options['name']) {
$this->handler = Db::connect($this->options['name'])->name($this->options['table']);
} else {
$this->handler = Db::name($this->options['table']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$this->handler->insert([
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
]);
// 每隔48小时清理一次过期Token
$time = time();
$lastCacheCleanupTime = Cache::get('last_cache_cleanup_time');
if (!$lastCacheCleanupTime || $lastCacheCleanupTime < $time - 172800) {
Cache::set('last_cache_cleanup_time', $time);
$this->handler->where('expire_time', '<', time())->where('expire_time', '>', 0)->delete();
}
return true;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if (!$data) {
return [];
}
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 返回剩余有效时间
return $data;
}
/**
* @throws Throwable
*/
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) return false;
return $data['type'] == $type && $data['user_id'] == $userId;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$this->handler->where('type', $type)->where('user_id', $userId)->delete();
return true;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use BadFunctionCallException;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Redis extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* Token 过期后缓存继续保留的时间(s)
*/
protected int $expiredHold = 60 * 60 * 24 * 2;
/**
* 构造函数
* @access public
* @param array $options 参数
* @throws Throwable
*/
public function __construct(array $options = [])
{
if (!extension_loaded('redis')) {
throw new BadFunctionCallException('未安装redis扩展');
}
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
$this->handler = new \Redis();
if ($this->options['persistent']) {
$this->handler->pconnect($this->options['host'], $this->options['port'], $this->options['timeout'], 'persistent_id_' . $this->options['select']);
} else {
$this->handler->connect($this->options['host'], $this->options['port'], $this->options['timeout']);
}
if ('' != $this->options['password']) {
$this->handler->auth($this->options['password']);
}
if (false !== $this->options['select']) {
$this->handler->select($this->options['select']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$tokenInfo = [
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
];
$tokenInfo = json_encode($tokenInfo, JSON_UNESCAPED_UNICODE);
if ($expire) {
$expire += $this->expiredHold;
$result = $this->handler->setex($token, $expire, $tokenInfo);
} else {
$result = $this->handler->set($token, $tokenInfo);
}
$this->handler->sAdd($this->getUserKey($type, $userId), $token);
return $result;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$key = $this->getEncryptedToken($token);
$data = $this->handler->get($key);
if (is_null($data) || false === $data) {
return [];
}
$data = json_decode($data, true);
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 过期时间
return $data;
}
/**
* @throws Throwable
*/
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) return false;
return $data['type'] == $type && $data['user_id'] == $userId;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$data = $this->get($token);
if ($data) {
$key = $this->getEncryptedToken($token);
$this->handler->del($key);
$this->handler->sRem($this->getUserKey($data['type'], $data['user_id']), $key);
}
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$userKey = $this->getUserKey($type, $userId);
$keys = $this->handler->sMembers($userKey);
$this->handler->del($userKey);
$this->handler->del($keys);
return true;
}
/**
* 获取会员的key
* @param $type
* @param $userId
* @return string
*/
protected function getUserKey($type, $userId): string
{
return $this->options['prefix'] . $type . '-' . $userId;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace app\common\library\upload;
use think\file\UploadedFile;
/**
* 上传驱动抽象类
*/
abstract class Driver
{
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
abstract public function save(UploadedFile $file, string $saveName): bool;
/**
* 删除文件
* @param string $saveName
* @return bool
*/
abstract public function delete(string $saveName): bool;
/**
* 获取资源 URL 地址;
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
abstract public function url(string $saveName, string|bool $domain = true, string $default = ''): string;
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
abstract public function exists(string $saveName): bool;
}

View File

@@ -0,0 +1,148 @@
<?php
namespace app\common\library\upload\driver;
use ba\Filesystem;
use think\facade\Config;
use think\file\UploadedFile;
use think\exception\FileException;
use app\common\library\upload\Driver;
/**
* 上传到本地磁盘的驱动
* @see Driver
*/
class Local extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = Config::get('filesystem.disks.public');
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
}
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
public function save(UploadedFile $file, string $saveName): bool
{
$savePathInfo = pathinfo($saveName);
$saveFullPath = $this->getFullPath($saveName);
// cgi 直接 move
if (request()->isCgi()) {
$file->move($saveFullPath, $savePathInfo['basename']);
return true;
}
set_error_handler(function ($type, $msg) use (&$error) {
$error = $msg;
});
// 建立文件夹
if (!is_dir($saveFullPath) && !mkdir($saveFullPath, 0755, true)) {
restore_error_handler();
throw new FileException(sprintf('Unable to create the "%s" directory (%s)', $saveFullPath, strip_tags($error)));
}
// cli 使用 rename
$saveName = $this->getFullPath($saveName, true);
if (!rename($file->getPathname(), $saveName)) {
restore_error_handler();
throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s)', $file->getPathname(), $saveName, strip_tags($error)));
}
restore_error_handler();
@chmod($saveName, 0666 & ~umask());
return true;
}
/**
* 删除文件
* @param string $saveName
* @return bool
*/
public function delete(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
if ($this->exists($saveFullName)) {
@unlink($saveFullName);
}
Filesystem::delEmptyDir(dirname($saveFullName));
return true;
}
/**
* 获取资源 URL 地址
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
public function url(string $saveName, string|bool $domain = true, string $default = ''): string
{
$saveName = $this->clearRootPath($saveName);
if ($domain === true) {
$domain = '//' . request()->host();
} elseif ($domain === false) {
$domain = '';
}
$saveName = $saveName ?: $default;
if (!$saveName) return $domain;
$regex = "/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i";
if (preg_match('/^http(s)?:\/\//', $saveName) || preg_match($regex, $saveName) || $domain === false) {
return $saveName;
}
return str_replace('\\', '/', $domain . $saveName);
}
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
public function exists(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
return file_exists($saveFullName);
}
/**
* 获取文件的完整存储路径
* @param string $saveName
* @param bool $baseName 是否包含文件名
* @return string
*/
public function getFullPath(string $saveName, bool $baseName = false): string
{
$savePathInfo = pathinfo($saveName);
$root = $this->getRootPath();
$dirName = $savePathInfo['dirname'] . '/';
// 以 root 路径开始时单独返回,避免重复调用此方法时造成 $dirName 的错误拼接
if (str_starts_with($saveName, $root)) {
return Filesystem::fsFit($baseName || !isset($savePathInfo['extension']) ? $saveName : $dirName);
}
return Filesystem::fsFit($root . $dirName . ($baseName ? $savePathInfo['basename'] : ''));
}
public function clearRootPath(string $saveName): string
{
return str_replace($this->getRootPath(), '', Filesystem::fsFit($saveName));
}
public function getRootPath(): string
{
return Filesystem::fsFit(str_replace($this->options['url'], '', $this->options['root']));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\middleware;
use Closure;
use Throwable;
use think\facade\Config;
use app\admin\model\AdminLog as AdminLogModel;
class AdminLog
{
/**
* 写入管理日志
* @throws Throwable
*/
public function handle($request, Closure $next)
{
$response = $next($request);
if (($request->isPost() || $request->isDelete()) && Config::get('buildadmin.auto_write_admin_log')) {
AdminLogModel::instance()->record();
}
return $response;
}
}

View File

@@ -0,0 +1,66 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2021 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\middleware;
use Closure;
use think\Request;
use think\Response;
use think\facade\Config;
/**
* 跨域请求支持
* 安全起见,只支持了配置中的域名
*/
class AllowCrossDomain
{
protected array $header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 1800,
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
];
/**
* 跨域请求检测
* @access public
* @param Request $request
* @param Closure $next
* @param array|null $header
* @return Response
*/
public function handle(Request $request, Closure $next, ?array $header = []): Response
{
$header = !empty($header) ? array_merge($this->header, $header) : $this->header;
$origin = $request->header('origin');
if ($origin && !isset($header['Access-Control-Allow-Origin'])) {
$info = parse_url($origin);
// 获取跨域配置
$corsDomain = explode(',', Config::get('buildadmin.cors_request_domain'));
$corsDomain[] = $request->host(true);
if (in_array("*", $corsDomain) || in_array($origin, $corsDomain) || (isset($info['host']) && in_array($info['host'], $corsDomain))) {
$header['Access-Control-Allow-Origin'] = $origin;
}
}
if ($request->isOptions()) {
return response('', 204, $header);
}
$request->allowCrossDomainHeaders = $header;
return $next($request)->header($header);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use think\facade\Event;
use app\admin\model\Admin;
use app\common\library\Upload;
use think\model\relation\BelongsTo;
/**
* Attachment模型
* @property string url 文件物理路径
* @property int quote 上传(引用)次数
* @property int last_upload_time 最后上传时间
*/
class Attachment extends Model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
protected $append = [
'suffix',
'full_url'
];
/**
* 上传类实例,可以通过它调用上传文件驱动,且驱动类具有静态缓存
*/
protected static Upload $upload;
protected static function init(): void
{
self::$upload = new Upload();
}
public function getSuffixAttr($value, $row): string
{
if ($row['name']) {
$suffix = strtolower(pathinfo($row['name'], PATHINFO_EXTENSION));
return $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
}
return 'file';
}
public function getFullUrlAttr($value, $row): string
{
$driver = self::$upload->getDriver($row['storage'], false);
return $driver ? $driver->url($row['url']) : full_url($row['url']);
}
/**
* 新增前
* @throws Throwable
*/
protected static function onBeforeInsert($model): bool
{
$repeat = $model->where([
['sha1', '=', $model->sha1],
['topic', '=', $model->topic],
['storage', '=', $model->storage],
])->find();
if ($repeat) {
$driver = self::$upload->getDriver($repeat->storage, false);
if ($driver && !$driver->exists($repeat->url)) {
$repeat->delete();
return true;
} else {
$repeat->quote++;
$repeat->last_upload_time = time();
$repeat->save();
return false;
}
}
return true;
}
/**
* 新增后
*/
protected static function onAfterInsert($model): void
{
Event::trigger('AttachmentInsert', $model);
if (!$model->last_upload_time) {
$model->quote = 1;
$model->last_upload_time = time();
$model->save();
}
}
/**
* 删除后
*/
protected static function onAfterDelete($model): void
{
Event::trigger('AttachmentDel', $model);
$driver = self::$upload->getDriver($model->storage, false);
if ($driver && $driver->exists($model->url)) {
$driver->delete($model->url);
}
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use app\admin\model\Config as adminConfigModel;
class Config extends Model
{
/**
* 添加系统配置分组
* @throws Throwable
*/
public static function addConfigGroup(string $key, string $value): bool
{
return self::addArrayItem('config_group', $key, $value);
}
/**
* 删除系统配置分组
* @throws Throwable
*/
public static function removeConfigGroup(string $key): bool
{
if (adminConfigModel::where('group', $key)->find()) return false;
return self::removeArrayItem('config_group', $key);
}
/**
* 添加系统快捷配置入口
* @throws Throwable
*/
public static function addQuickEntrance(string $key, string $value): bool
{
return self::addArrayItem('config_quick_entrance', $key, $value);
}
/**
* 删除系统快捷配置入口
* @throws Throwable
*/
public static function removeQuickEntrance(string $key): bool
{
return self::removeArrayItem('config_quick_entrance', $key);
}
/**
* 为Array类型的配置项添加元素
* @throws Throwable
*/
public static function addArrayItem(string $name, string $key, string $value): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
foreach ($configRow->value as $item) {
if ($item['key'] == $key) {
return false;
}
}
$configRow->value = array_merge($configRow->value, [['key' => $key, 'value' => $value]]);
$configRow->save();
return true;
}
/**
* 删除Array类型配置项的一个元素
* @throws Throwable
*/
public static function removeArrayItem(string $name, string $key): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
$configRowValue = $configRow->value;
foreach ($configRowValue as $iKey => $item) {
if ($item['key'] == $key) {
unset($configRowValue[$iKey]);
}
}
$configRow->value = $configRowValue;
$configRow->save();
return true;
}
}

51
app/common/model/User.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 会员公共模型
* @property int $id 会员ID
* @property string $password 密码密文
* @property string $salt 密码盐(废弃待删)
* @property int $login_failure 登录失败次数
* @property string $last_login_time 上次登录时间
* @property string $last_login_ip 上次登录IP
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $status 状态:enable=启用,disable=禁用,...(string存储可自定义其他)
*/
class User extends Model
{
protected $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function resetPassword($uid, $newPassword): int|User
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
/**
* 用户的余额是不可以直接进行修改的,请通过 UserMoneyLog 模型插入记录来实现自动修改余额
* 此处定义上 money 的修改器仅为防止直接对余额的修改造成数据错乱
*/
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace app\common\model;
use think\model;
class UserMoneyLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getBeforeAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setBeforeAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getAfterAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setAfterAttr($value): string
{
return bcmul($value, 100, 2);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace app\common\model;
use think\model;
class UserScoreLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
}

View File

@@ -0,0 +1,34 @@
<?php
namespace app\common\service;
use think\Service;
use think\facade\Event;
use app\admin\library\module\Server;
class moduleService extends Service
{
public function register(): void
{
$this->moduleAppInit();
}
public function moduleAppInit(): void
{
$installed = Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR);
foreach ($installed as $item) {
if ($item['state'] != 1) {
continue;
}
$moduleClass = Server::getClass($item['uid']);
if (class_exists($moduleClass)) {
if (method_exists($moduleClass, 'AppInit')) {
Event::listen('AppInit', function () use ($moduleClass) {
$handle = new $moduleClass();
$handle->AppInit();
});
}
}
}
}
}

17
app/event.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];

11
app/middleware.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class,
\think\middleware\Throttle::class,
];

10
app/provider.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
use app\ExceptionHandle;
use app\Request;
// 容器Provider定义文件
return [
'think\Request' => Request::class,
'think\exception\Handle' => ExceptionHandle::class,
];

11
app/service.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
use app\AppService;
use app\common\service\moduleService;
// 系统服务定义文件
// 服务在完成全局初始化之后执行
return [
AppService::class,
moduleService::class,
];

57
composer.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "wonderful-code/buildadmin",
"description": "Build your admin framework",
"type": "project",
"keywords": [
"buildadmin",
"thinkphp"
],
"homepage": "https://uni.buildadmin.com",
"license": "Apache-2.0",
"authors": [
{
"name": "妙码生花",
"email": "hi@buildadmin.com"
}
],
"require": {
"php": ">=8.2.0",
"topthink/framework": "8.1.4",
"topthink/think-orm": "3.0.33",
"topthink/think-multi-app": "1.1.1",
"topthink/think-throttle": "2.0.2",
"topthink/think-migration": "3.1.1",
"symfony/http-foundation": "^7.3|v8.0",
"phpmailer/phpmailer": "^6.8",
"guzzlehttp/guzzle": "^7.8.1",
"build-admin/anti-xss": "dev-master",
"voku/portable-utf8": "dev-master",
"nelexa/zip": "^4.0.0",
"ext-bcmath": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-gd": "*"
},
"require-dev": {
"symfony/var-dumper": "^5.4",
"topthink/think-trace": "^1.0"
},
"autoload": {
"psr-4": {
"app\\": "app",
"modules\\": "modules"
},
"psr-0": {
"": "extend/"
}
},
"config": {
"preferred-install": "dist"
},
"scripts": {
"post-autoload-dump": [
"@php think service:discover",
"@php think vendor:publish"
]
}
}

32
config/app.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
// +----------------------------------------------------------------------
// | 应用设置
// +----------------------------------------------------------------------
return [
// 应用地址
'app_host' => env('app.host', ''),
// 应用的命名空间
'app_namespace' => '',
// 是否启用路由
'with_route' => true,
// 默认应用
'default_app' => 'api',
// 默认时区
'default_timezone' => 'Asia/Shanghai',
// 应用映射(自动多应用模式有效)
'app_map' => [],
// 域名绑定(自动多应用模式有效)
'domain_bind' => [],
// 禁止URL访问的应用列表自动多应用模式有效
'deny_app_list' => ['common'],
// 异常页面的模板文件
'exception_tmpl' => app()->getThinkPath() . 'tpl/think_exception.tpl',
// 错误显示信息,非调试模式有效
'error_message' => '页面错误!请稍后再试~',
// 显示错误信息
'show_error_msg' => false,
];

86
config/buildadmin.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin设置
// +----------------------------------------------------------------------
return [
// 允许跨域访问的域名
'cors_request_domain' => 'localhost,127.0.0.1',
// 是否开启会员登录验证码
'user_login_captcha' => true,
// 是否开启管理员登录验证码
'admin_login_captcha' => true,
// 会员登录失败可重试次数,false则无限
'user_login_retry' => 10,
// 管理员登录失败可重试次数,false则无限
'admin_login_retry' => 10,
// 开启管理员单处登录它处失效
'admin_sso' => false,
// 开启会员单处登录它处失效
'user_sso' => false,
// 会员登录态保持时间非刷新token3天
'user_token_keep_time' => 60 * 60 * 24 * 3,
// 管理员登录态保持时间非刷新token3天
'admin_token_keep_time' => 60 * 60 * 24 * 3,
// 开启前台会员中心
'open_member_center' => true,
// 模块纯净安装(安装时移动模块文件而不是复制)
'module_pure_install' => true,
// 点选验证码配置
'click_captcha' => [
// 模式:text=文字,icon=图标(若只有icon则适用于国际化站点)
'mode' => ['text', 'icon'],
// 长度
'length' => 2,
// 混淆点长度
'confuse_length' => 2,
],
// 代理服务器IP\think\Request 类将尝试获取这些代理服务器发送过来的真实IP
'proxy_server_ip' => [],
// Token 配置
'token' => [
// 默认驱动方式
'default' => 'mysql',
// 加密key
'key' => 'tcbDgmqLVzuAdNH39o0QnhOisvSCFZ7I',
// 加密方式
'algo' => 'ripemd160',
// 驱动
'stores' => [
'mysql' => [
'type' => 'Mysql',
// 留空表示使用默认的 Mysql 数据库,也可以填写其他数据库连接配置的`name`
'name' => '',
// 存储token的表名
'table' => 'token',
// 默认 token 有效时间
'expire' => 2592000,
],
'redis' => [
'type' => 'Redis',
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
// Db索引非 0 以避免数据被意外清理
'select' => 1,
'timeout' => 0,
// 默认 token 有效时间
'expire' => 2592000,
'persistent' => false,
'prefix' => 'tk:',
],
]
],
// 自动写入管理员操作日志
'auto_write_admin_log' => true,
// 缺省头像图片路径
'default_avatar' => '/static/images/avatar.png',
// 内容分发网络URL末尾不带`/`
'cdn_url' => '',
// 内容分发网络URL参数将自动添加 `?`,之后拼接到 cdn_url 的结尾(例如 `imageMogr2/format/heif`
'cdn_url_params' => '',
// 版本号
'version' => 'v2.3.6',
// 中心接口地址(用于请求模块市场的数据等用途)
'api_url' => 'https://api.buildadmin.com',
];

29
config/cache.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
return [
// 默认缓存驱动
'default' => env('cache.driver', 'file'),
// 缓存连接方式配置
'stores' => [
'file' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => '',
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 序列化机制 例如 ['serialize', 'unserialize']
'serialize' => [],
],
// 更多的缓存连接
],
];

8
config/console.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// +----------------------------------------------------------------------
// | 控制台配置
// +----------------------------------------------------------------------
return [
// 指令定义
'commands' => [],
];

20
config/cookie.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
// +----------------------------------------------------------------------
// | Cookie设置
// +----------------------------------------------------------------------
return [
// cookie 保存时间
'expire' => 0,
// cookie 保存路径
'path' => '/',
// cookie 有效域名
'domain' => '',
// cookie 启用安全传输
'secure' => false,
// httponly设置
'httponly' => false,
// 是否使用 setcookie
'setcookie' => true,
// samesite 设置,支持 'strict' 'lax'
'samesite' => '',
];

63
config/database.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
return [
// 默认使用的数据库连接配置
'default' => env('database.driver', 'mysql'),
// 自定义时间查询规则
'time_query_rule' => [],
// 自动写入时间戳字段
// true为自动识别类型 false关闭
// 字符串则明确指定时间字段类型 支持 int timestamp datetime date
'auto_timestamp' => true,
// 时间字段取出后的默认时间格式
'datetime_format' => false,
// 时间字段配置 配置格式create_time,update_time
'datetime_field' => '',
// 数据库连接配置信息
'connections' => [
'mysql' => [
// 数据库类型
'type' => env('database.type', 'mysql'),
// 服务器地址
'hostname' => env('database.hostname', '127.0.0.1'),
// 数据库名
'database' => env('database.database', 'buildadmin_com'),
// 用户名
'username' => env('database.username', 'root'),
// 密码
'password' => env('database.password', 'admin888'),
// 端口
'hostport' => env('database.hostport', '3306'),
// 数据库连接参数
'params' => [],
// 数据库编码默认采用utf8mb4
'charset' => env('database.charset', 'utf8mb4'),
// 数据库表前缀
'prefix' => env('database.prefix', 'ba_'),
// 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'deploy' => 0,
// 数据库读写是否分离 主从式有效
'rw_separate' => false,
// 读写分离后 主服务器数量
'master_num' => 1,
// 指定从服务器序号
'slave_no' => '',
// 是否严格检查字段是否存在
'fields_strict' => true,
// 是否需要断线重连
'break_reconnect' => true,
// 监听SQL
'trigger_sql' => env('app_debug', false),
// 开启字段缓存
'fields_cache' => false,
],
// 更多的数据库配置信息
],
];

24
config/filesystem.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
return [
// 默认磁盘
'default' => env('filesystem.driver', 'local'),
// 磁盘列表
'disks' => [
'local' => [
'type' => 'local',
'root' => app()->getRuntimePath() . 'storage',
],
'public' => [
// 磁盘类型
'type' => 'local',
// 磁盘路径
'root' => app()->getRootPath() . 'public/storage',
// 磁盘路径对应的外部URL路径
'url' => '/storage',
// 可见性
'visibility' => 'public',
],
// 更多的磁盘配置信息
],
];

27
config/lang.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
// +----------------------------------------------------------------------
// | 多语言设置
// +----------------------------------------------------------------------
return [
// 默认语言
'default_lang' => env('lang.default_lang', 'zh-cn'),
// 允许的语言列表
'allow_lang_list' => ['zh-cn', 'en'],
// 多语言自动侦测变量名
'detect_var' => 'lang',
// 是否使用Cookie记录-开启后 ob_flush() 等操作会报错
'use_cookie' => false,
// 多语言cookie变量
'cookie_var' => 'think_lang',
// 多语言header变量
'header_var' => 'think-lang',
// 扩展语言包
'extend_list' => [],
// Accept-Language转义为对应语言包名称
'accept_language' => [
'zh-hans-cn' => 'zh-cn',
],
// 是否支持语言分组
'allow_group' => false,
];

45
config/log.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | 日志设置
// +----------------------------------------------------------------------
return [
// 默认日志记录通道
'default' => env('log.channel', 'file'),
// 日志记录级别
'level' => [],
// 日志类型记录的通道 ['error'=>'email',...]
'type_channel' => [],
// 关闭全局日志写入
'close' => false,
// 全局日志处理 支持闭包
'processor' => null,
// 日志通道列表
'channels' => [
'file' => [
// 日志记录方式
'type' => 'File',
// 日志保存目录
'path' => '',
// 单文件日志写入
'single' => false,
// 独立日志级别
'apart_level' => [],
// 最大日志文件数量
'max_files' => 0,
// 使用JSON格式记录
'json' => false,
// 日志处理
'processor' => null,
// 关闭通道日志写入
'close' => false,
// 日志输出格式化
'format' => '[%s][%s] %s',
// 是否实时写入
'realtime_write' => false,
],
// 其它日志通道配置
],
];

8
config/middleware.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// 中间件配置
return [
// 别名或分组
'alias' => [],
// 优先级设置,此数组中的中间件会按照数组中的顺序优先执行
'priority' => [],
];

45
config/route.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | 路由设置
// +----------------------------------------------------------------------
return [
// pathinfo分隔符
'pathinfo_depr' => '/',
// URL伪静态后缀
'url_html_suffix' => 'html',
// URL普通方式参数 用于自动生成
'url_common_param' => true,
// 是否开启路由延迟解析
'url_lazy_route' => false,
// 是否强制使用路由
'url_route_must' => false,
// 合并路由规则
'route_rule_merge' => false,
// 路由是否完全匹配
'route_complete_match' => false,
// 访问控制器层名称
'controller_layer' => 'controller',
// 空控制器名
'empty_controller' => 'Error',
// 是否使用控制器后缀
'controller_suffix' => false,
// 默认的路由变量规则
'default_route_pattern' => '[\w\.]+',
// 是否开启请求缓存 true自动缓存 支持设置请求缓存规则
'request_cache_key' => false,
// 请求缓存有效期
'request_cache_expire' => null,
// 全局请求缓存排除规则
'request_cache_except' => [],
// 默认控制器名
'default_controller' => 'Index',
// 默认操作名
'default_action' => 'index',
// 操作方法后缀
'action_suffix' => '',
// 默认JSONP格式返回的处理方法
'default_jsonp_handler' => 'jsonpReturn',
// 默认JSONP处理方法
'var_jsonp_handler' => 'callback',
];

19
config/session.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
// +----------------------------------------------------------------------
// | 会话设置
// +----------------------------------------------------------------------
return [
// session name
'name' => 'PHPSESSID',
// SESSION_ID的提交变量,解决flash上传跨域
'var_session_id' => '',
// 驱动方式 支持file cache
'type' => 'file',
// 存储连接标识 当type使用cache的时候有效
'store' => null,
// 过期时间
'expire' => 1440,
// 前缀
'prefix' => '',
];

169
config/terminal.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin - WEB终端配置
// | 1. 我们只推荐在本地开发环境使用WEB终端
// | 2. 命令参数中可以使用 %s %f 等占位符,系统将从 request()->param('extend') 取值,以 ~~ 分割多个参数后使用 sprintf 函数替换进完整的命令字符串
// | 3. 命令主体千万不能使用 %s %f 等占位符,参数使用占位符时,系统将自动使用 escapeshellarg 对参数值进行转义来防止命令注入攻击
// +----------------------------------------------------------------------
return [
// npm包管理器
'npm_package_manager' => 'pnpm',
// 允许执行的命令
'commands' => [
// 数据库迁移命令
'migrate' => [
'run' => [
'cwd' => '',
'command' => 'php think migrate:run',
'notes' => 'Start the database migration'
],
'rollback' => 'php think migrate:rollback',
'breakpoint' => 'php think migrate:breakpoint',
],
// 安装包管理器的命令
'install' => [
'cnpm' => 'npm install cnpm -g --registry=https://registry.npmmirror.com',
'yarn' => 'npm install -g yarn',
'pnpm' => 'npm install -g pnpm',
'ni' => 'npm install -g @antfu/ni',
],
// 查看版本的命令
'version' => [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
],
// 测试命令
'test' => [
'npm' => [
'cwd' => 'public/npm-install-test',
'command' => 'npm install',
],
'cnpm' => [
'cwd' => 'public/npm-install-test',
'command' => 'cnpm install',
],
'yarn' => [
'cwd' => 'public/npm-install-test',
'command' => 'yarn install',
],
'pnpm' => [
'cwd' => 'public/npm-install-test',
'command' => 'pnpm install',
],
'ni' => [
'cwd' => 'public/npm-install-test',
'command' => 'ni install',
],
],
// 安装 WEB 依赖包
'web-install' => [
'npm' => [
'cwd' => 'web',
'command' => 'npm install',
],
'cnpm' => [
'cwd' => 'web',
'command' => 'cnpm install',
],
'yarn' => [
'cwd' => 'web',
'command' => 'yarn install',
],
'pnpm' => [
'cwd' => 'web',
'command' => 'pnpm install',
],
'ni' => [
'cwd' => 'web',
'command' => 'ni install',
],
],
// 安装 Web-Nuxt 依赖包
'nuxt-install' => [
'npm' => [
'cwd' => 'web-nuxt',
'command' => 'npm install',
],
'cnpm' => [
'cwd' => 'web-nuxt',
'command' => 'cnpm install',
],
'yarn' => [
'cwd' => 'web-nuxt',
'command' => 'yarn install',
],
'pnpm' => [
'cwd' => 'web-nuxt',
'command' => 'pnpm install',
],
'ni' => [
'cwd' => 'web-nuxt',
'command' => 'ni install',
],
],
// 构建 WEB 端
'web-build' => [
'npm' => [
'cwd' => 'web',
'command' => 'npm run build',
'notes' => 'Start executing the build command of the web project',
],
'cnpm' => [
'cwd' => 'web',
'command' => 'cnpm run build',
'notes' => 'Start executing the build command of the web project',
],
'yarn' => [
'cwd' => 'web',
'command' => 'yarn run build',
'notes' => 'Start executing the build command of the web project',
],
'pnpm' => [
'cwd' => 'web',
'command' => 'pnpm run build',
'notes' => 'Start executing the build command of the web project',
],
'ni' => [
'cwd' => 'web',
'command' => 'nr build',
'notes' => 'Start executing the build command of the web project',
],
],
// 设置 NPM 源
'set-npm-registry' => [
'npm' => 'npm config set registry https://registry.npmjs.org/ && npm config get registry',
'taobao' => 'npm config set registry https://registry.npmmirror.com/ && npm config get registry',
'tencent' => 'npm config set registry https://mirrors.cloud.tencent.com/npm/ && npm config get registry'
],
// 设置 composer 源
'set-composer-registry' => [
'composer' => 'composer config --unset repos.packagist',
'aliyun' => 'composer config -g repos.packagist composer https://mirrors.aliyun.com/composer/',
'tencent' => 'composer config -g repos.packagist composer https://mirrors.cloud.tencent.com/composer/',
'huawei' => 'composer config -g repos.packagist composer https://mirrors.huaweicloud.com/repository/php/',
'kkame' => 'composer config -g repos.packagist composer https://packagist.kr',
],
'npx' => [
'prettier' => [
'cwd' => 'web',
'command' => 'npx prettier --write %s',
'notes' => 'Start formatting the web project code',
],
],
'composer' => [
'update' => [
'cwd' => '',
'command' => 'composer update --no-interaction',
'notes' => 'Start installing the composer dependencies'
]
],
'ping' => [
'baidu' => 'ping baidu.com',
'localhost' => 'ping 127.0.0.1 -n 6',
]
],
];

42
config/throttle.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
// +----------------------------------------------------------------------
// | 节流设置
// +----------------------------------------------------------------------
use think\middleware\Throttle;
use think\middleware\throttle\CounterFixed;
use think\Request;
use think\Response;
return [
// 缓存键前缀,防止键值与其他应用冲突
'prefix' => 'throttle_',
// 缓存的键true 表示使用来源ip
'key' => true,
// 要被限制的请求类型, eg: GET POST PUT DELETE HEAD 等
'visit_method' => ['GET', 'HEAD'],
// 设置访问频率,例如 '10/m' 指的是允许每分钟请求10次;'10/60'指允许每60秒请求10次。值 null 表示不限制, eg: null 10/m 20/h 300/d 200/300
'visit_rate' => '120/m',
/*
* 设置节流算法,组件提供了四种算法:
* - CounterFixed :计数固定窗口
* - CounterSlider: 滑动窗口
* - TokenBucket : 令牌桶算法
* - LeakyBucket : 漏桶限流算法
*/
'driver_name' => CounterFixed::class,
// 响应体中设置速率限制的头部信息含义见https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
'visit_enable_show_rate_limit' => true,
// 访问受限时返回的响应
'visit_fail_response' => function (Throttle $throttle, Request $request, int $wait_seconds) {
return Response::create([
'code' => 0,
'msg' => 'Please do not request frequently. Try again in ' . $wait_seconds . ' seconds.',
], 'json')->header([
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 0,
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
'Access-Control-Allow-Origin' => '*',
]);
},
];

10
config/trace.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
// +----------------------------------------------------------------------
// | Trace设置 开启调试模式后有效
// +----------------------------------------------------------------------
return [
// 内置Html和Console两种方式 支持扩展
'type' => 'Html',
// 读取的日志通道名
'channel' => '',
];

21
config/upload.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin上传设置
// +----------------------------------------------------------------------
return [
// 最大上传
'max_size' => '10mb',
// 文件保存格式化方法:topic=存储子目录,fileName=文件名前15个字符
'save_name' => '/storage/{topic}/{year}{mon}{day}/{fileName}{fileSha1}{.suffix}',
/**
* 上传文件的后缀和 MIME类型 白名单
* 0. 永远使用最少配置
* 1. 此处不支持通配符
* 2. 千万不要允许 php,php5,.htaccess,.user.ini 等可执行或配置文件
* 3. 允许 pdf,ppt,docx 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览
*/
'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3',
'allowed_mime_types' => [],
];

25
config/view.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
// +----------------------------------------------------------------------
// | 模板设置
// +----------------------------------------------------------------------
return [
// 模板引擎类型使用Think
'type' => 'Think',
// 默认模板渲染规则 1 解析为小写+下划线 2 全部转换小写 3 保持操作方法
'auto_rule' => 1,
// 模板目录名
'view_dir_name' => 'view',
// 模板后缀
'view_suffix' => 'html',
// 模板文件名分隔符
'view_depr' => DIRECTORY_SEPARATOR,
// 模板引擎普通标签开始标记
'tpl_begin' => '{',
// 模板引擎普通标签结束标记
'tpl_end' => '}',
// 标签库标签开始标记
'taglib_begin' => '{',
// 标签库标签结束标记
'taglib_end' => '}',
];

View File

@@ -0,0 +1,575 @@
<?php
use think\migration\Migrator;
use Phinx\Db\Adapter\MysqlAdapter;
class Install extends Migrator
{
public function change(): void
{
$this->admin();
$this->adminGroup();
$this->adminGroupAccess();
$this->adminLog();
$this->area();
$this->attachment();
$this->captcha();
$this->config();
$this->menuRule();
$this->securityDataRecycle();
$this->securityDataRecycleLog();
$this->securitySensitiveData();
$this->securitySensitiveDataLog();
$this->testBuild();
$this->token();
$this->user();
$this->userGroup();
$this->userMoneyLog();
$this->userRule();
$this->userScoreLog();
$this->crudLog();
}
public function admin(): void
{
if (!$this->hasTable('admin')) {
$table = $this->table('admin', [
'id' => false,
'comment' => '管理员表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('username', 'string', ['limit' => 20, 'default' => '', 'comment' => '用户名', 'null' => false])
->addColumn('nickname', 'string', ['limit' => 50, 'default' => '', 'comment' => '昵称', 'null' => false])
->addColumn('avatar', 'string', ['limit' => 255, 'default' => '', 'comment' => '头像', 'null' => false])
->addColumn('email', 'string', ['limit' => 50, 'default' => '', 'comment' => '邮箱', 'null' => false])
->addColumn('mobile', 'string', ['limit' => 11, 'default' => '', 'comment' => '手机', 'null' => false])
->addColumn('loginfailure', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '登录失败次数', 'null' => false])
->addColumn('lastlogintime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '上次登录时间'])
->addColumn('lastloginip', 'string', ['limit' => 50, 'default' => '', 'comment' => '上次登录IP', 'null' => false])
->addColumn('password', 'string', ['limit' => 32, 'default' => '', 'comment' => '密码', 'null' => false])
->addColumn('salt', 'string', ['limit' => 30, 'default' => '', 'comment' => '密码盐', 'null' => false])
->addColumn('motto', 'string', ['limit' => 255, 'default' => '', 'comment' => '签名', 'null' => false])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('createtime', 'integer', ['limit' => 10, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('updatetime', 'integer', ['limit' => 10, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addIndex(['username'], [
'unique' => true,
])
->create();
}
}
public function adminGroup(): void
{
if (!$this->hasTable('admin_group')) {
$table = $this->table('admin_group', [
'id' => false,
'comment' => '管理分组表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('pid', 'integer', ['comment' => '上级分组', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('name', 'string', ['limit' => 100, 'default' => '', 'comment' => '组名', 'null' => false])
->addColumn('rules', 'text', ['null' => true, 'default' => null, 'comment' => '权限规则ID'])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function adminGroupAccess(): void
{
if (!$this->hasTable('admin_group_access')) {
$table = $this->table('admin_group_access', [
'id' => false,
'comment' => '管理分组映射表',
'row_format' => 'DYNAMIC',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('uid', 'integer', ['comment' => '管理员ID', 'signed' => false, 'null' => false])
->addColumn('group_id', 'integer', ['comment' => '分组ID', 'signed' => false, 'null' => false])
->addIndex(['uid'], [
'type' => 'BTREE',
])
->addIndex(['group_id'], [
'type' => 'BTREE',
])
->create();
}
}
public function adminLog(): void
{
if (!$this->hasTable('admin_log')) {
$table = $this->table('admin_log', [
'id' => false,
'comment' => '管理员日志表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('admin_id', 'integer', ['comment' => '管理员ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('username', 'string', ['limit' => 20, 'default' => '', 'comment' => '管理员用户名', 'null' => false])
->addColumn('url', 'string', ['limit' => 1500, 'default' => '', 'comment' => '操作Url', 'null' => false])
->addColumn('title', 'string', ['limit' => 100, 'default' => '', 'comment' => '日志标题', 'null' => false])
->addColumn('data', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'default' => null, 'comment' => '请求数据'])
->addColumn('ip', 'string', ['limit' => 50, 'default' => '', 'comment' => 'IP', 'null' => false])
->addColumn('useragent', 'string', ['limit' => 255, 'default' => '', 'comment' => 'User-Agent', 'null' => false])
->addColumn('createtime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function area(): void
{
if (!$this->hasTable('area')) {
$table = $this->table('area', [
'id' => false,
'comment' => '省份地区表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('pid', 'integer', ['comment' => '父id', 'null' => true, 'default' => null, 'signed' => false])
->addColumn('shortname', 'string', ['limit' => 100, 'null' => true, 'default' => null, 'comment' => '简称'])
->addColumn('name', 'string', ['limit' => 100, 'null' => true, 'default' => null, 'comment' => '名称'])
->addColumn('mergename', 'string', ['limit' => 255, 'null' => true, 'default' => null, 'comment' => '全称'])
->addColumn('level', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'null' => true, 'default' => null, 'comment' => '层级:1=省,2=市,3=区/县'])
->addColumn('pinyin', 'string', ['limit' => 100, 'null' => true, 'default' => null, 'comment' => '拼音'])
->addColumn('code', 'string', ['limit' => 100, 'null' => true, 'default' => null, 'comment' => '长途区号'])
->addColumn('zip', 'string', ['limit' => 100, 'null' => true, 'default' => null, 'comment' => '邮编'])
->addColumn('first', 'string', ['limit' => 50, 'null' => true, 'default' => null, 'comment' => '首字母'])
->addColumn('lng', 'string', ['limit' => 50, 'null' => true, 'default' => null, 'comment' => '经度'])
->addColumn('lat', 'string', ['limit' => 50, 'null' => true, 'default' => null, 'comment' => '纬度'])
->addIndex(['pid'], [
'type' => 'BTREE',
])
->create();
}
}
public function attachment(): void
{
if (!$this->hasTable('attachment')) {
$table = $this->table('attachment', [
'id' => false,
'comment' => '附件表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('topic', 'string', ['limit' => 20, 'default' => '', 'comment' => '细目', 'null' => false])
->addColumn('admin_id', 'integer', ['comment' => '上传管理员ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('user_id', 'integer', ['comment' => '上传用户ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('url', 'string', ['limit' => 255, 'default' => '', 'comment' => '物理路径', 'null' => false])
->addColumn('width', 'integer', ['comment' => '宽度', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('height', 'integer', ['comment' => '高度', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('name', 'string', ['limit' => 100, 'default' => '', 'comment' => '原始名称', 'null' => false])
->addColumn('size', 'integer', ['comment' => '大小', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('mimetype', 'string', ['limit' => 100, 'default' => '', 'comment' => 'mime类型', 'null' => false])
->addColumn('quote', 'integer', ['comment' => '上传(引用)次数', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('storage', 'string', ['limit' => 50, 'default' => '', 'comment' => '存储方式', 'null' => false])
->addColumn('sha1', 'string', ['limit' => 40, 'default' => '', 'comment' => 'sha1编码', 'null' => false])
->addColumn('createtime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addColumn('lastuploadtime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '最后上传时间'])
->create();
}
}
public function captcha(): void
{
if (!$this->hasTable('captcha')) {
$table = $this->table('captcha', [
'id' => false,
'comment' => '验证码表',
'row_format' => 'DYNAMIC',
'primary_key' => 'key',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('key', 'string', ['limit' => 32, 'default' => '', 'comment' => '验证码Key', 'null' => false])
->addColumn('code', 'string', ['limit' => 32, 'default' => '', 'comment' => '验证码(加密后)', 'null' => false])
->addColumn('captcha', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'default' => null, 'comment' => '验证码数据'])
->addColumn('createtime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addColumn('expiretime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '过期时间'])
->create();
}
}
public function config(): void
{
if (!$this->hasTable('config')) {
$table = $this->table('config', [
'id' => false,
'comment' => '系统配置',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('name', 'string', ['limit' => 30, 'default' => '', 'comment' => '变量名', 'null' => false])
->addColumn('group', 'string', ['limit' => 30, 'default' => '', 'comment' => '分组', 'null' => false])
->addColumn('title', 'string', ['limit' => 50, 'default' => '', 'comment' => '变量标题', 'null' => false])
->addColumn('tip', 'string', ['limit' => 100, 'default' => '', 'comment' => '变量描述', 'null' => false])
->addColumn('type', 'string', ['limit' => 30, 'default' => '', 'comment' => '变量输入组件类型', 'null' => false])
->addColumn('value', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'default' => null, 'comment' => '变量值'])
->addColumn('content', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'default' => null, 'comment' => '字典数据'])
->addColumn('rule', 'string', ['limit' => 100, 'default' => '', 'comment' => '验证规则', 'null' => false])
->addColumn('extend', 'string', ['limit' => 255, 'default' => '', 'comment' => '扩展属性', 'null' => false])
->addColumn('allow_del', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '允许删除:0=否,1=是', 'null' => false])
->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false])
->addIndex(['name'], [
'unique' => true,
])
->create();
}
}
public function menuRule(): void
{
if (!$this->hasTable('menu_rule') && !$this->hasTable('admin_rule')) {
$table = $this->table('menu_rule', [
'id' => false,
'comment' => '菜单和权限规则表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('pid', 'integer', ['comment' => '上级菜单', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('type', 'enum', ['values' => 'menu_dir,menu,button', 'default' => 'menu', 'comment' => '类型:menu_dir=菜单目录,menu=菜单项,button=页面按钮', 'null' => false])
->addColumn('title', 'string', ['limit' => 50, 'default' => '', 'comment' => '标题', 'null' => false])
->addColumn('name', 'string', ['limit' => 50, 'default' => '', 'comment' => '规则名称', 'null' => false])
->addColumn('path', 'string', ['limit' => 100, 'default' => '', 'comment' => '路由路径', 'null' => false])
->addColumn('icon', 'string', ['limit' => 50, 'default' => '', 'comment' => '图标', 'null' => false])
->addColumn('menu_type', 'enum', ['values' => 'tab,link,iframe', 'null' => true, 'default' => null, 'comment' => '菜单类型:tab=选项卡,link=链接,iframe=Iframe'])
->addColumn('url', 'string', ['limit' => 255, 'default' => '', 'comment' => 'Url', 'null' => false])
->addColumn('component', 'string', ['limit' => 100, 'default' => '', 'comment' => '组件路径', 'null' => false])
->addColumn('keepalive', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '缓存:0=关闭,1=开启', 'null' => false])
->addColumn('extend', 'enum', ['values' => 'none,add_rules_only,add_menu_only', 'default' => 'none', 'comment' => '扩展属性:none=无,add_rules_only=只添加为路由,add_menu_only=只添加为菜单', 'null' => false])
->addColumn('remark', 'string', ['limit' => 255, 'default' => '', 'comment' => '备注', 'null' => false])
->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addIndex(['pid'], [
'type' => 'BTREE',
])
->create();
}
}
public function securityDataRecycle(): void
{
if (!$this->hasTable('security_data_recycle')) {
$table = $this->table('security_data_recycle', [
'id' => false,
'comment' => '回收规则表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('name', 'string', ['limit' => 50, 'default' => '', 'comment' => '规则名称', 'null' => false])
->addColumn('controller', 'string', ['limit' => 100, 'default' => '', 'comment' => '控制器', 'null' => false])
->addColumn('controller_as', 'string', ['limit' => 100, 'default' => '', 'comment' => '控制器别名', 'null' => false])
->addColumn('data_table', 'string', ['limit' => 100, 'default' => '', 'comment' => '对应数据表', 'null' => false])
->addColumn('primary_key', 'string', ['limit' => 50, 'default' => '', 'comment' => '数据表主键', 'null' => false])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function securityDataRecycleLog(): void
{
if (!$this->hasTable('security_data_recycle_log')) {
$table = $this->table('security_data_recycle_log', [
'id' => false,
'comment' => '数据回收记录表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('admin_id', 'integer', ['comment' => '操作管理员', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('recycle_id', 'integer', ['comment' => '回收规则ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('data', 'text', ['null' => true, 'default' => null, 'comment' => '回收的数据'])
->addColumn('data_table', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据表', 'null' => false])
->addColumn('primary_key', 'string', ['limit' => 50, 'default' => '', 'comment' => '数据表主键', 'null' => false])
->addColumn('is_restore', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '是否已还原:0=否,1=是', 'null' => false])
->addColumn('ip', 'string', ['limit' => 50, 'default' => '', 'comment' => '操作者IP', 'null' => false])
->addColumn('useragent', 'string', ['limit' => 255, 'default' => '', 'comment' => 'User-Agent', 'null' => false])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function securitySensitiveData(): void
{
if (!$this->hasTable('security_sensitive_data')) {
$table = $this->table('security_sensitive_data', [
'id' => false,
'comment' => '敏感数据规则表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('name', 'string', ['limit' => 50, 'default' => '', 'comment' => '规则名称', 'null' => false])
->addColumn('controller', 'string', ['limit' => 100, 'default' => '', 'comment' => '控制器', 'null' => false])
->addColumn('controller_as', 'string', ['limit' => 100, 'default' => '', 'comment' => '控制器别名', 'null' => false])
->addColumn('data_table', 'string', ['limit' => 100, 'default' => '', 'comment' => '对应数据表', 'null' => false])
->addColumn('primary_key', 'string', ['limit' => 50, 'default' => '', 'comment' => '数据表主键', 'null' => false])
->addColumn('data_fields', 'text', ['null' => true, 'default' => null, 'comment' => '敏感数据字段'])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function securitySensitiveDataLog(): void
{
if (!$this->hasTable('security_sensitive_data_log')) {
$table = $this->table('security_sensitive_data_log', [
'id' => false,
'comment' => '敏感数据修改记录',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('admin_id', 'integer', ['comment' => '操作管理员', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('sensitive_id', 'integer', ['comment' => '敏感数据规则ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('data_table', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据表', 'null' => false])
->addColumn('primary_key', 'string', ['limit' => 50, 'default' => '', 'comment' => '数据表主键', 'null' => false])
->addColumn('data_field', 'string', ['limit' => 50, 'default' => '', 'comment' => '被修改字段', 'null' => false])
->addColumn('data_comment', 'string', ['limit' => 50, 'default' => '', 'comment' => '被修改项', 'null' => false])
->addColumn('id_value', 'integer', ['comment' => '被修改项主键值', 'default' => 0, 'null' => false])
->addColumn('before', 'text', ['null' => true, 'default' => null, 'comment' => '修改前'])
->addColumn('after', 'text', ['null' => true, 'default' => null, 'comment' => '修改后'])
->addColumn('ip', 'string', ['limit' => 50, 'default' => '', 'comment' => '操作者IP', 'null' => false])
->addColumn('useragent', 'string', ['limit' => 255, 'default' => '', 'comment' => 'User-Agent', 'null' => false])
->addColumn('is_rollback', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '是否已回滚:0=否,1=是', 'null' => false])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function testBuild(): void
{
if (!$this->hasTable('test_build')) {
$table = $this->table('test_build', [
'id' => false,
'comment' => '知识库表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('title', 'string', ['limit' => 100, 'default' => '', 'comment' => '标题', 'null' => false])
->addColumn('keyword_rows', 'string', ['limit' => 100, 'default' => '', 'comment' => '关键词', 'null' => false])
->addColumn('content', 'text', ['null' => true, 'default' => null, 'comment' => '内容'])
->addColumn('views', 'integer', ['comment' => '浏览量', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('likes', 'integer', ['comment' => '有帮助数', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('dislikes', 'integer', ['comment' => '无帮助数', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('note_textarea', 'string', ['limit' => 100, 'default' => '', 'comment' => '备注', 'null' => false])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=隐藏,1=正常', 'null' => false])
->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false])
->addColumn('update_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('create_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function token(): void
{
if (!$this->hasTable('token')) {
$table = $this->table('token', [
'id' => false,
'comment' => '用户Token表',
'row_format' => 'DYNAMIC',
'primary_key' => 'token',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('token', 'string', ['limit' => 50, 'default' => '', 'comment' => 'Token', 'null' => false])
->addColumn('type', 'string', ['limit' => 15, 'default' => '', 'comment' => '类型', 'null' => false])
->addColumn('user_id', 'integer', ['comment' => '用户ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addColumn('expiretime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '过期时间'])
->create();
}
}
public function user(): void
{
if (!$this->hasTable('user')) {
$table = $this->table('user', [
'id' => false,
'comment' => '会员表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('group_id', 'integer', ['comment' => '分组ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('username', 'string', ['limit' => 32, 'default' => '', 'comment' => '用户名', 'null' => false])
->addColumn('nickname', 'string', ['limit' => 50, 'default' => '', 'comment' => '昵称', 'null' => false])
->addColumn('email', 'string', ['limit' => 50, 'default' => '', 'comment' => '邮箱', 'null' => false])
->addColumn('mobile', 'string', ['limit' => 11, 'default' => '', 'comment' => '手机', 'null' => false])
->addColumn('avatar', 'string', ['limit' => 255, 'default' => '', 'comment' => '头像', 'null' => false])
->addColumn('gender', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '性别:0=未知,1=男,2=女', 'null' => false])
->addColumn('birthday', 'date', ['null' => true, 'default' => null, 'comment' => '生日'])
->addColumn('money', 'integer', ['comment' => '余额', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('score', 'integer', ['comment' => '积分', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('lastlogintime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '上次登录时间'])
->addColumn('lastloginip', 'string', ['limit' => 50, 'default' => '', 'comment' => '上次登录IP', 'null' => false])
->addColumn('loginfailure', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '登录失败次数', 'null' => false])
->addColumn('joinip', 'string', ['limit' => 50, 'default' => '', 'comment' => '加入IP', 'null' => false])
->addColumn('jointime', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '加入时间'])
->addColumn('motto', 'string', ['limit' => 255, 'default' => '', 'comment' => '签名', 'null' => false])
->addColumn('password', 'string', ['limit' => 32, 'default' => '', 'comment' => '密码', 'null' => false])
->addColumn('salt', 'string', ['limit' => 30, 'default' => '', 'comment' => '密码盐', 'null' => false])
->addColumn('status', 'string', ['limit' => 30, 'default' => '', 'comment' => '状态', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addIndex(['username'], [
'unique' => true,
])
->addIndex(['email'], [
'unique' => true,
])
->addIndex(['mobile'], [
'unique' => true,
])
->create();
}
}
public function userGroup(): void
{
if (!$this->hasTable('user_group')) {
$table = $this->table('user_group', [
'id' => false,
'comment' => '会员组表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('name', 'string', ['limit' => 50, 'default' => '', 'comment' => '组名', 'null' => false])
->addColumn('rules', 'text', ['null' => true, 'default' => null, 'comment' => '权限节点'])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function userMoneyLog(): void
{
if (!$this->hasTable('user_money_log')) {
$table = $this->table('user_money_log', [
'id' => false,
'comment' => '会员余额变动表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('user_id', 'integer', ['comment' => '会员ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('money', 'integer', ['comment' => '变更余额', 'default' => 0, 'null' => false])
->addColumn('before', 'integer', ['comment' => '变更前余额', 'default' => 0, 'null' => false])
->addColumn('after', 'integer', ['comment' => '变更后余额', 'default' => 0, 'null' => false])
->addColumn('memo', 'string', ['limit' => 255, 'default' => '', 'comment' => '备注', 'null' => false])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function userRule(): void
{
if (!$this->hasTable('user_rule')) {
$table = $this->table('user_rule', [
'id' => false,
'comment' => '会员菜单权限规则表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('pid', 'integer', ['comment' => '上级菜单', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('type', 'enum', ['values' => 'route,menu_dir,menu,nav_user_menu,nav,button', 'default' => 'menu', 'comment' => '类型:route=路由,menu_dir=菜单目录,menu=菜单项,nav_user_menu=顶栏会员菜单下拉项,nav=顶栏菜单项,button=页面按钮', 'null' => false])
->addColumn('title', 'string', ['limit' => 50, 'default' => '', 'comment' => '标题', 'null' => false])
->addColumn('name', 'string', ['limit' => 50, 'default' => '', 'comment' => '规则名称', 'null' => false])
->addColumn('path', 'string', ['limit' => 100, 'default' => '', 'comment' => '路由路径', 'null' => false])
->addColumn('icon', 'string', ['limit' => 50, 'default' => '', 'comment' => '图标', 'null' => false])
->addColumn('menu_type', 'enum', ['values' => 'tab,link,iframe', 'default' => 'tab', 'comment' => '菜单类型:tab=选项卡,link=链接,iframe=Iframe', 'null' => false])
->addColumn('url', 'string', ['limit' => 255, 'default' => '', 'comment' => 'Url', 'null' => false])
->addColumn('component', 'string', ['limit' => 100, 'default' => '', 'comment' => '组件路径', 'null' => false])
->addColumn('no_login_valid', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '未登录有效:0=否,1=是', 'null' => false])
->addColumn('extend', 'enum', ['values' => 'none,add_rules_only,add_menu_only', 'default' => 'none', 'comment' => '扩展属性:none=无,add_rules_only=只添加为路由,add_menu_only=只添加为菜单', 'null' => false])
->addColumn('remark', 'string', ['limit' => 255, 'default' => '', 'comment' => '备注', 'null' => false])
->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false])
->addColumn('status', 'enum', ['values' => '0,1', 'default' => '1', 'comment' => '状态:0=禁用,1=启用', 'null' => false])
->addColumn('updatetime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addIndex(['pid'], [
'type' => 'BTREE',
])
->create();
}
}
public function userScoreLog(): void
{
if (!$this->hasTable('user_score_log')) {
$table = $this->table('user_score_log', [
'id' => false,
'comment' => '会员积分变动表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('user_id', 'integer', ['comment' => '会员ID', 'default' => 0, 'signed' => false, 'null' => false])
->addColumn('score', 'integer', ['comment' => '变更积分', 'default' => 0, 'null' => false])
->addColumn('before', 'integer', ['comment' => '变更前积分', 'default' => 0, 'null' => false])
->addColumn('after', 'integer', ['comment' => '变更后积分', 'default' => 0, 'null' => false])
->addColumn('memo', 'string', ['limit' => 255, 'default' => '', 'comment' => '备注', 'null' => false])
->addColumn('createtime', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
public function crudLog(): void
{
if (!$this->hasTable('crud_log')) {
$table = $this->table('crud_log', [
'id' => false,
'comment' => 'CRUD记录表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('table_name', 'string', ['limit' => 200, 'default' => '', 'comment' => '数据表名', 'null' => false])
->addColumn('table', 'text', ['null' => true, 'default' => null, 'comment' => '数据表数据'])
->addColumn('fields', 'text', ['null' => true, 'default' => null, 'comment' => '字段数据'])
->addColumn('status', 'enum', ['values' => 'delete,success,error,start', 'default' => 'start', 'comment' => '状态:delete=已删除,success=成功,error=失败,start=生成中', 'null' => false])
->addColumn('create_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->create();
}
}
}

View File

@@ -0,0 +1,1418 @@
<?php
use think\migration\Migrator;
use think\facade\Db;
class InstallData extends Migrator
{
public string $nowTime = '';
public function up(): void
{
$this->nowTime = time();
$this->admin();
$this->adminGroup();
$this->adminGroupAccess();
$this->config();
$this->menuRule();
$this->securityDataRecycle();
$this->securitySensitiveData();
$this->user();
$this->userGroup();
$this->userRule();
}
public function admin(): void
{
$table = $this->table('admin');
$rows = [
[
'id' => 1,
'username' => 'admin',
'nickname' => 'Admin',
'email' => 'admin@buildadmin.com',
'mobile' => '18888888888',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('admin')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function adminGroup(): void
{
$table = $this->table('admin_group');
$rows = [
[
'id' => 1,
'pid' => 0,
'name' => '超级管理组',
'rules' => '*',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 2,
'pid' => 1,
'name' => '一级管理员',
'rules' => '1,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,77,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 3,
'pid' => 2,
'name' => '二级管理员',
'rules' => '21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 4,
'pid' => 3,
'name' => '三级管理员',
'rules' => '55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
];
$exist = Db::name('admin_group')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function adminGroupAccess(): void
{
$table = $this->table('admin_group_access');
$rows = [
[
'uid' => 1,
'group_id' => 1,
]
];
$exist = Db::name('admin_group_access')->where('uid', 1)->value('uid');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function config(): void
{
$table = $this->table('config');
$rows = [
[
'id' => 1,
'name' => 'config_group',
'group' => 'basics',
'title' => 'Config group',
'type' => 'array',
'value' => '[{"key":"basics","value":"Basics"},{"key":"mail","value":"Mail"},{"key":"config_quick_entrance","value":"Config Quick entrance"}]',
'rule' => 'required',
'weigh' => -1,
],
[
'id' => 2,
'name' => 'site_name',
'group' => 'basics',
'title' => 'Site Name',
'tip' => '',
'type' => 'string',
'value' => '站点名称',
'rule' => 'required',
'weigh' => 99,
],
[
'id' => 3,
'name' => 'record_number',
'group' => 'basics',
'title' => 'Record number',
'tip' => '域名备案号',
'type' => 'string',
'value' => '渝ICP备8888888号-1',
],
[
'id' => 4,
'name' => 'version',
'group' => 'basics',
'title' => 'Version number',
'tip' => '系统版本号',
'type' => 'string',
'value' => 'v1.0.0',
'rule' => 'required',
],
[
'id' => 5,
'name' => 'time_zone',
'group' => 'basics',
'title' => 'time zone',
'type' => 'string',
'value' => 'Asia/Shanghai',
'rule' => 'required',
],
[
'id' => 6,
'name' => 'no_access_ip',
'group' => 'basics',
'title' => 'No access ip',
'tip' => '禁止访问站点的ip列表,一行一个',
'type' => 'textarea',
],
[
'id' => 7,
'name' => 'smtp_server',
'group' => 'mail',
'title' => 'smtp server',
'type' => 'string',
'value' => 'smtp.qq.com',
'weigh' => 9,
],
[
'id' => 8,
'name' => 'smtp_port',
'group' => 'mail',
'title' => 'smtp port',
'type' => 'string',
'value' => '465',
'weigh' => 8,
],
[
'id' => 9,
'name' => 'smtp_user',
'group' => 'mail',
'title' => 'smtp user',
'type' => 'string',
'weigh' => 7,
],
[
'id' => 10,
'name' => 'smtp_pass',
'group' => 'mail',
'title' => 'smtp pass',
'type' => 'string',
'weigh' => 6,
],
[
'id' => 11,
'name' => 'smtp_verification',
'group' => 'mail',
'title' => 'smtp verification',
'type' => 'select',
'value' => 'SSL',
'content' => '{"SSL":"SSL","TLS":"TLS"}',
'weigh' => 5,
],
[
'id' => 12,
'name' => 'smtp_sender_mail',
'group' => 'mail',
'title' => 'smtp sender mail',
'type' => 'string',
'rule' => 'email',
'weigh' => 4,
],
[
'id' => 13,
'name' => 'config_quick_entrance',
'group' => 'config_quick_entrance',
'title' => 'Config Quick entrance',
'type' => 'array',
'value' => '[{"key":"数据回收规则配置","value":"/admin/security/dataRecycle"},{"key":"敏感数据规则配置","value":"/admin/security/sensitiveData"}]',
],
];
$exist = Db::name('config')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function menuRule(): void
{
if (!$this->hasTable('menu_rule')) return;
$table = $this->table('menu_rule');
$rows = [
[
'id' => '1',
'type' => 'menu',
'title' => '控制台',
'name' => 'dashboard/dashboard',
'path' => 'dashboard',
'icon' => 'fa fa-dashboard',
'menu_type' => 'tab',
'component' => '/src/views/backend/dashboard.vue',
'keepalive' => '1',
'remark' => 'Remark lang',
'weigh' => '999',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '2',
'type' => 'menu_dir',
'title' => '权限管理',
'name' => 'auth',
'path' => 'auth',
'icon' => 'fa fa-group',
'weigh' => '100',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '3',
'pid' => '2',
'type' => 'menu',
'title' => '角色组管理',
'name' => 'auth/group',
'path' => 'auth/group',
'icon' => 'fa fa-group',
'menu_type' => 'tab',
'component' => '/src/views/backend/auth/group/index.vue',
'keepalive' => '1',
'weigh' => '99',
'remark' => 'Remark lang',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '4',
'pid' => '3',
'type' => 'button',
'title' => '查看',
'name' => 'auth/group/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '5',
'pid' => '3',
'type' => 'button',
'title' => '添加',
'name' => 'auth/group/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '6',
'pid' => '3',
'type' => 'button',
'title' => '编辑',
'name' => 'auth/group/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '7',
'pid' => '3',
'type' => 'button',
'title' => '删除',
'name' => 'auth/group/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '8',
'pid' => '2',
'type' => 'menu',
'title' => '管理员管理',
'name' => 'auth/admin',
'path' => 'auth/admin',
'icon' => 'el-icon-UserFilled',
'menu_type' => 'tab',
'component' => '/src/views/backend/auth/admin/index.vue',
'keepalive' => '1',
'weigh' => '98',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '9',
'pid' => '8',
'type' => 'button',
'title' => '查看',
'name' => 'auth/admin/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '10',
'pid' => '8',
'type' => 'button',
'title' => '添加',
'name' => 'auth/admin/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '11',
'pid' => '8',
'type' => 'button',
'title' => '编辑',
'name' => 'auth/admin/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '12',
'pid' => '8',
'type' => 'button',
'title' => '删除',
'name' => 'auth/admin/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '13',
'pid' => '2',
'type' => 'menu',
'title' => '菜单规则管理',
'name' => 'auth/menu',
'path' => 'auth/menu',
'icon' => 'el-icon-Grid',
'menu_type' => 'tab',
'component' => '/src/views/backend/auth/menu/index.vue',
'keepalive' => '1',
'weigh' => '97',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '14',
'pid' => '13',
'type' => 'button',
'title' => '查看',
'name' => 'auth/menu/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '15',
'pid' => '13',
'type' => 'button',
'title' => '添加',
'name' => 'auth/menu/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '16',
'pid' => '13',
'type' => 'button',
'title' => '编辑',
'name' => 'auth/menu/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '17',
'pid' => '13',
'type' => 'button',
'title' => '删除',
'name' => 'auth/menu/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '18',
'pid' => '13',
'type' => 'button',
'title' => '快速排序',
'name' => 'auth/menu/sortable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '19',
'pid' => '2',
'type' => 'menu',
'title' => '管理员日志管理',
'name' => 'auth/adminLog',
'path' => 'auth/adminLog',
'icon' => 'el-icon-List',
'menu_type' => 'tab',
'component' => '/src/views/backend/auth/adminLog/index.vue',
'keepalive' => '1',
'weigh' => '96',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '20',
'pid' => '19',
'type' => 'button',
'title' => '查看',
'name' => 'auth/adminLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '21',
'type' => 'menu_dir',
'title' => '会员管理',
'name' => 'user',
'path' => 'user',
'icon' => 'fa fa-drivers-license',
'weigh' => '95',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '22',
'pid' => '21',
'type' => 'menu',
'title' => '会员管理',
'name' => 'user/user',
'path' => 'user/user',
'icon' => 'fa fa-user',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/user/index.vue',
'keepalive' => '1',
'weigh' => '94',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '23',
'pid' => '22',
'type' => 'button',
'title' => '查看',
'name' => 'user/user/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '24',
'pid' => '22',
'type' => 'button',
'title' => '添加',
'name' => 'user/user/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '25',
'pid' => '22',
'type' => 'button',
'title' => '编辑',
'name' => 'user/user/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '26',
'pid' => '22',
'type' => 'button',
'title' => '删除',
'name' => 'user/user/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '27',
'pid' => '21',
'type' => 'menu',
'title' => '会员分组管理',
'name' => 'user/group',
'path' => 'user/group',
'icon' => 'fa fa-group',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/group/index.vue',
'keepalive' => '1',
'weigh' => '93',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '28',
'pid' => '27',
'type' => 'button',
'title' => '查看',
'name' => 'user/group/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '29',
'pid' => '27',
'type' => 'button',
'title' => '添加',
'name' => 'user/group/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '30',
'pid' => '27',
'type' => 'button',
'title' => '编辑',
'name' => 'user/group/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '31',
'pid' => '27',
'type' => 'button',
'title' => '删除',
'name' => 'user/group/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '32',
'pid' => '21',
'type' => 'menu',
'title' => '会员规则管理',
'name' => 'user/rule',
'path' => 'user/rule',
'icon' => 'fa fa-th-list',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/rule/index.vue',
'keepalive' => '1',
'weigh' => '92',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '33',
'pid' => '32',
'type' => 'button',
'title' => '查看',
'name' => 'user/rule/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '34',
'pid' => '32',
'type' => 'button',
'title' => '添加',
'name' => 'user/rule/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '35',
'pid' => '32',
'type' => 'button',
'title' => '编辑',
'name' => 'user/rule/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '36',
'pid' => '32',
'type' => 'button',
'title' => '删除',
'name' => 'user/rule/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '37',
'pid' => '32',
'type' => 'button',
'title' => '快速排序',
'name' => 'user/rule/sortable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '38',
'pid' => '21',
'type' => 'menu',
'title' => '会员余额管理',
'name' => 'user/moneyLog',
'path' => 'user/moneyLog',
'icon' => 'el-icon-Money',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/moneyLog/index.vue',
'keepalive' => '1',
'weigh' => '91',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '39',
'pid' => '38',
'type' => 'button',
'title' => '查看',
'name' => 'user/moneyLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '40',
'pid' => '38',
'type' => 'button',
'title' => '添加',
'name' => 'user/moneyLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '41',
'pid' => '21',
'type' => 'menu',
'title' => '会员积分管理',
'name' => 'user/scoreLog',
'path' => 'user/scoreLog',
'icon' => 'el-icon-Discount',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/scoreLog/index.vue',
'keepalive' => '1',
'weigh' => '90',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '42',
'pid' => '41',
'type' => 'button',
'title' => '查看',
'name' => 'user/scoreLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '43',
'pid' => '41',
'type' => 'button',
'title' => '添加',
'name' => 'user/scoreLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '44',
'type' => 'menu_dir',
'title' => '常规管理',
'name' => 'routine',
'path' => 'routine',
'icon' => 'fa fa-cogs',
'weigh' => '89',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '45',
'pid' => '44',
'type' => 'menu',
'title' => '系统配置',
'name' => 'routine/config',
'path' => 'routine/config',
'icon' => 'el-icon-Tools',
'menu_type' => 'tab',
'component' => '/src/views/backend/routine/config/index.vue',
'keepalive' => '1',
'weigh' => '88',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '46',
'pid' => '45',
'type' => 'button',
'title' => '查看',
'name' => 'routine/config/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '47',
'pid' => '45',
'type' => 'button',
'title' => '编辑',
'name' => 'routine/config/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '48',
'pid' => '44',
'type' => 'menu',
'title' => '附件管理',
'name' => 'routine/attachment',
'path' => 'routine/attachment',
'icon' => 'fa fa-folder',
'menu_type' => 'tab',
'component' => '/src/views/backend/routine/attachment/index.vue',
'keepalive' => '1',
'remark' => 'Remark lang',
'weigh' => '87',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '49',
'pid' => '48',
'type' => 'button',
'title' => '查看',
'name' => 'routine/attachment/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '50',
'pid' => '48',
'type' => 'button',
'title' => '编辑',
'name' => 'routine/attachment/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '51',
'pid' => '48',
'type' => 'button',
'title' => '删除',
'name' => 'routine/attachment/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '52',
'pid' => '44',
'type' => 'menu',
'title' => '个人资料',
'name' => 'routine/adminInfo',
'path' => 'routine/adminInfo',
'icon' => 'fa fa-user',
'menu_type' => 'tab',
'component' => '/src/views/backend/routine/adminInfo.vue',
'keepalive' => '1',
'weigh' => '86',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '53',
'pid' => '52',
'type' => 'button',
'title' => '查看',
'name' => 'routine/adminInfo/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '54',
'pid' => '52',
'type' => 'button',
'title' => '编辑',
'name' => 'routine/adminInfo/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '55',
'type' => 'menu_dir',
'title' => '数据安全管理',
'name' => 'security',
'path' => 'security',
'icon' => 'fa fa-shield',
'weigh' => '85',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '56',
'pid' => '55',
'type' => 'menu',
'title' => '数据回收站',
'name' => 'security/dataRecycleLog',
'path' => 'security/dataRecycleLog',
'icon' => 'fa fa-database',
'menu_type' => 'tab',
'component' => '/src/views/backend/security/dataRecycleLog/index.vue',
'keepalive' => '1',
'weigh' => '84',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '57',
'pid' => '56',
'type' => 'button',
'title' => '查看',
'name' => 'security/dataRecycleLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '58',
'pid' => '56',
'type' => 'button',
'title' => '删除',
'name' => 'security/dataRecycleLog/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '59',
'pid' => '56',
'type' => 'button',
'title' => '还原',
'name' => 'security/dataRecycleLog/restore',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '60',
'pid' => '56',
'type' => 'button',
'title' => '查看详情',
'name' => 'security/dataRecycleLog/info',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '61',
'pid' => '55',
'type' => 'menu',
'title' => '敏感数据修改记录',
'name' => 'security/sensitiveDataLog',
'path' => 'security/sensitiveDataLog',
'icon' => 'fa fa-expeditedssl',
'menu_type' => 'tab',
'component' => '/src/views/backend/security/sensitiveDataLog/index.vue',
'keepalive' => '1',
'weigh' => '83',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '62',
'pid' => '61',
'type' => 'button',
'title' => '查看',
'name' => 'security/sensitiveDataLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '63',
'pid' => '61',
'type' => 'button',
'title' => '删除',
'name' => 'security/sensitiveDataLog/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '64',
'pid' => '61',
'type' => 'button',
'title' => '回滚',
'name' => 'security/sensitiveDataLog/rollback',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '65',
'pid' => '61',
'type' => 'button',
'title' => '查看详情',
'name' => 'security/sensitiveDataLog/info',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '66',
'pid' => '55',
'type' => 'menu',
'title' => '数据回收规则管理',
'name' => 'security/dataRecycle',
'path' => 'security/dataRecycle',
'icon' => 'fa fa-database',
'menu_type' => 'tab',
'component' => '/src/views/backend/security/dataRecycle/index.vue',
'keepalive' => '1',
'remark' => 'Remark lang',
'weigh' => '82',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '67',
'pid' => '66',
'type' => 'button',
'title' => '查看',
'name' => 'security/dataRecycle/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '68',
'pid' => '66',
'type' => 'button',
'title' => '添加',
'name' => 'security/dataRecycle/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '69',
'pid' => '66',
'type' => 'button',
'title' => '编辑',
'name' => 'security/dataRecycle/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '70',
'pid' => '66',
'type' => 'button',
'title' => '删除',
'name' => 'security/dataRecycle/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '71',
'pid' => '55',
'type' => 'menu',
'title' => '敏感字段规则管理',
'name' => 'security/sensitiveData',
'path' => 'security/sensitiveData',
'icon' => 'fa fa-expeditedssl',
'menu_type' => 'tab',
'component' => '/src/views/backend/security/sensitiveData/index.vue',
'keepalive' => '1',
'remark' => 'Remark lang',
'weigh' => '81',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '72',
'pid' => '71',
'type' => 'button',
'title' => '查看',
'name' => 'security/sensitiveData/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '73',
'pid' => '71',
'type' => 'button',
'title' => '添加',
'name' => 'security/sensitiveData/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '74',
'pid' => '71',
'type' => 'button',
'title' => '编辑',
'name' => 'security/sensitiveData/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '75',
'pid' => '71',
'type' => 'button',
'title' => '删除',
'name' => 'security/sensitiveData/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '76',
'type' => 'menu',
'title' => 'BuildAdmin',
'name' => 'buildadmin/buildadmin',
'path' => 'buildadmin',
'icon' => 'local-logo',
'menu_type' => 'link',
'url' => 'https://doc.buildadmin.com',
'status' => '0',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '77',
'pid' => '45',
'type' => 'button',
'title' => '添加',
'name' => 'routine/config/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '78',
'type' => 'menu',
'title' => '模块市场',
'name' => 'moduleStore/moduleStore',
'path' => 'moduleStore',
'icon' => 'el-icon-GoodsFilled',
'menu_type' => 'tab',
'component' => '/src/views/backend/module/index.vue',
'keepalive' => '1',
'weigh' => '86',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '79',
'pid' => '78',
'type' => 'button',
'title' => '查看',
'name' => 'moduleStore/moduleStore/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '80',
'pid' => '78',
'type' => 'button',
'title' => '安装',
'name' => 'moduleStore/moduleStore/install',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '81',
'pid' => '78',
'type' => 'button',
'title' => '调整状态',
'name' => 'moduleStore/moduleStore/changeState',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '82',
'pid' => '78',
'type' => 'button',
'title' => '卸载',
'name' => 'moduleStore/moduleStore/uninstall',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '83',
'pid' => '78',
'type' => 'button',
'title' => '更新',
'name' => 'moduleStore/moduleStore/update',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '84',
'type' => 'menu',
'title' => 'CRUD代码生成',
'name' => 'crud/crud',
'path' => 'crud/crud',
'icon' => 'fa fa-code',
'menu_type' => 'tab',
'component' => '/src/views/backend/crud/index.vue',
'keepalive' => '1',
'weigh' => '80',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '85',
'pid' => '84',
'type' => 'button',
'title' => '查看',
'name' => 'crud/crud/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '86',
'pid' => '84',
'type' => 'button',
'title' => '生成',
'name' => 'crud/crud/generate',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '87',
'pid' => '84',
'type' => 'button',
'title' => '删除',
'name' => 'crud/crud/delete',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '88',
'pid' => '45',
'type' => 'button',
'title' => '删除',
'name' => 'routine/config/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
];
$exist = Db::name('menu_rule')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function securityDataRecycle(): void
{
$table = $this->table('security_data_recycle');
$rows = [
[
'id' => 1,
'name' => '管理员',
'controller' => 'auth/Admin.php',
'controller_as' => 'auth/admin',
'data_table' => 'admin',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 2,
'name' => '管理员日志',
'controller' => 'auth/AdminLog.php',
'controller_as' => 'auth/adminlog',
'data_table' => 'admin_log',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 3,
'name' => '菜单规则',
'controller' => 'auth/Menu.php',
'controller_as' => 'auth/menu',
'data_table' => 'menu_rule',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 4,
'name' => '系统配置项',
'controller' => 'routine/Config.php',
'controller_as' => 'routine/config',
'data_table' => 'config',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 5,
'name' => '会员',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 6,
'name' => '数据回收规则',
'controller' => 'security/DataRecycle.php',
'controller_as' => 'security/datarecycle',
'data_table' => 'security_data_recycle',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
];
$exist = Db::name('security_data_recycle')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function securitySensitiveData(): void
{
$table = $this->table('security_sensitive_data');
$rows = [
[
'id' => 1,
'name' => '管理员数据',
'controller' => 'auth/Admin.php',
'controller_as' => 'auth/admin',
'data_table' => 'admin',
'primary_key' => 'id',
'data_fields' => '{"username":"用户名","mobile":"手机","password":"密码","status":"状态"}',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 2,
'name' => '会员数据',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'data_fields' => '{"username":"用户名","mobile":"手机号","password":"密码","status":"状态","email":"邮箱地址"}',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 3,
'name' => '管理员权限',
'controller' => 'auth/Group.php',
'controller_as' => 'auth/group',
'data_table' => 'admin_group',
'primary_key' => 'id',
'data_fields' => '{"rules":"权限规则ID"}',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
];
$exist = Db::name('security_sensitive_data')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function user(): void
{
$table = $this->table('user');
$rows = [
[
'id' => 1,
'group_id' => 1,
'username' => 'user',
'nickname' => 'User',
'email' => '18888888888@qq.com',
'mobile' => '18888888888',
'gender' => '2',
'birthday' => date('Y-m-d'),
'status' => 'enable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userGroup(): void
{
$table = $this->table('user_group');
$rows = [
[
'id' => 1,
'name' => '默认分组',
'rules' => '*',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user_group')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userRule(): void
{
$table = $this->table('user_rule');
$rows = [
[
'id' => 1,
'pid' => 0,
'type' => 'menu_dir',
'title' => '我的账户',
'name' => 'account',
'path' => 'account',
'icon' => 'fa fa-user-circle',
'menu_type' => 'tab',
'weigh' => '98',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 2,
'pid' => 1,
'type' => 'menu',
'title' => '账户概览',
'name' => 'account/overview',
'path' => 'account/overview',
'icon' => 'fa fa-home',
'menu_type' => 'tab',
'component' => '/src/views/frontend/user/account/overview.vue',
'weigh' => '99',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 3,
'pid' => 1,
'type' => 'menu',
'title' => '个人资料',
'name' => 'account/profile',
'path' => 'account/profile',
'icon' => 'fa fa-user-circle-o',
'menu_type' => 'tab',
'component' => '/src/views/frontend/user/account/profile.vue',
'weigh' => '98',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 4,
'pid' => 1,
'type' => 'menu',
'title' => '修改密码',
'name' => 'account/changePassword',
'path' => 'account/changePassword',
'icon' => 'fa fa-shield',
'menu_type' => 'tab',
'component' => '/src/views/frontend/user/account/changePassword.vue',
'weigh' => '97',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 5,
'pid' => 1,
'type' => 'menu',
'title' => '积分记录',
'name' => 'account/integral',
'path' => 'account/integral',
'icon' => 'fa fa-tag',
'menu_type' => 'tab',
'component' => '/src/views/frontend/user/account/integral.vue',
'weigh' => '96',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 6,
'pid' => 1,
'type' => 'menu',
'title' => '余额记录',
'name' => 'account/balance',
'path' => 'account/balance',
'icon' => 'fa fa-money',
'menu_type' => 'tab',
'component' => '/src/views/frontend/user/account/balance.vue',
'weigh' => '95',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user_rule')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
use think\facade\Db;
use think\migration\Migrator;
use Phinx\Db\Adapter\MysqlAdapter;
class Version200 extends Migrator
{
public function up(): void
{
$admin = $this->table('admin');
if ($admin->hasColumn('loginfailure')) {
// 字段改名
$admin->renameColumn('loginfailure', 'login_failure')
->renameColumn('lastlogintime', 'last_login_time')
->renameColumn('lastloginip', 'last_login_ip')
->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['after' => 'update_time', 'limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$adminGroup = $this->table('admin_group');
if ($adminGroup->hasColumn('updatetime')) {
$adminGroup->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$adminLog = $this->table('admin_log');
if ($adminLog->hasColumn('createtime')) {
$adminLog->renameColumn('createtime', 'create_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->changeColumn('data', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'default' => null, 'comment' => '请求数据'])
->save();
}
$attachment = $this->table('attachment');
if ($attachment->hasColumn('createtime')) {
$attachment->renameColumn('createtime', 'create_time')
->renameColumn('lastuploadtime', 'last_upload_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->changeColumn('last_upload_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '最后上传时间'])
->save();
}
$captcha = $this->table('captcha');
if ($captcha->hasColumn('createtime')) {
$captcha->renameColumn('createtime', 'create_time')
->renameColumn('expiretime', 'expire_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->changeColumn('expire_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '过期时间'])
->changeColumn('captcha', 'text', ['limit' => MysqlAdapter::TEXT_REGULAR, 'null' => true, 'default' => null, 'comment' => '验证码数据'])
->save();
}
if ($this->hasTable('menu_rule')) {
$menuRule = $this->table('menu_rule');
if ($menuRule->hasColumn('updatetime') && $this->hasTable('menu_rule')) {
$menuRule->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
$menuRule->rename('admin_rule')->save();
Db::name('admin_rule')
->where('name', 'auth/menu')
->update([
'name' => 'auth/rule',
'path' => 'auth/rule',
'component' => '/src/views/backend/auth/rule/index.vue',
]);
Db::name('admin_rule')->where('name', 'auth/menu/index')->update(['name' => 'auth/rule/index']);
Db::name('admin_rule')->where('name', 'auth/menu/add')->update(['name' => 'auth/rule/add']);
Db::name('admin_rule')->where('name', 'auth/menu/edit')->update(['name' => 'auth/rule/edit']);
Db::name('admin_rule')->where('name', 'auth/menu/del')->update(['name' => 'auth/rule/del']);
Db::name('admin_rule')->where('name', 'auth/menu/sortable')->update(['name' => 'auth/rule/sortable']);
Db::name('admin_rule')->whereIn('name', [
'dashboard/dashboard',
'routine/attachment',
])->update(['remark' => 'Remark lang']);
}
}
$securityDataRecycle = $this->table('security_data_recycle');
if ($securityDataRecycle->hasColumn('updatetime')) {
$securityDataRecycle->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$securityDataRecycleLog = $this->table('security_data_recycle_log');
if ($securityDataRecycleLog->hasColumn('createtime')) {
$securityDataRecycleLog->renameColumn('createtime', 'create_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$securitySensitiveData = $this->table('security_sensitive_data');
if ($securitySensitiveData->hasColumn('updatetime')) {
$securitySensitiveData->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$securitySensitiveDataLog = $this->table('security_sensitive_data_log');
if ($securitySensitiveDataLog->hasColumn('createtime')) {
$securitySensitiveDataLog->renameColumn('createtime', 'create_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$token = $this->table('token');
if ($token->hasColumn('createtime')) {
$token->renameColumn('createtime', 'create_time')
->renameColumn('expiretime', 'expire_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->changeColumn('expire_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '过期时间'])
->save();
}
$userGroup = $this->table('user_group');
if ($userGroup->hasColumn('createtime')) {
$userGroup->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$userMoneyLog = $this->table('user_money_log');
if ($userMoneyLog->hasColumn('createtime')) {
$userMoneyLog->renameColumn('createtime', 'create_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$userRule = $this->table('user_rule');
if ($userRule->hasColumn('createtime')) {
$userRule->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->changeColumn('type', 'enum', ['values' => 'route,menu_dir,menu,nav_user_menu,nav,button', 'default' => 'menu', 'comment' => '类型:route=路由,menu_dir=菜单目录,menu=菜单项,nav_user_menu=顶栏会员菜单下拉项,nav=顶栏菜单项,button=页面按钮', 'null' => false]);
if (!$userRule->hasColumn('no_login_valid')) {
$userRule->addColumn('no_login_valid', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '未登录有效:0=否,1=是']);
}
$userRule->save();
}
$userScoreLog = $this->table('user_score_log');
if ($userScoreLog->hasColumn('createtime')) {
$userScoreLog->renameColumn('createtime', 'create_time')
->changeColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
$user = $this->table('user');
if ($user->hasColumn('loginfailure')) {
$user->renameColumn('lastlogintime', 'last_login_time')
->renameColumn('lastloginip', 'last_login_ip')
->renameColumn('loginfailure', 'login_failure')
->renameColumn('joinip', 'join_ip')
->renameColumn('jointime', 'join_time')
->renameColumn('updatetime', 'update_time')
->renameColumn('createtime', 'create_time')
->changeColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->changeColumn('create_time', 'biginteger', ['after' => 'update_time', 'limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->save();
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
use think\migration\Migrator;
class Version201 extends Migrator
{
public function up(): void
{
$user = $this->table('user');
if ($user->hasIndex('email')) {
$user->removeIndexByName('email')
->removeIndexByName('mobile')
->update();
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
use think\facade\Db;
use think\migration\Migrator;
class Version202 extends Migrator
{
/**
* 规范菜单规则
* @throws Throwable
*/
public function up(): void
{
Db::startTrans();
try {
$dashboardId = Db::name('admin_rule')
->where('name', 'dashboard/dashboard')
->lock(true)
->value('id');
if ($dashboardId) {
// 修改name
Db::name('admin_rule')
->where('name', 'dashboard/dashboard')
->update([
'name' => 'dashboard',
]);
// 增加一个查看的权限节点
$dashboardIndexId = Db::name('admin_rule')->insertGetId([
'pid' => $dashboardId,
'type' => 'button',
'title' => '查看',
'name' => 'dashboard/index',
'update_time' => time(),
'create_time' => time(),
]);
// 原本有控制台权限的管理员,给予新增的查看权限
$group = Db::name('admin_group')
->where('rules', 'find in set', $dashboardId)
->select();
foreach ($group as $item) {
$newRules = trim($item['rules'], ',');
$newRules = $newRules . ',' . $dashboardIndexId;
Db::name('admin_group')
->where('id', $item['id'])
->update([
'rules' => $newRules
]);
}
}
// 修改name
Db::name('admin_rule')
->where('name', 'buildadmin/buildadmin')
->update([
'name' => 'buildadmin',
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw $e;
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
use think\facade\Db;
use app\admin\model\Config;
use think\migration\Migrator;
class Version205 extends Migrator
{
public function up(): void
{
$configQuickEntrance = Config::where('name', 'config_quick_entrance')->find();
$value = $configQuickEntrance->value;
foreach ($value as &$item) {
if (str_starts_with($item['value'], '/admin/')) {
$pathData = Db::name('admin_rule')->where('path', substr($item['value'], 7))->find();
if ($pathData) {
$item['value'] = $pathData['name'];
}
}
}
$configQuickEntrance->value = $value;
$configQuickEntrance->save();
}
}

View File

@@ -0,0 +1,60 @@
<?php
use think\facade\Db;
use think\migration\Migrator;
class Version206 extends Migrator
{
/**
* @throws Throwable
*/
public function up(): void
{
$exist = Db::name('config')->where('name', 'backend_entrance')->value('id');
if (!$exist) {
$rows = [
[
'name' => 'backend_entrance',
'group' => 'basics',
'title' => 'Backend entrance',
'type' => 'string',
'value' => '/admin',
'rule' => 'required',
'weigh' => 1,
],
];
$table = $this->table('config');
$table->insert($rows)->saveData();
}
$crudLog = $this->table('crud_log');
if (!$crudLog->hasColumn('connection')) {
$crudLog->addColumn('connection', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据库连接配置标识', 'null' => false, 'after' => 'status']);
$crudLog->save();
}
$securityDataRecycle = $this->table('security_data_recycle');
if (!$securityDataRecycle->hasColumn('connection')) {
$securityDataRecycle->addColumn('connection', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据库连接配置标识', 'null' => false, 'after' => 'data_table']);
$securityDataRecycle->save();
}
$securityDataRecycleLog = $this->table('security_data_recycle_log');
if (!$securityDataRecycleLog->hasColumn('connection')) {
$securityDataRecycleLog->addColumn('connection', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据库连接配置标识', 'null' => false, 'after' => 'data_table']);
$securityDataRecycleLog->save();
}
$securitySensitiveData = $this->table('security_sensitive_data');
if (!$securitySensitiveData->hasColumn('connection')) {
$securitySensitiveData->addColumn('connection', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据库连接配置标识', 'null' => false, 'after' => 'data_table']);
$securitySensitiveData->save();
}
$securitySensitiveDataLog = $this->table('security_sensitive_data_log');
if (!$securitySensitiveDataLog->hasColumn('connection')) {
$securitySensitiveDataLog->addColumn('connection', 'string', ['limit' => 100, 'default' => '', 'comment' => '数据库连接配置标识', 'null' => false, 'after' => 'data_table']);
$securitySensitiveDataLog->save();
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
use think\facade\Db;
use app\admin\model\CrudLog;
use think\migration\Migrator;
class Version222 extends Migrator
{
/**
* @throws Throwable
*/
public function up(): void
{
/**
* 修复附件表 name 字段长度可能不够的问题
*/
$attachment = $this->table('attachment');
$attachment->changeColumn('name', 'string', ['limit' => 120, 'default' => '', 'comment' => '原始名称', 'null' => false])->save();
/**
* 用户表
* 1. status 注释优化
* 2. password 增加长度至 password_hash 建议值
* 3. salt 注释中标记废弃待删除
*/
$user = $this->table('user');
$user->changeColumn('status', 'string', ['limit' => 30, 'default' => '', 'comment' => '状态:enable=启用,disable=禁用', 'null' => false])
->changeColumn('password', 'string', ['limit' => 255, 'default' => '', 'comment' => '密码', 'null' => false])
->changeColumn('salt', 'string', ['limit' => 30, 'default' => '', 'comment' => '密码盐(废弃待删)', 'null' => false])
->save();
/**
* 管理员表
* 1. status 改为字符串存储
* 2. 其他和以上用户表的改动相同
*/
$admin = $this->table('admin');
$admin->changeColumn('status', 'string', ['limit' => 30, 'default' => '', 'comment' => '状态:enable=启用,disable=禁用', 'null' => false])
->changeColumn('password', 'string', ['limit' => 255, 'default' => '', 'comment' => '密码', 'null' => false])
->changeColumn('salt', 'string', ['limit' => 30, 'default' => '', 'comment' => '密码盐(废弃待删)', 'null' => false])
->save();
Db::name('admin')->where('status', '0')->update(['status' => 'disable']);
Db::name('admin')->where('status', '1')->update(['status' => 'enable']);
/**
* CRUD 历史记录表
*/
$crudLog = $this->table('crud_log');
if (!$crudLog->hasColumn('comment')) {
$crudLog
->addColumn('comment', 'string', ['limit' => 255, 'default' => '', 'comment' => '注释', 'null' => false, 'after' => 'table_name'])
->addColumn('sync', 'integer', ['default' => 0, 'signed' => false, 'comment' => '同步记录', 'null' => false, 'after' => 'fields'])
->save();
$logs = CrudLog::select();
foreach ($logs as $log) {
if ($log->table['comment']) {
$log->comment = $log->table['comment'];
$log->save();
}
}
}
/**
* 多个数据表的 status 字段类型修改为更合理的类型
*/
$tables = ['admin_group', 'admin_rule', 'user_group', 'user_rule', 'security_data_recycle', 'security_sensitive_data', 'test_build'];
foreach ($tables as $table) {
if ($this->hasTable($table)) {
$mTable = $this->table($table);
$mTable->changeColumn('status', 'boolean', ['default' => 1, 'signed' => false, 'comment' => '状态:0=禁用,1=启用', 'null' => false])->save();
// 原状态值兼容至新类型
Db::name($table)->where('status', 1)->update(['status' => 0]);
Db::name($table)->where('status', 2)->update(['status' => 1]);
}
}
}
}

241
extend/ba/Auth.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
namespace ba;
use Throwable;
use think\facade\Db;
/**
* 权限规则类
*/
class Auth
{
/**
* 默认配置
* @var array|string[]
*/
protected array $config = [
'auth_group' => 'admin_group', // 用户组数据表名
'auth_group_access' => 'admin_group_access', // 用户-用户组关系表
'auth_rule' => 'admin_rule', // 权限规则表
];
/**
* 子菜单规则数组
* @var array
*/
protected array $children = [];
/**
* 构造方法
* @param array $config
*/
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
}
/**
* 魔术方法-获取当前配置
* @param $name
* @return mixed
*/
public function __get($name): mixed
{
return $this->config[$name];
}
/**
* 获取菜单规则列表
* @access public
* @param int $uid 用户ID
* @return array
* @throws Throwable
*/
public function getMenus(int $uid): array
{
$this->children = [];
$originAuthRules = $this->getOriginAuthRules($uid);
foreach ($originAuthRules as $rule) {
$this->children[$rule['pid']][] = $rule;
}
// 没有根菜单规则
if (!isset($this->children[0])) return [];
return $this->getChildren($this->children[0]);
}
/**
* 获取传递的菜单规则的子规则
* @param array $rules 菜单规则
* @return array
*/
private function getChildren(array $rules): array
{
foreach ($rules as $key => $rule) {
if (array_key_exists($rule['id'], $this->children)) {
$rules[$key]['children'] = $this->getChildren($this->children[$rule['id']]);
}
}
return $rules;
}
/**
* 检查是否有某权限
* @param string $name 菜单规则的 name可以传递两个以','号隔开
* @param int $uid 用户ID
* @param string $relation 如果出现两个 name,是两个都通过(and)还是一个通过即可(or)
* @param string $mode 如果不使用 url 则菜单规则name匹配到即通过
* @return bool
* @throws Throwable
*/
public function check(string $name, int $uid, string $relation = 'or', string $mode = 'url'): bool
{
// 获取用户需要验证的所有有效规则列表
$ruleList = $this->getRuleList($uid);
if (in_array('*', $ruleList)) {
return true;
}
if ($name) {
$name = strtolower($name);
if (str_contains($name, ',')) {
$name = explode(',', $name);
} else {
$name = [$name];
}
}
$list = []; //保存验证通过的规则名
if ('url' == $mode) {
$REQUEST = json_decode(strtolower(json_encode(request()->param(), JSON_UNESCAPED_UNICODE)), true);
}
foreach ($ruleList as $rule) {
$query = preg_replace('/^.+\?/U', '', $rule);
if ('url' == $mode && $query != $rule) {
parse_str($query, $param); //解析规则中的param
$intersect = array_intersect_assoc($REQUEST, $param);
$rule = preg_replace('/\?.*$/U', '', $rule);
if (in_array($rule, $name) && $intersect == $param) {
// 如果节点相符且url参数满足
$list[] = $rule;
}
} elseif (in_array($rule, $name)) {
$list[] = $rule;
}
}
if ('or' == $relation && !empty($list)) {
return true;
}
$diff = array_diff($name, $list);
if ('and' == $relation && empty($diff)) {
return true;
}
return false;
}
/**
* 获得权限规则列表
* @param int $uid 用户id
* @return array
* @throws Throwable
*/
public function getRuleList(int $uid): array
{
// 读取用户规则节点
$ids = $this->getRuleIds($uid);
if (empty($ids)) return [];
$originAuthRules = $this->getOriginAuthRules($uid);
// 用户规则
$rules = [];
if (in_array('*', $ids)) {
$rules[] = "*";
}
foreach ($originAuthRules as $rule) {
$rules[$rule['id']] = strtolower($rule['name']);
}
return array_unique($rules);
}
/**
* 获得权限规则原始数据
* @param int $uid 用户id
* @return array
* @throws Throwable
*/
public function getOriginAuthRules(int $uid): array
{
$ids = $this->getRuleIds($uid);
if (empty($ids)) return [];
$where = [];
$where[] = ['status', '=', '1'];
// 如果没有 * 则只获取用户拥有的规则
if (!in_array('*', $ids)) {
$where[] = ['id', 'in', $ids];
}
$rules = Db::name($this->config['auth_rule'])
->withoutField(['remark', 'status', 'weigh', 'update_time', 'create_time'])
->where($where)
->order('weigh desc,id asc')
->select()
->toArray();
foreach ($rules as $key => $rule) {
if (!empty($rule['keepalive'])) {
$rules[$key]['keepalive'] = $rule['name'];
}
}
return $rules;
}
/**
* 获取权限规则ids
* @param int $uid
* @return array
* @throws Throwable
*/
public function getRuleIds(int $uid): array
{
// 用户的组别和规则ID
$groups = $this->getGroups($uid);
$ids = [];
foreach ($groups as $g) {
$ids = array_merge($ids, explode(',', trim($g['rules'], ',')));
}
return array_unique($ids);
}
/**
* 获取用户所有分组和对应权限规则
* @param int $uid
* @return array
* @throws Throwable
*/
public function getGroups(int $uid): array
{
$dbName = $this->config['auth_group_access'] ?: 'user';
if ($this->config['auth_group_access']) {
$userGroups = Db::name($dbName)
->alias('aga')
->join($this->config['auth_group'] . ' ag', 'aga.group_id = ag.id', 'LEFT')
->field('aga.uid,aga.group_id,ag.id,ag.pid,ag.name,ag.rules')
->where("aga.uid='$uid' and ag.status='1'")
->select()
->toArray();
} else {
$userGroups = Db::name($dbName)
->alias('u')
->join($this->config['auth_group'] . ' ag', 'u.group_id = ag.id', 'LEFT')
->field('u.id as uid,u.group_id,ag.id,ag.name,ag.rules')
->where("u.id='$uid' and ag.status='1'")
->select()
->toArray();
}
return $userGroups;
}
}

441
extend/ba/Captcha.php Normal file
View File

@@ -0,0 +1,441 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2015 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: yunwuxin <448901948@qq.com>
// +----------------------------------------------------------------------
// | 妙码生花在 2022-2-26 进行修订通过Mysql保存验证码而不是Session以更好的支持API访问
// | 使用Cache不能清理过期验证码且一旦执行清理缓存操作验证码将失效
// +----------------------------------------------------------------------
namespace ba;
use GdImage;
use Throwable;
use think\Response;
use think\facade\Db;
/**
* 验证码类(图形验证码、继续流程验证码)
* @property string $seKey 验证码加密密钥
* @property string $codeSet 验证码字符集合
* @property int $expire 验证码过期时间s
* @property bool $useZh 使用中文验证码
* @property string $zhSet 中文验证码字符串
* @property bool $useImgBg 使用背景图片
* @property int $fontSize 验证码字体大小(px)
* @property bool $useCurve 是否画混淆曲线
* @property bool $useNoise 是否添加杂点
* @property int $imageH 验证码图片高度
* @property int $imageW 验证码图片宽度
* @property int $length 验证码位数
* @property string $fontTtf 验证码字体,不设置随机获取
* @property array $bg 背景颜色
* @property bool $reset 验证成功后是否重置
*/
class Captcha
{
protected array $config = [
// 验证码加密密钥
'seKey' => 'BuildAdmin',
// 验证码字符集合
'codeSet' => '2345678abcdefhijkmnpqrstuvwxyzABCDEFGHJKLMNPQRTUVWXY',
// 验证码过期时间s
'expire' => 600,
// 使用中文验证码
'useZh' => false,
// 中文验证码字符串
'zhSet' => '们以我到他会作时要动国产的一是工就年阶义发成部民可出能方进在了不和有大这主中人上为来分生对于学下级地个用同行面说种过命度革而多子后自社加小机也经力线本电高量长党得实家定深法表着水理化争现所二起政三好十战无农使性前等反体合斗路图把结第里正新开论之物从当两些还天资事队批点育重其思与间内去因件日利相由压员气业代全组数果期导平各基或月毛然如应形想制心样干都向变关问比展那它最及外没看治提五解系林者米群头意只明四道马认次文通但条较克又公孔领军流入接席位情运器并飞原油放立题质指建区验活众很教决特此常石强极土少已根共直团统式转别造切九你取西持总料连任志观调七么山程百报更见必真保热委手改管处己将修支识病象几先老光专什六型具示复安带每东增则完风回南广劳轮科北打积车计给节做务被整联步类集号列温装即毫知轴研单色坚据速防史拉世设达尔场织历花受求传口断况采精金界品判参层止边清至万确究书术状厂须离再目海交权且儿青才证低越际八试规斯近注办布门铁需走议县兵固除般引齿千胜细影济白格效置推空配刀叶率述今选养德话查差半敌始片施响收华觉备名红续均药标记难存测士身紧液派准斤角降维板许破述技消底床田势端感往神便贺村构照容非搞亚磨族火段算适讲按值美态黄易彪服早班麦削信排台声该击素张密害侯草何树肥继右属市严径螺检左页抗苏显苦英快称坏移约巴材省黑武培著河帝仅针怎植京助升王眼她抓含苗副杂普谈围食射源例致酸旧却充足短划剂宣环落首尺波承粉践府鱼随考刻靠够满夫失包住促枝局菌杆周护岩师举曲春元超负砂封换太模贫减阳扬江析亩木言球朝医校古呢稻宋听唯输滑站另卫字鼓刚写刘微略范供阿块某功套友限项余倒卷创律雨让骨远帮初皮播优占死毒圈伟季训控激找叫云互跟裂粮粒母练塞钢顶策双留误础吸阻故寸盾晚丝女散焊功株亲院冷彻弹错散商视艺灭版烈零室轻血倍缺厘泵察绝富城冲喷壤简否柱李望盘磁雄似困巩益洲脱投送奴侧润盖挥距触星松送获兴独官混纪依未突架宽冬章湿偏纹吃执阀矿寨责熟稳夺硬价努翻奇甲预职评读背协损棉侵灰虽矛厚罗泥辟告卵箱掌氧恩爱停曾溶营终纲孟钱待尽俄缩沙退陈讨奋械载胞幼哪剥迫旋征槽倒握担仍呀鲜吧卡粗介钻逐弱脚怕盐末阴丰雾冠丙街莱贝辐肠付吉渗瑞惊顿挤秒悬姆烂森糖圣凹陶词迟蚕亿矩康遵牧遭幅园腔订香肉弟屋敏恢忘编印蜂急拿扩伤飞露核缘游振操央伍域甚迅辉异序免纸夜乡久隶缸夹念兰映沟乙吗儒杀汽磷艰晶插埃燃欢铁补咱芽永瓦倾阵碳演威附牙芽永瓦斜灌欧献顺猪洋腐请透司危括脉宜笑若尾束壮暴企菜穗楚汉愈绿拖牛份染既秋遍锻玉夏疗尖殖井费州访吹荣铜沿替滚客召旱悟刺脑措贯藏敢令隙炉壳硫煤迎铸粘探临薄旬善福纵择礼愿伏残雷延烟句纯渐耕跑泽慢栽鲁赤繁境潮横掉锥希池败船假亮谓托伙哲怀割摆贡呈劲财仪沉炼麻罪祖息车穿货销齐鼠抽画饲龙库守筑房歌寒喜哥洗蚀废纳腹乎录镜妇恶脂庄擦险赞钟摇典柄辩竹谷卖乱虚桥奥伯赶垂途额壁网截野遗静谋弄挂课镇妄盛耐援扎虑键归符庆聚绕摩忙舞遇索顾胶羊湖钉仁音迹碎伸灯避泛亡答勇频皇柳哈揭甘诺概宪浓岛袭谁洪谢炮浇斑讯懂灵蛋闭孩释乳巨徒私银伊景坦累匀霉杜乐勒隔弯绩招绍胡呼痛峰零柴簧午跳居尚丁秦稍追梁折耗碱殊岗挖氏刃剧堆赫荷胸衡勤膜篇登驻案刊秧缓凸役剪川雪链渔啦脸户洛孢勃盟买杨宗焦赛旗滤硅炭股坐蒸凝竟陷枪黎救冒暗洞犯筒您宋弧爆谬涂味津臂障褐陆啊健尊豆拔莫抵桑坡缝警挑污冰柬嘴啥饭塑寄赵喊垫丹渡耳刨虎笔稀昆浪萨茶滴浅拥穴覆伦娘吨浸袖珠雌妈紫戏塔锤震岁貌洁剖牢锋疑霸闪埔猛诉刷狠忽灾闹乔唐漏闻沈熔氯荒茎男凡抢像浆旁玻亦忠唱蒙予纷捕锁尤乘乌智淡允叛畜俘摸锈扫毕璃宝芯爷鉴秘净蒋钙肩腾枯抛轨堂拌爸循诱祝励肯酒绳穷塘燥泡袋朗喂铝软渠颗惯贸粪综墙趋彼届墨碍启逆卸航衣孙龄岭骗休借',
// 使用背景图片
'useImgBg' => false,
// 验证码字体大小(px)
'fontSize' => 25,
// 是否画混淆曲线
'useCurve' => true,
// 是否添加杂点
'useNoise' => true,
// 验证码图片高度
'imageH' => 0,
// 验证码图片宽度
'imageW' => 0,
// 验证码位数
'length' => 4,
// 验证码字体,不设置随机获取
'fontTtf' => '',
// 背景颜色
'bg' => [243, 251, 254],
// 验证成功后是否重置
'reset' => true,
];
/**
* 验证码图片实例
* @var GdImage|resource|null
*/
private $image = null;
/**
* 验证码字体颜色
* @var bool|int|null
*/
private bool|int|null $color = null;
/**
* 架构方法 设置参数
* @param array $config 配置参数
* @throws Throwable
*/
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
// 清理过期的验证码
Db::name('captcha')
->where('expire_time', '<', time())
->delete();
}
/**
* 使用 $this->name 获取配置
* @param string $name 配置名称
* @return mixed 配置值
*/
public function __get(string $name): mixed
{
return $this->config[$name];
}
/**
* 设置验证码配置
* @param string $name 配置名称
* @param mixed $value 配置值
* @return void
*/
public function __set(string $name, mixed $value): void
{
if (isset($this->config[$name])) {
$this->config[$name] = $value;
}
}
/**
* 检查配置
* @param string $name 配置名称
* @return bool
*/
public function __isset(string $name): bool
{
return isset($this->config[$name]);
}
/**
* 验证验证码是否正确
* @param string $code 用户验证码
* @param string $id 验证码标识
* @return bool 用户验证码是否正确
* @throws Throwable
*/
public function check(string $code, string $id): bool
{
$key = $this->authCode($this->seKey, $id);
$seCode = Db::name('captcha')->where('key', $key)->find();
// 验证码为空
if (empty($code) || empty($seCode)) {
return false;
}
// 验证码过期
if (time() > $seCode['expire_time']) {
Db::name('captcha')->where('key', $key)->delete();
return false;
}
if ($this->authCode(strtoupper($code), $id) == $seCode['code']) {
$this->reset && Db::name('captcha')->where('key', $key)->delete();
return true;
}
return false;
}
/**
* 创建一个逻辑验证码可供后续验证(非图形)
* @param string $id 验证码标识
* @param string|bool $captcha 验证码,不传递则自动生成
* @return string 生成的验证码,发送出去或做它用...
* @throws Throwable
*/
public function create(string $id, string|bool $captcha = false): string
{
$nowTime = time();
$key = $this->authCode($this->seKey, $id);
$captchaTemp = Db::name('captcha')->where('key', $key)->find();
if ($captchaTemp) {
// 重复的为同一标识创建验证码
Db::name('captcha')->where('key', $key)->delete();
}
$captcha = $this->generate($captcha);
$code = $this->authCode($captcha, $id);
Db::name('captcha')
->insert([
'key' => $key,
'code' => $code,
'captcha' => $captcha,
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
return $captcha;
}
/**
* 获取验证码数据
* @param string $id 验证码标识
* @return array
* @throws Throwable
*/
public function getCaptchaData(string $id): array
{
$key = $this->authCode($this->seKey, $id);
$seCode = Db::name('captcha')->where('key', $key)->find();
return $seCode ?: [];
}
/**
* 输出图形验证码并把验证码的值保存的Mysql中
* @param string $id 要生成验证码的标识
* @return Response
* @throws Throwable
*/
public function entry(string $id): Response
{
$nowTime = time();
// 图片宽(px)
$this->imageW || $this->imageW = $this->length * $this->fontSize * 1.5 + $this->length * $this->fontSize / 2;
// 图片高(px)
$this->imageH || $this->imageH = $this->fontSize * 2.5;
// 建立一幅 $this->imageW x $this->imageH 的图像
$this->image = imagecreate($this->imageW, $this->imageH);
// 设置背景
imagecolorallocate($this->image, $this->bg[0], $this->bg[1], $this->bg[2]);
// 验证码字体随机颜色
$this->color = imagecolorallocate($this->image, mt_rand(1, 150), mt_rand(1, 150), mt_rand(1, 150));
// 验证码使用随机字体
$ttfPath = public_path() . 'static' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR . ($this->useZh ? 'zhttfs' : 'ttfs') . DIRECTORY_SEPARATOR;
if (empty($this->fontTtf)) {
$dir = dir($ttfPath);
$ttfFiles = [];
while (false !== ($file = $dir->read())) {
if ('.' != $file[0] && str_ends_with($file, '.ttf')) {
$ttfFiles[] = $file;
}
}
$dir->close();
$this->fontTtf = $ttfFiles[array_rand($ttfFiles)];
}
$this->fontTtf = $ttfPath . $this->fontTtf;
if ($this->useImgBg) {
$this->background();
}
if ($this->useNoise) {
// 绘杂点
$this->writeNoise();
}
if ($this->useCurve) {
// 绘干扰线
$this->writeCurve();
}
$key = $this->authCode($this->seKey, $id);
$captcha = Db::name('captcha')->where('key', $key)->find();
// 绘验证码
if ($captcha && $nowTime <= $captcha['expire_time']) {
$this->writeText($captcha['captcha']);
} else {
$captcha = $this->writeText();
// 保存验证码
$code = $this->authCode(strtoupper(implode('', $captcha)), $id);
Db::name('captcha')->insert([
'key' => $key,
'code' => $code,
'captcha' => strtoupper(implode('', $captcha)),
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
}
ob_start();
// 输出图像
imagepng($this->image);
$content = ob_get_clean();
return response($content, 200, ['Content-Length' => strlen($content)])->contentType('image/png');
}
/**
* 绘验证码
* @param string $captcha 验证码
* @return array|string 验证码
*/
private function writeText(string $captcha = ''): array|string
{
$code = []; // 验证码
$codeNX = 0; // 验证码第N个字符的左边距
if ($this->useZh) {
// 中文验证码
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : iconv_substr($this->zhSet, floor(mt_rand(0, mb_strlen($this->zhSet, 'utf-8') - 1)), 1, 'utf-8');
imagettftext($this->image, $this->fontSize, mt_rand(-40, 40), $this->fontSize * ($i + 1) * 1.5, $this->fontSize + mt_rand(10, 20), (int)$this->color, $this->fontTtf, $code[$i]);
}
} else {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : $this->codeSet[mt_rand(0, strlen($this->codeSet) - 1)];
$codeNX += mt_rand((int)($this->fontSize * 1.2), (int)($this->fontSize * 1.6));
imagettftext($this->image, $this->fontSize, mt_rand(-40, 40), $codeNX, (int)($this->fontSize * 1.6), (int)$this->color, $this->fontTtf, $code[$i]);
}
}
return $captcha ?: $code;
}
/**
* 画一条由两条连在一起构成的随机正弦函数曲线作干扰线(你可以改成更帅的曲线函数)
* 正弦型函数解析式y=Asin(ωx+φ)+b
* 各常数值对函数图像的影响:
* A决定峰值即纵向拉伸压缩的倍数
* b表示波形在Y轴的位置关系或纵向移动距离上加下减
* φ决定波形与X轴位置关系或横向移动距离左加右减
* ω决定周期最小正周期T=2π/∣ω∣)
*/
private function writeCurve(): void
{
$py = 0;
// 曲线前部分
$A = mt_rand(1, $this->imageH / 2); // 振幅
$b = mt_rand(-$this->imageH / 4, $this->imageH / 4); // Y轴方向偏移量
$f = mt_rand(-$this->imageH / 4, $this->imageH / 4); // X轴方向偏移量
$T = mt_rand($this->imageH, $this->imageW * 2); // 周期
$w = (2 * M_PI) / $T;
$px1 = 0; // 曲线横坐标起始位置
$px2 = mt_rand($this->imageW / 2, $this->imageW * 0.8); // 曲线横坐标结束位置
for ($px = $px1; $px <= $px2; $px = $px + 1) {
if (0 != $w) {
$py = $A * sin($w * $px + $f) + $b + $this->imageH / 2; // y = Asin(ωx+φ) + b
$i = (int)($this->fontSize / 5);
while ($i > 0) {
imagesetpixel($this->image, $px + $i, $py + $i, (int)$this->color); // 这里(while)循环画像素点比imagettftext和imagestring用字体大小一次画出不用这while循环性能要好很多
$i--;
}
}
}
// 曲线后部分
$A = mt_rand(1, $this->imageH / 2); // 振幅
$f = mt_rand(-$this->imageH / 4, $this->imageH / 4); // X轴方向偏移量
$T = mt_rand($this->imageH, $this->imageW * 2); // 周期
$w = (2 * M_PI) / $T;
$b = $py - $A * sin($w * $px + $f) - $this->imageH / 2;
$px1 = $px2;
$px2 = $this->imageW;
for ($px = $px1; $px <= $px2; $px = $px + 1) {
if (0 != $w) {
$py = $A * sin($w * $px + $f) + $b + $this->imageH / 2; // y = Asin(ωx+φ) + b
$i = (int)($this->fontSize / 5);
while ($i > 0) {
imagesetpixel($this->image, $px + $i, $py + $i, (int)$this->color);
$i--;
}
}
}
}
/**
* 绘杂点,往图片上写不同颜色的字母或数字
*/
private function writeNoise(): void
{
$codeSet = '2345678abcdefhijkmnpqrstuvwxyz';
for ($i = 0; $i < 10; $i++) {
//杂点颜色
$noiseColor = imagecolorallocate($this->image, mt_rand(150, 225), mt_rand(150, 225), mt_rand(150, 225));
for ($j = 0; $j < 5; $j++) {
// 绘制
imagestring($this->image, 5, mt_rand(-10, $this->imageW), mt_rand(-10, $this->imageH), $codeSet[mt_rand(0, 29)], $noiseColor);
}
}
}
/**
* 绘制背景图片
*
* 注:如果验证码输出图片比较大,将占用比较多的系统资源
*/
private function background(): void
{
$path = Filesystem::fsFit(public_path() . 'static/images/captcha/image/');
$dir = dir($path);
$bgs = [];
while (false !== ($file = $dir->read())) {
if ('.' != $file[0] && str_ends_with($file, '.jpg')) {
$bgs[] = $path . $file;
}
}
$dir->close();
$gb = $bgs[array_rand($bgs)];
list($width, $height) = @getimagesize($gb);
// Resample
$bgImage = @imagecreatefromjpeg($gb);
@imagecopyresampled($this->image, $bgImage, 0, 0, 0, 0, $this->imageW, $this->imageH, $width, $height);
}
/**
* 加密验证码
* @param string $str 验证码字符串
* @param string $id 验证码标识
*/
private function authCode(string $str, string $id): string
{
$key = substr(md5($this->seKey), 5, 8);
$str = substr(md5($str), 8, 10);
return md5($key . $str . $id);
}
/**
* 生成验证码随机字符
* @param bool|string $captcha
* @return string
*/
private function generate(bool|string $captcha = false): string
{
$code = []; // 验证码
if ($this->useZh) {
// 中文验证码
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : iconv_substr($this->zhSet, floor(mt_rand(0, mb_strlen($this->zhSet, 'utf-8') - 1)), 1, 'utf-8');
}
} else {
for ($i = 0; $i < $this->length; $i++) {
$code[$i] = $captcha ? $captcha[$i] : $this->codeSet[mt_rand(0, strlen($this->codeSet) - 1)];
}
}
$captcha = $captcha ?: implode('', $code);
return strtoupper($captcha);
}
}

335
extend/ba/ClickCaptcha.php Normal file
View File

@@ -0,0 +1,335 @@
<?php
namespace ba;
use Throwable;
use think\facade\Db;
use think\facade\Lang;
use think\facade\Config;
/**
* 点选文字验证码类
*/
class ClickCaptcha
{
/**
* 验证码过期时间(s)
* @var int
*/
private int $expire = 600;
/**
* 可以使用的背景图片路径
* @var array
*/
private array $bgPaths = [
'static/images/captcha/click/bgs/1.png',
'static/images/captcha/click/bgs/2.png',
'static/images/captcha/click/bgs/3.png',
];
/**
* 可以使用的字体文件路径
* @var array
*/
private array $fontPaths = [
'static/fonts/zhttfs/SourceHanSansCN-Normal.ttf',
];
/**
* 验证点 Icon 映射表
* @var array
*/
private array $iconDict = [
'aeroplane' => '飞机',
'apple' => '苹果',
'banana' => '香蕉',
'bell' => '铃铛',
'bicycle' => '自行车',
'bird' => '小鸟',
'bomb' => '炸弹',
'butterfly' => '蝴蝶',
'candy' => '糖果',
'crab' => '螃蟹',
'cup' => '杯子',
'dolphin' => '海豚',
'fire' => '火',
'guitar' => '吉他',
'hexagon' => '六角形',
'pear' => '梨',
'rocket' => '火箭',
'sailboat' => '帆船',
'snowflake' => '雪花',
'wolf head' => '狼头',
];
/**
* 配置
* @var array
*/
private array $config = [
// 透明度
'alpha' => 36,
// 中文字符集
'zhSet' => '们以我到他会作时要动国产的是工就年阶义发成部民可出能方进在和有大这主中为来分生对于学级地用同行面说种过命度革而多子后自社加小机也经力线本电高量长党得实家定深法表着水理化争现所起政好十战无农使前等反体合斗路图把结第里正新开论之物从当两些还天资事队点育重其思与间内去因件利相由压员气业代全组数果期导平各基或月然如应形想制心样都向变关问比展那它最及外没看治提五解系林者米群头意只明四道马认次文通但条较克又公孔领军流接席位情运器并飞原油放立题质指建区验活众很教决特此常石强极已根共直团统式转别造切九你取西持总料连任志观调么山程百报更见必真保热委手改管处己将修支识象先老光专什六型具示复安带每东增则完风回南劳轮科北打积车计给节做务被整联步类集号列温装即毫知轴研单坚据速防史拉世设达尔场织历花求传断况采精金界品判参层止边清至万确究书术状须离再目海权且青才证低越际八试规斯近注办布门铁需走议县兵固除般引齿胜细影济白格效置推空配叶率述今选养德话查差半敌始片施响收华觉备名红续均药标记难存测身紧液派准斤角降维板许破述技消底床田势端感往神便贺村构照容非亚磨族段算适讲按值美态易彪服早班麦削信排台声该击素张密害侯何树肥继右属市严径螺检左页抗苏显苦英快称坏移巴材省黑武培著河帝仅针怎植京助升王眼她抓苗副杂普谈围食源例致酸旧却充足短划剂宣环落首尺波承粉践府鱼随考刻靠够满夫失包住促枝局菌杆周护岩师举曲春元超负砂封换太模贫减阳扬江析亩木言球朝医校古呢稻宋听唯输滑站另卫字鼓刚写刘微略范供阿块某功友限项余倒卷创律雨让骨远帮初皮播优占圈伟季训控激找叫云互跟粮粒母练塞钢顶策双留误础阻故寸盾晚丝女散焊功株亲院冷彻弹错散商视艺版烈零室轻倍缺厘泵察绝富城冲喷壤简否柱李望盘磁雄似困巩益洲脱投送侧润盖挥距触星松送获兴独官混纪依未突架宽冬章偏纹吃执阀矿寨责熟稳夺硬价努翻奇甲预职评读背协损棉侵灰虽矛厚罗泥辟告箱掌氧恩爱停曾溶营终纲孟钱待尽俄缩沙退陈讨奋械载胞哪旋征槽倒握担仍呀鲜吧卡粗介钻逐弱脚怕盐末丰雾冠丙街莱贝辐肠付吉渗瑞惊顿挤秒悬姆森糖圣凹陶词迟蚕亿矩康遵牧遭幅园腔订香肉弟屋敏恢忘编印蜂急拿扩飞露核缘游振操央伍域甚迅辉异序免纸夜乡久隶念兰映沟乙吗儒汽磷艰晶埃燃欢铁补咱芽永瓦倾阵碳演威附牙芽永瓦斜灌欧献顺猪洋腐请透司括脉宜笑若尾束壮暴企菜穗楚汉愈绿拖牛份染既秋遍锻玉夏疗尖井费州访吹荣铜沿替滚客召旱悟刺脑措贯藏敢令隙炉壳硫煤迎铸粘探临薄旬善福纵择礼愿伏残雷延烟句纯渐耕跑泽慢栽鲁赤繁境潮横掉锥希池败船假亮谓托伙哲怀摆贡呈劲财仪沉炼麻祖息车穿货销齐鼠抽画饲龙库守筑房歌寒喜哥洗蚀废纳腹乎录镜脂庄擦险赞钟摇典柄辩竹谷乱虚桥奥伯赶垂途额壁网截野遗静谋弄挂课镇妄盛耐扎虑键归符庆聚绕摩忙舞遇索顾胶羊湖钉仁音迹碎伸灯避泛答勇频皇柳哈揭甘诺概宪浓岛袭谁洪谢炮浇斑讯懂灵蛋闭孩释巨徒私银伊景坦累匀霉杜乐勒隔弯绩招绍胡呼峰零柴簧午跳居尚秦稍追梁折耗碱殊岗挖氏刃剧堆赫荷胸衡勤膜篇登驻案刊秧缓凸役剪川雪链渔啦脸户洛孢勃盟买杨宗焦赛旗滤硅炭股坐蒸凝竟枪黎救冒暗洞犯筒您宋弧爆谬涂味津臂障褐陆啊健尊豆拔莫抵桑坡缝警挑冰柬嘴啥饭塑寄赵喊垫丹渡耳虎笔稀昆浪萨茶滴浅拥覆伦娘吨浸袖珠雌妈紫戏塔锤震岁貌洁剖牢锋疑霸闪埔猛诉刷忽闹乔唐漏闻沈熔氯荒茎男凡抢像浆旁玻亦忠唱蒙予纷捕锁尤乘乌智淡允叛畜俘摸锈扫毕璃宝芯爷鉴秘净蒋钙肩腾枯抛轨堂拌爸循诱祝励肯酒绳塘燥泡袋朗喂铝软渠颗惯贸综墙趋彼届墨碍启逆卸航衣孙龄岭休借',
];
/**
* 构造方法
* @param array $config 点击验证码配置
* @throws Throwable
*/
public function __construct(array $config = [])
{
$clickConfig = Config::get('buildadmin.click_captcha');
$this->config = array_merge($clickConfig, $this->config, $config);
// 清理过期的验证码
Db::name('captcha')->where('expire_time', '<', time())->delete();
}
/**
* 创建图形验证码
* @param string $id 验证码ID开发者自定义
* @return array 返回验证码图片的base64编码和验证码文字信息
*/
public function creat(string $id): array
{
$imagePath = Filesystem::fsFit(public_path() . $this->bgPaths[mt_rand(0, count($this->bgPaths) - 1)]);
$fontPath = Filesystem::fsFit(public_path() . $this->fontPaths[mt_rand(0, count($this->fontPaths) - 1)]);
$randPoints = $this->randPoints($this->config['length'] + $this->config['confuse_length']);
$lang = Lang::getLangSet();
foreach ($randPoints as $v) {
$tmp['size'] = rand(15, 30);
if (isset($this->iconDict[$v])) {
// 图标
$tmp['icon'] = true;
$tmp['name'] = $v;
$tmp['text'] = $lang == 'zh-cn' ? "<{$this->iconDict[$v]}>" : "<$v>";
$iconInfo = getimagesize(Filesystem::fsFit(public_path() . 'static/images/captcha/click/icons/' . $v . '.png'));
$tmp['width'] = $iconInfo[0];
$tmp['height'] = $iconInfo[1];
} else {
// 字符串文本框宽度和长度
$fontArea = imagettfbbox($tmp['size'], 0, $fontPath, $v);
$textWidth = $fontArea[2] - $fontArea[0];
$textHeight = $fontArea[1] - $fontArea[7];
$tmp['icon'] = false;
$tmp['text'] = $v;
$tmp['width'] = $textWidth;
$tmp['height'] = $textHeight;
}
$textArr['text'][] = $tmp;
}
// 图片宽高和类型
$imageInfo = getimagesize($imagePath);
$textArr['width'] = $imageInfo[0];
$textArr['height'] = $imageInfo[1];
// 随机生成验证点位置
foreach ($textArr['text'] as &$v) {
list($x, $y) = $this->randPosition($textArr['text'], $textArr['width'], $textArr['height'], $v['width'], $v['height'], $v['icon']);
$v['x'] = $x;
$v['y'] = $y;
$text[] = $v['text'];
}
unset($v);
// 创建图片的实例
$image = imagecreatefromstring(file_get_contents($imagePath));
foreach ($textArr['text'] as $v) {
if ($v['icon']) {
$this->iconCover($image, $v);
} else {
//字体颜色
$color = imagecolorallocatealpha($image, 239, 239, 234, 127 - intval($this->config['alpha'] * (127 / 100)));
// 绘画文字
imagettftext($image, $v['size'], 0, $v['x'], $v['y'], $color, $fontPath, $v['text']);
}
}
$nowTime = time();
$textArr['text'] = array_splice($textArr['text'], 0, $this->config['length']);
$text = array_splice($text, 0, $this->config['length']);
Db::name('captcha')
->replace()
->insert([
'key' => md5($id),
'code' => md5(implode(',', $text)),
'captcha' => json_encode($textArr, JSON_UNESCAPED_UNICODE),
'create_time' => $nowTime,
'expire_time' => $nowTime + $this->expire
]);
// 输出图片
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
switch ($imageInfo[2]) {
case 1:// GIF
imagegif($image);
$content = ob_get_clean();
break;
case 2:// JPG
imagejpeg($image);
$content = ob_get_clean();
break;
case 3:// PNG
imagepng($image);
$content = ob_get_clean();
break;
default:
$content = '';
break;
}
return [
'id' => $id,
'text' => $text,
'base64' => 'data:' . $imageInfo['mime'] . ';base64,' . base64_encode($content),
'width' => $textArr['width'],
'height' => $textArr['height'],
];
}
/**
* 检查验证码
* @param string $id 开发者自定义的验证码ID
* @param string $info 验证信息
* @param bool $unset 验证成功是否删除验证码
* @return bool
* @throws Throwable
*/
public function check(string $id, string $info, bool $unset = true): bool
{
$key = md5($id);
$captcha = Db::name('captcha')->where('key', $key)->find();
if ($captcha) {
// 验证码过期
if (time() > $captcha['expire_time']) {
Db::name('captcha')->where('key', $key)->delete();
return false;
}
$textArr = json_decode($captcha['captcha'], true);
list($xy, $w, $h) = explode(';', $info);
$xyArr = explode('-', $xy);
$xPro = $w / $textArr['width'];// 宽度比例
$yPro = $h / $textArr['height'];// 高度比例
foreach ($xyArr as $k => $v) {
$xy = explode(',', $v);
$x = $xy[0];
$y = $xy[1];
if ($x / $xPro < $textArr['text'][$k]['x'] || $x / $xPro > $textArr['text'][$k]['x'] + $textArr['text'][$k]['width']) {
return false;
}
$phStart = $textArr['text'][$k]['icon'] ? $textArr['text'][$k]['y'] : $textArr['text'][$k]['y'] - $textArr['text'][$k]['height'];
$phEnd = $textArr['text'][$k]['icon'] ? $textArr['text'][$k]['y'] + $textArr['text'][$k]['height'] : $textArr['text'][$k]['y'];
if ($y / $yPro < $phStart || $y / $yPro > $phEnd) {
return false;
}
}
if ($unset) Db::name('captcha')->where('key', $key)->delete();
return true;
} else {
return false;
}
}
/**
* 绘制Icon
*/
protected function iconCover($bgImg, $iconImgData): void
{
$iconImage = imagecreatefrompng(Filesystem::fsFit(public_path() . 'static/images/captcha/click/icons/' . $iconImgData['name'] . '.png'));
$trueColorImage = imagecreatetruecolor($iconImgData['width'], $iconImgData['height']);
imagecopy($trueColorImage, $bgImg, 0, 0, $iconImgData['x'], $iconImgData['y'], $iconImgData['width'], $iconImgData['height']);
imagecopy($trueColorImage, $iconImage, 0, 0, 0, 0, $iconImgData['width'], $iconImgData['height']);
imagecopymerge($bgImg, $trueColorImage, $iconImgData['x'], $iconImgData['y'], 0, 0, $iconImgData['width'], $iconImgData['height'], $this->config['alpha']);
}
/**
* 随机生成验证点元素
* @param int $length
* @return array
*/
public function randPoints(int $length = 4): array
{
$arr = [];
// 文字
if (in_array('text', $this->config['mode'])) {
for ($i = 0; $i < $length; $i++) {
$arr[] = mb_substr($this->config['zhSet'], mt_rand(0, mb_strlen($this->config['zhSet'], 'utf-8') - 1), 1, 'utf-8');
}
}
// 图标
if (in_array('icon', $this->config['mode'])) {
$icon = array_keys($this->iconDict);
shuffle($icon);
$icon = array_slice($icon, 0, $length);
$arr = array_merge($arr, $icon);
}
shuffle($arr);
return array_slice($arr, 0, $length);
}
/**
* 随机生成位置布局
* @param array $textArr 点位数据
* @param int $imgW 图片宽度
* @param int $imgH 图片高度
* @param int $fontW 文字宽度
* @param int $fontH 文字高度
* @param bool $isIcon 是否是图标
* @return array
*/
private function randPosition(array $textArr, int $imgW, int $imgH, int $fontW, int $fontH, bool $isIcon): array
{
$x = rand(0, $imgW - $fontW);
$y = rand($fontH, $imgH - $fontH);
// 碰撞验证
if (!$this->checkPosition($textArr, $x, $y, $fontW, $fontH, $isIcon)) {
$position = $this->randPosition($textArr, $imgW, $imgH, $fontW, $fontH, $isIcon);
} else {
$position = [$x, $y];
}
return $position;
}
/**
* 碰撞验证
* @param array $textArr 验证点数据
* @param int $x x轴位置
* @param int $y y轴位置
* @param int $w 验证点宽度
* @param int $h 验证点高度
* @param bool $isIcon 是否是图标
* @return bool
*/
public function checkPosition(array $textArr, int $x, int $y, int $w, int $h, bool $isIcon): bool
{
$flag = true;
foreach ($textArr as $v) {
if (isset($v['x']) && isset($v['y'])) {
$flagX = false;
$flagY = false;
$historyPw = $v['x'] + $v['width'];
if (($x + $w) < $v['x'] || $x > $historyPw) {
$flagX = true;
}
$currentPhStart = $isIcon ? $y : $y - $h;
$currentPhEnd = $isIcon ? $y + $v['height'] : $y;
$historyPhStart = $v['icon'] ? $v['y'] : ($v['y'] - $v['height']);
$historyPhEnd = $v['icon'] ? ($v['y'] + $v['height']) : $v['y'];
if ($currentPhEnd < $historyPhStart || $currentPhStart > $historyPhEnd) {
$flagY = true;
}
if (!$flagX && !$flagY) {
$flag = false;
}
}
}
return $flag;
}
}

195
extend/ba/Date.php Normal file
View File

@@ -0,0 +1,195 @@
<?php
namespace ba;
use DateTime;
use Throwable;
use DateTimeZone;
use DateTimeInterface;
/**
* 日期时间处理类
* @form https://gitee.com/karson/fastadmin/blob/develop/extend/fast/Date.php
*/
class Date
{
private const YEAR = 31536000;
private const MONTH = 2592000;
private const WEEK = 604800;
private const DAY = 86400;
private const HOUR = 3600;
private const MINUTE = 60;
/**
* 计算两个时区间相差的时长,单位为秒
*
* [!!] A list of time zones that PHP supports can be found at
* <http://php.net/timezones>.
* @param string $remote timezone that to find the offset of
* @param string|null $local timezone used as the baseline
* @param string|int|null $now UNIX timestamp or date string
* @return int
* @throws Throwable
* @example $seconds = self::offset('America/Chicago', 'GMT');
*/
public static function offset(string $remote, ?string $local = null, string|int|null $now = null): int
{
if ($local === null) {
// Use the default timezone
$local = date_default_timezone_get();
}
if (is_int($now)) {
// Convert the timestamp into a string
$now = date(DateTimeInterface::RFC2822, $now);
}
// Create timezone objects
$zone_remote = new DateTimeZone($remote);
$zone_local = new DateTimeZone($local);
// Create date objects from timezones
$time_remote = new DateTime($now, $zone_remote);
$time_local = new DateTime($now, $zone_local);
// Find the offset
return $zone_remote->getOffset($time_remote) - $zone_local->getOffset($time_local);
}
/**
* 计算两个时间戳之间相差的时间
*
* $span = self::span(60, 182, 'minutes,seconds'); // array('minutes' => 2, 'seconds' => 2)
* $span = self::span(60, 182, 'minutes'); // 2
*
* @param int $remote timestamp to find the span of
* @param int|null $local timestamp to use as the baseline
* @param string $output formatting string
* @return bool|array|string associative list of all outputs requested|when only a single output is requested
* @from https://github.com/kohana/ohanzee-helpers/blob/master/src/Date.php
*/
public static function span(int $remote, ?int $local = null, string $output = 'years,months,weeks,days,hours,minutes,seconds'): bool|array|string
{
// Normalize output
$output = trim(strtolower($output));
if (!$output) {
// Invalid output
return false;
}
// Array with the output formats
$output = preg_split('/[^a-z]+/', $output);
// Convert the list of outputs to an associative array
$output = array_combine($output, array_fill(0, count($output), 0));
// Make the output values into keys
extract(array_flip($output), EXTR_SKIP);
if ($local === null) {
// Calculate the span from the current time
$local = time();
}
// Calculate timespan (seconds)
$timespan = abs($remote - $local);
if (isset($output['years'])) {
$timespan -= self::YEAR * ($output['years'] = (int)floor($timespan / self::YEAR));
}
if (isset($output['months'])) {
$timespan -= self::MONTH * ($output['months'] = (int)floor($timespan / self::MONTH));
}
if (isset($output['weeks'])) {
$timespan -= self::WEEK * ($output['weeks'] = (int)floor($timespan / self::WEEK));
}
if (isset($output['days'])) {
$timespan -= self::DAY * ($output['days'] = (int)floor($timespan / self::DAY));
}
if (isset($output['hours'])) {
$timespan -= self::HOUR * ($output['hours'] = (int)floor($timespan / self::HOUR));
}
if (isset($output['minutes'])) {
$timespan -= self::MINUTE * ($output['minutes'] = (int)floor($timespan / self::MINUTE));
}
// Seconds ago, 1
if (isset($output['seconds'])) {
$output['seconds'] = $timespan;
}
if (count($output) === 1) {
// Only a single output was requested, return it
return array_pop($output);
}
// Return array
return $output;
}
/**
* 格式化 UNIX 时间戳为人易读的字符串
*
* @param int $remote Unix 时间戳
* @param ?int $local 本地时间戳
* @return string 格式化的日期字符串
*/
public static function human(int $remote, ?int $local = null): string
{
$timeDiff = (is_null($local) ? time() : $local) - $remote;
$tense = $timeDiff < 0 ? 'after' : 'ago';
$timeDiff = abs($timeDiff);
$chunks = [
[60 * 60 * 24 * 365, 'year'],
[60 * 60 * 24 * 30, 'month'],
[60 * 60 * 24 * 7, 'week'],
[60 * 60 * 24, 'day'],
[60 * 60, 'hour'],
[60, 'minute'],
[1, 'second'],
];
$count = 0;
$name = '';
for ($i = 0, $j = count($chunks); $i < $j; $i++) {
$seconds = $chunks[$i][0];
$name = $chunks[$i][1];
if (($count = floor($timeDiff / $seconds)) != 0) {
break;
}
}
return __("%d $name%s $tense", [$count, $count > 1 ? 's' : '']);
}
/**
* 获取一个基于时间偏移的Unix时间戳
*
* @param string $type 时间类型默认为day可选minute,hour,day,week,month,quarter,year
* @param int $offset 时间偏移量 默认为0正数表示当前type之后负数表示当前type之前
* @param string $position 时间的开始或结束默认为begin可选前(begin,start,first,front)end
* @param int|null $year 基准年默认为null即以当前年为基准
* @param int|null $month 基准月默认为null即以当前月为基准
* @param int|null $day 基准天默认为null即以当前天为基准
* @param int|null $hour 基准小时默认为null即以当前年小时基准
* @param int|null $minute 基准分钟默认为null即以当前分钟为基准
* @return int 处理后的Unix时间戳
*/
public static function unixTime(string $type = 'day', int $offset = 0, string $position = 'begin', ?int $year = null, ?int $month = null, ?int $day = null, ?int $hour = null, ?int $minute = null): int
{
$year = is_null($year) ? date('Y') : $year;
$month = is_null($month) ? date('m') : $month;
$day = is_null($day) ? date('d') : $day;
$hour = is_null($hour) ? date('H') : $hour;
$minute = is_null($minute) ? date('i') : $minute;
$position = in_array($position, ['begin', 'start', 'first', 'front']);
return match ($type) {
'minute' => $position ? mktime($hour, $minute + $offset, 0, $month, $day, $year) : mktime($hour, $minute + $offset, 59, $month, $day, $year),
'hour' => $position ? mktime($hour + $offset, 0, 0, $month, $day, $year) : mktime($hour + $offset, 59, 59, $month, $day, $year),
'day' => $position ? mktime(0, 0, 0, $month, $day + $offset, $year) : mktime(23, 59, 59, $month, $day + $offset, $year),
// 使用固定的 this week monday 而不是 $offset weeks monday 的语法才能确保准确性
'week' => $position ? strtotime('this week monday', mktime(0, 0, 0, $month, $day + ($offset * 7), $year)) : strtotime('this week sunday 23:59:59', mktime(0, 0, 0, $month, $day + ($offset * 7), $year)),
'month' => $position ? mktime(0, 0, 0, $month + $offset, 1, $year) : mktime(23, 59, 59, $month + $offset, self::daysInMonth($month + $offset, $year), $year),
'quarter' => $position ?
mktime(0, 0, 0, 1 + ((ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) - 1) * 3, 1, $year) :
mktime(23, 59, 59, (ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) * 3, self::daysInMonth((ceil(date('n', mktime(0, 0, 0, $month, $day, $year)) / 3) + $offset) * 3, $year), $year),
'year' => $position ? mktime(0, 0, 0, 1, 1, $year + $offset) : mktime(23, 59, 59, 12, 31, $year + $offset),
default => mktime($hour, $minute, 0, $month, $day, $year),
};
}
/**
* 获取给定月份的天数 28 到 31
*/
public static function daysInMonth(int $month, ?int $year = null): int
{
return (int)date('t', mktime(0, 0, 0, $month, 1, $year));
}
}

212
extend/ba/Depends.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
namespace ba;
use Throwable;
use think\Exception;
/**
* 依赖管理
*/
class Depends
{
/**
* json 文件内容
* @var array
*/
protected array $jsonContent = [];
public function __construct(protected string $json, protected string $type = 'npm')
{
}
/**
* 获取 json 文件内容
* @param bool $realTime 获取实时内容
* @return array
* @throws Throwable
*/
public function getContent(bool $realTime = false): array
{
if (!file_exists($this->json)) {
throw new Exception($this->json . ' file does not exist!');
}
if ($this->jsonContent && !$realTime) return $this->jsonContent;
$content = @file_get_contents($this->json);
$this->jsonContent = json_decode($content, true);
if (!$this->jsonContent) {
throw new Exception($this->json . ' file read failure!');
}
return $this->jsonContent;
}
/**
* 设置 json 文件内容
* @param array $content
* @throws Throwable
*/
public function setContent(array $content = []): void
{
if (!$content) $content = $this->jsonContent;
if (!isset($content['name'])) {
throw new Exception('Depend content file content is incomplete');
}
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$result = @file_put_contents($this->json, $content . PHP_EOL);
if (!$result) {
throw new Exception('File has no write permission:' . $this->json);
}
}
/**
* 获取依赖项
* @param bool $devEnv 是否是获取开发环境依赖
* @return array
* @throws Throwable
*/
public function getDepends(bool $devEnv = false): array
{
try {
$content = $this->getContent();
} catch (Throwable) {
return [];
}
if ($this->type == 'npm') {
return $devEnv ? $content['devDependencies'] : $content['dependencies'];
} else {
return $devEnv ? $content['require-dev'] : $content['require'];
}
}
/**
* 是否存在某个依赖
* @param string $name 依赖名称
* @param bool $devEnv 是否是获取开发环境依赖
* @return bool|string false或者依赖版本号
* @throws Throwable
*/
public function hasDepend(string $name, bool $devEnv = false): bool|string
{
$depends = $this->getDepends($devEnv);
return $depends[$name] ?? false;
}
/**
* 添加依赖
* @param array $depends 要添加的依赖数组["xxx" => ">=7.1.0",]
* @param bool $devEnv 是否添加为开发环境依赖
* @param bool $cover 覆盖模式
* @return void
* @throws Throwable
*/
public function addDepends(array $depends, bool $devEnv = false, bool $cover = false): void
{
$content = $this->getContent(true);
$dKey = $devEnv ? ($this->type == 'npm' ? 'devDependencies' : 'require-dev') : ($this->type == 'npm' ? 'dependencies' : 'require');
if (!$cover) {
foreach ($depends as $key => $item) {
if (isset($content[$dKey][$key])) {
throw new Exception($key . ' depend already exists!');
}
}
}
$content[$dKey] = array_merge($content[$dKey], $depends);
$this->setContent($content);
}
/**
* 删除依赖
* @param array $depends 要删除的依赖数组["php", "w7corp/easyWechat"]
* @param bool $devEnv 是否为开发环境删除依赖
* @return void
* @throws Throwable
*/
public function removeDepends(array $depends, bool $devEnv = false): void
{
$content = $this->getContent(true);
$dKey = $devEnv ? ($this->type == 'npm' ? 'devDependencies' : 'require-dev') : ($this->type == 'npm' ? 'dependencies' : 'require');
foreach ($depends as $item) {
if (isset($content[$dKey][$item])) {
unset($content[$dKey][$item]);
}
}
$this->setContent($content);
}
/**
* 获取 composer.json 的 config 字段
*/
public function getComposerConfig(): array
{
try {
$content = $this->getContent();
} catch (Throwable) {
return [];
}
return $content['config'];
}
/**
* 设置 composer.json 的 config 字段
* @throws Throwable
*/
public function setComposerConfig(array $config, bool $cover = true): void
{
$content = $this->getContent(true);
// 配置冲突检查
if (!$cover) {
foreach ($config as $key => $item) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
if (isset($content['config'][$key][$configKey]) && $content['config'][$key][$configKey] != $configItem) {
throw new Exception(__('composer config %s conflict', [$configKey]));
}
}
} elseif (isset($content['config'][$key]) && $content['config'][$key] != $item) {
throw new Exception(__('composer config %s conflict', [$key]));
}
}
}
foreach ($config as $key => $item) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
$content['config'][$key][$configKey] = $configItem;
}
} else {
$content['config'][$key] = $item;
}
}
$this->setContent($content);
}
/**
* 删除 composer 配置项
* @throws Throwable
*/
public function removeComposerConfig(array $config): void
{
if (!$config) return;
$content = $this->getContent(true);
foreach ($config as $key => $item) {
if (isset($content['config'][$key])) {
if (is_array($item)) {
foreach ($item as $configKey => $configItem) {
if (isset($content['config'][$key][$configKey])) unset($content['config'][$key][$configKey]);
}
// 没有子级配置项了
if (!$content['config'][$key]) {
unset($content['config'][$key]);
}
} else {
unset($content['config'][$key]);
}
}
}
$this->setContent($content);
}
}

17
extend/ba/Exception.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace ba;
use think\Exception as E;
/**
* BuildAdmin通用异常类
* catch 到异常后可以直接 $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
*/
class Exception extends E
{
public function __construct(protected $message, protected $code = 0, protected $data = [])
{
parent::__construct($message, $code);
}
}

248
extend/ba/Filesystem.php Normal file
View File

@@ -0,0 +1,248 @@
<?php
namespace ba;
use Throwable;
use PhpZip\ZipFile;
use FilesystemIterator;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
/**
* 访问和操作文件系统
*/
class Filesystem
{
/**
* 是否是空目录
*/
public static function dirIsEmpty(string $dir): bool
{
if (!file_exists($dir)) return true;
$handle = opendir($dir);
while (false !== ($entry = readdir($handle))) {
if ($entry != "." && $entry != "..") {
closedir($handle);
return false;
}
}
closedir($handle);
return true;
}
/**
* 递归删除目录
* @param string $dir 目录路径
* @param bool $delSelf 是否删除传递的目录本身
* @return bool
*/
public static function delDir(string $dir, bool $delSelf = true): bool
{
if (!is_dir($dir)) {
return false;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileInfo) {
if ($fileInfo->isDir()) {
self::delDir($fileInfo->getRealPath());
} else {
@unlink($fileInfo->getRealPath());
}
}
if ($delSelf) {
@rmdir($dir);
}
return true;
}
/**
* 删除一个路径下的所有相对空文件夹(删除此路径中的所有空文件夹)
* @param string $path 相对于根目录的文件夹路径 如`c:BuildAdmin/a/b/`
* @return void
*/
public static function delEmptyDir(string $path): void
{
$path = str_replace(root_path(), '', rtrim(self::fsFit($path), DIRECTORY_SEPARATOR));
$path = array_filter(explode(DIRECTORY_SEPARATOR, $path));
for ($i = count($path) - 1; $i >= 0; $i--) {
$dirPath = root_path() . implode(DIRECTORY_SEPARATOR, $path);
if (!is_dir($dirPath)) {
unset($path[$i]);
continue;
}
if (self::dirIsEmpty($dirPath)) {
self::delDir($dirPath);
unset($path[$i]);
} else {
break;
}
}
}
/**
* 检查目录/文件是否可写
* @param $path
* @return bool
*/
public static function pathIsWritable($path): bool
{
if (DIRECTORY_SEPARATOR == '/' && !@ini_get('safe_mode')) {
return is_writable($path);
}
if (is_dir($path)) {
$path = rtrim($path, '/') . '/' . md5(mt_rand(1, 100) . mt_rand(1, 100));
if (($fp = @fopen($path, 'ab')) === false) {
return false;
}
fclose($fp);
@chmod($path, 0777);
@unlink($path);
return true;
} elseif (!is_file($path) || ($fp = @fopen($path, 'ab')) === false) {
return false;
}
fclose($fp);
return true;
}
/**
* 路径分隔符根据当前系统分隔符适配
* @param string $path 路径
* @return string 转换后的路径
*/
public static function fsFit(string $path): string
{
return str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
}
/**
* 解压Zip
* @param string $file ZIP文件路径
* @param string $dir 解压路径
* @return string 解压后的路径
* @throws Throwable
*/
public static function unzip(string $file, string $dir = ''): string
{
if (!file_exists($file)) {
throw new Exception("Zip file not found");
}
$zip = new ZipFile();
try {
$zip->openFile($file);
} catch (Throwable $e) {
$zip->close();
throw new Exception('Unable to open the zip file', 0, ['msg' => $e->getMessage()]);
}
$dir = $dir ?: substr($file, 0, strripos($file, '.zip'));
if (!is_dir($dir)) {
@mkdir($dir, 0755);
}
try {
$zip->extractTo($dir);
} catch (Throwable $e) {
throw new Exception('Unable to extract ZIP file', 0, ['msg' => $e->getMessage()]);
} finally {
$zip->close();
}
return $dir;
}
/**
* 创建ZIP
* @param array $files 文件路径列表
* @param string $fileName ZIP文件名称
* @return bool
* @throws Throwable
*/
public static function zip(array $files, string $fileName): bool
{
$zip = new ZipFile();
try {
foreach ($files as $v) {
if (is_array($v) && isset($v['file']) && isset($v['name'])) {
$zip->addFile(root_path() . str_replace(root_path(), '', Filesystem::fsFit($v['file'])), $v['name']);
} else {
$saveFile = str_replace(root_path(), '', Filesystem::fsFit($v));
$zip->addFile(root_path() . $saveFile, $saveFile);
}
}
$zip->saveAsFile($fileName);
} catch (Throwable $e) {
throw new Exception('Unable to package zip file', 0, ['msg' => $e->getMessage(), 'file' => $fileName]);
} finally {
$zip->close();
}
if (file_exists($fileName)) {
return true;
} else {
return false;
}
}
/**
* 递归创建目录
* @param string $dir 目录路径
* @return bool
*/
public static function mkdir(string $dir): bool
{
if (!is_dir($dir)) {
return mkdir($dir, 0755, true);
}
return false;
}
/**
* 获取一个目录内的文件列表
* @param string $dir 目录路径
* @param array $suffix 要获取的文件列表的后缀
* @return array
*/
public static function getDirFiles(string $dir, array $suffix = []): array
{
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::LEAVES_ONLY
);
$fileList = [];
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
if (!empty($suffix) && !in_array($file->getExtension(), $suffix)) {
continue;
}
$filePath = $file->getRealPath();
$name = str_replace($dir, '', $filePath);
$name = str_replace(DIRECTORY_SEPARATOR, "/", $name);
$fileList[$name] = $name;
}
return $fileList;
}
/**
* 将一个文件单位转为字节
* @param string $unit 将b、kb、m、mb、g、gb的单位转为 byte
* @return int byte
*/
public static function fileUnitToByte(string $unit): int
{
preg_match('/([0-9.]+)(\w+)/', $unit, $matches);
if (!$matches) {
return 0;
}
$typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
return (int)($matches[1] * pow(1024, $typeDict[strtolower($matches[2])] ?? 0));
}
}

54
extend/ba/Random.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace ba;
class Random
{
/**
* 获取全球唯一标识
* @return string
*/
public static function uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000
) . substr(md5(uniqid(mt_rand(), true)), 0, 12);
}
/**
* 随机字符生成
* @param string $type 类型 alpha/alnum/numeric/noZero/unique/md5/encrypt/sha1
* @param int $len 长度
* @return string
*/
public static function build(string $type = 'alnum', int $len = 8): string
{
switch ($type) {
case 'alpha':
case 'alnum':
case 'numeric':
case 'noZero':
$pool = match ($type) {
'alpha' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'alnum' => '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'numeric' => '0123456789',
'noZero' => '123456789',
default => '',
};
return substr(str_shuffle(str_repeat($pool, ceil($len / strlen($pool)))), 0, $len);
case 'unique':
case 'md5':
return md5(uniqid(mt_rand()));
case 'encrypt':
case 'sha1':
return sha1(uniqid(mt_rand(), true));
}
return '';
}
}

186
extend/ba/TableManager.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
namespace ba;
use Throwable;
use think\facade\Db;
use think\facade\Config;
use think\migration\db\Table;
use Phinx\Db\Adapter\AdapterFactory;
use Phinx\Db\Adapter\AdapterInterface;
/**
* 数据表管理类
*/
class TableManager
{
/**
* 返回一个 Phinx/Db/Table 实例 用于操作数据表
* @param string $table 表名
* @param array $options 传递给 Phinx/Db/Table 的 options
* @param bool $prefixWrapper 是否使用表前缀包装表名
* @param ?string $connection 连接配置标识
* @return Table
* @throws Throwable
*/
public static function phinxTable(string $table, array $options = [], bool $prefixWrapper = true, ?string $connection = null): Table
{
return new Table($table, $options, self::phinxAdapter($prefixWrapper, $connection));
}
/**
* 返回 Phinx\Db\Adapter\AdapterFactory (适配器/连接驱动)实例
* @param bool $prefixWrapper 是否使用表前缀包装表名
* @param ?string $connection 连接配置标识
* @return AdapterInterface
* @throws Throwable
*/
public static function phinxAdapter(bool $prefixWrapper = true, ?string $connection = null): AdapterInterface
{
$config = static::getPhinxDbConfig($connection);
$factory = AdapterFactory::instance();
$adapter = $factory->getAdapter($config['adapter'], $config);
if ($prefixWrapper) return $factory->getWrapper('prefix', $adapter);
return $adapter;
}
/**
* 数据表名
* @param string $table 表名,带不带前缀均可
* @param bool $fullName 是否返回带前缀的表名
* @param ?string $connection 连接配置标识
* @return string 表名
* @throws Exception
*/
public static function tableName(string $table, bool $fullName = true, ?string $connection = null): string
{
$connection = self::getConnectionConfig($connection);
$pattern = '/^' . $connection['prefix'] . '/i';
return ($fullName ? $connection['prefix'] : '') . (preg_replace($pattern, '', $table));
}
/**
* 数据表列表
* @param ?string $connection 连接配置标识
* @throws Exception
*/
public static function getTableList(?string $connection = null): array
{
$tableList = [];
$config = self::getConnectionConfig($connection);
$connection = self::getConnection($connection);
$tables = Db::connect($connection)->query("SELECT TABLE_NAME,TABLE_COMMENT FROM information_schema.TABLES WHERE table_schema = ? ", [$config['database']]);
foreach ($tables as $row) {
$tableList[$row['TABLE_NAME']] = $row['TABLE_NAME'] . ($row['TABLE_COMMENT'] ? ' - ' . $row['TABLE_COMMENT'] : '');
}
return $tableList;
}
/**
* 获取数据表所有列
* @param string $table 数据表名
* @param bool $onlyCleanComment 只要干净的字段注释信息
* @param ?string $connection 连接配置标识
* @throws Throwable
*/
public static function getTableColumns(string $table, bool $onlyCleanComment = false, ?string $connection = null): array
{
if (!$table) return [];
$table = self::tableName($table, true, $connection);
$config = self::getConnectionConfig($connection);
$connection = self::getConnection($connection);
// 从数据库中获取表字段信息
// Phinx 目前无法正确获取到列注释信息,故使用 sql
$sql = "SELECT * FROM `information_schema`.`columns` "
. "WHERE TABLE_SCHEMA = ? AND table_name = ? "
. "ORDER BY ORDINAL_POSITION";
$columnList = Db::connect($connection)->query($sql, [$config['database'], $table]);
$fieldList = [];
foreach ($columnList as $item) {
if ($onlyCleanComment) {
$fieldList[$item['COLUMN_NAME']] = '';
if ($item['COLUMN_COMMENT']) {
$comment = explode(':', $item['COLUMN_COMMENT']);
$fieldList[$item['COLUMN_NAME']] = $comment[0];
}
continue;
}
$fieldList[$item['COLUMN_NAME']] = $item;
}
return $fieldList;
}
/**
* 系统是否存在多个数据库连接配置
*/
public static function isMultiDatabase(): bool
{
return count(Config::get("database.connections")) > 1;
}
/**
* 获取数据库连接配置标识
* @param ?string $source
* @return string 连接配置标识
*/
public static function getConnection(?string $source = null): string
{
if (!$source || $source === 'default') {
return Config::get('database.default');
}
return $source;
}
/**
* 获取某个数据库连接的配置数组
* @param ?string $connection 连接配置标识
* @throws Exception
*/
public static function getConnectionConfig(?string $connection = null): array
{
$connection = self::getConnection($connection);
$connection = config("database.connections.$connection");
if (!is_array($connection)) {
throw new Exception('Database connection configuration error');
}
// 分布式
if ($connection['deploy'] == 1) {
$keys = ['type', 'hostname', 'database', 'username', 'password', 'hostport', 'charset', 'prefix'];
foreach ($connection as $key => $item) {
if (in_array($key, $keys)) {
$connection[$key] = is_array($item) ? $item[0] : explode(',', $item)[0];
}
}
}
return $connection;
}
/**
* 获取 Phinx 适配器需要的数据库配置
* @param ?string $connection 连接配置标识
* @return array
* @throws Throwable
*/
protected static function getPhinxDbConfig(?string $connection = null): array
{
$config = self::getConnectionConfig($connection);
$connection = self::getConnection($connection);
$db = Db::connect($connection);
// 数据库为懒连接,执行 sql 命令为 $db 实例连接数据库
$db->query('SELECT 1');
$table = Config::get('database.migration_table', 'migrations');
return [
'adapter' => $config['type'],
'connection' => $db->getPdo(),
'name' => $config['database'],
'table_prefix' => $config['prefix'],
'migration_table' => $config['prefix'] . $table,
];
}
}

510
extend/ba/Terminal.php Normal file
View File

@@ -0,0 +1,510 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin [ Quickly create commercial-grade management system using popular technology stack ]
// +----------------------------------------------------------------------
// | Copyright (c) 2022~2022 http://buildadmin.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: 妙码生花 <hi@buildadmin.com>
// +----------------------------------------------------------------------
namespace ba;
use Throwable;
use think\Response;
use think\facade\Config;
use app\admin\library\Auth;
use app\admin\library\module\Manage;
use think\exception\HttpResponseException;
use app\common\library\token\TokenExpirationException;
class Terminal
{
/**
* @var ?Terminal 对象实例
*/
protected static ?Terminal $instance = null;
/**
* @var string 当前执行的命令 $command 的 key
*/
protected string $commandKey = '';
/**
* @var array proc_open 的参数
*/
protected array $descriptorsPec = [];
/**
* @var resource|bool proc_open 返回的 resource
*/
protected $process = false;
/**
* @var array proc_open 的管道
*/
protected array $pipes = [];
/**
* @var int proc执行状态:0=未执行,1=执行中,2=执行完毕
*/
protected int $procStatusMark = 0;
/**
* @var array proc执行状态数据
*/
protected array $procStatusData = [];
/**
* @var string 命令在前台的uuid
*/
protected string $uuid = '';
/**
* @var string 扩展信息
*/
protected string $extend = '';
/**
* @var string 命令执行输出文件
*/
protected string $outputFile = '';
/**
* @var string 命令执行实时输出内容
*/
protected string $outputContent = '';
/**
* @var string 自动构建的前端文件的 outDir相对于根目录
*/
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
/**
* @var array 状态标识
*/
protected array $flag = [
// 连接成功
'link-success' => 'command-link-success',
// 执行成功
'exec-success' => 'command-exec-success',
// 执行完成
'exec-completed' => 'command-exec-completed',
// 执行出错
'exec-error' => 'command-exec-error',
];
/**
* 初始化
*/
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
/**
* 构造函数
*/
public function __construct()
{
$this->uuid = request()->param('uuid', '');
$this->extend = request()->param('extend', '');
// 初始化日志文件
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
/**
* 命令执行结果输出到文件而不是管道
* 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
*/
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
/**
* 获取命令
* @param string $key 命令key
* @return array|bool
*/
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = Config::get('terminal.commands');
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$command['cwd'] = root_path() . $command['cwd'];
}
if (str_contains($command['command'], '%')) {
$args = request()->param('extend', '');
$args = explode('~~', $args);
$args = array_map('escapeshellarg', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
return $command;
}
/**
* 执行命令
* @param bool $authentication 是否鉴权
* @throws Throwable
*/
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$this->commandKey = request()->param('command');
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd']);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (preg_match('/\r\n|\r|\n/', $newOutput)) {
$this->output($newOutput);
$this->outputContent = $contents;
}
}
// 输出执行状态信息
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
/**
* 获取执行状态
* @throws Throwable
*/
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
/**
* 输出 EventSource 数据
* @param string $data
* @param bool $callback
*/
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();// 刷新浏览器缓冲区
}
}
/**
* 检查输出
* @param string $outputs 全部输出内容
* @param string $rowOutput 当前输出内容(行)
*/
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
/**
* 输出状态标记
* @param string $flag
*/
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
/**
* 输出后回调
*/
public function outputCallback($data): void
{
}
/**
* 成功后回调
* @return bool
* @throws Throwable
*/
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
} elseif ($commandPKey == 'web-install' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('npm');
}
} elseif ($commandPKey == 'composer' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('composer');
}
} elseif ($commandPKey == 'nuxt-install' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('nuxt_npm');
}
}
return true;
}
/**
* 执行前埋点
*/
public function beforeExecution(): void
{
if ($this->commandKey == 'test.pnpm') {
@unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
} elseif ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
/**
* 输出过滤
*/
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
/**
* 执行错误
*/
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
/**
* 退出执行
*/
public function break(): void
{
throw new HttpResponseException(Response::create()->contentType('text/event-stream'));
}
/**
* 执行一个命令并以字符串的方式返回执行输出
* 代替 exec 使用,这样就只需要解除 proc_open 的函数禁用了
* @param $commandKey
* @return string|bool
*/
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
// 不保存在数据库中,因为切换包管理器时,数据库资料可能还未配置
$oldPackageManager = Config::get('terminal.npm_package_manager');
$newPackageManager = request()->post('manager', $config['manager'] ?? $oldPackageManager);
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path() . 'terminal.php';
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
/**
* 最终输出
*/
public function finalOutput(string $data): void
{
$app = app();
if (!empty($app->worker) && !empty($app->connection)) {
$app->connection->send(new \Workerman\Protocols\Http\ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
/**
* 发送响应头
*/
public function sendHeader(): void
{
$headers = array_merge(request()->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$app = app();
if (!empty($app->worker) && !empty($app->connection)) {
$app->connection->send(new \Workerman\Protocols\Http\Response(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}

145
extend/ba/Tree.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
namespace ba;
/**
* 树
*/
class Tree
{
/**
* 实例
* @var ?Tree
*/
protected static ?Tree $instance = null;
/**
* 生成树型结构所需修饰符号
* @var array
*/
public static array $icon = array('│', '├', '└');
/**
* 子级数据(树枝)
* @var array
*/
protected array $children = [];
/**
* 初始化
* @access public
* @return Tree
*/
public static function instance(): Tree
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
/**
* 将数组某个字段渲染为树状,需自备children children可通过$this->assembleChild()方法组装
* @param array $arr 要改为树状的数组
* @param string $field '树枝'字段
* @param int $level 递归数组层次,无需手动维护
* @param bool $superiorEnd 递归上一级树枝是否结束,无需手动维护
* @return array
*/
public static function getTreeArray(array $arr, string $field = 'name', int $level = 0, bool $superiorEnd = false): array
{
$level++;
$number = 1;
$total = count($arr);
foreach ($arr as $key => $item) {
$prefix = ($number == $total) ? self::$icon[2] : self::$icon[1];
if ($level == 2) {
$arr[$key][$field] = str_pad('', 4) . $prefix . $item[$field];
} elseif ($level >= 3) {
$arr[$key][$field] = str_pad('', 4) . ($superiorEnd ? '' : self::$icon[0]) . str_pad('', ($level - 2) * 4) . $prefix . $item[$field];
}
if (isset($item['children']) && $item['children']) {
$arr[$key]['children'] = self::getTreeArray($item['children'], $field, $level, $number == $total);
}
$number++;
}
return $arr;
}
/**
* 递归合并树状数组根据children多维变二维方便渲染
* @param array $data 要合并的数组 ['id' => 1, 'pid' => 0, 'title' => '标题1', 'children' => ['id' => 2, 'pid' => 1, 'title' => ' └标题1-1']]
* @return array [['id' => 1, 'pid' => 0, 'title' => '标题1'], ['id' => 2, 'pid' => 1, 'title' => ' └标题1-1']]
*/
public static function assembleTree(array $data): array
{
$arr = [];
foreach ($data as $v) {
$children = $v['children'] ?? [];
unset($v['children']);
$arr[] = $v;
if ($children) {
$arr = array_merge($arr, self::assembleTree($children));
}
}
return $arr;
}
/**
* 递归的根据指定字段组装 children 数组
* @param array $data 数据源 例如:[['id' => 1, 'pid' => 0, title => '标题1'], ['id' => 2, 'pid' => 1, title => '标题1-1']]
* @param string $pid 存储上级id的字段
* @param string $pk 主键字段
* @return array ['id' => 1, 'pid' => 0, 'title' => '标题1', 'children' => ['id' => 2, 'pid' => 1, 'title' => '标题1-1']]
*/
public function assembleChild(array $data, string $pid = 'pid', string $pk = 'id'): array
{
if (!$data) return [];
$pks = [];
$topLevelData = []; // 顶级数据
$this->children = []; // 置空子级数据
foreach ($data as $item) {
$pks[] = $item[$pk];
// 以pid组成children
$this->children[$item[$pid]][] = $item;
}
// 上级不存在的就是顶级,只获取它们的 children
foreach ($data as $item) {
if (!in_array($item[$pid], $pks)) {
$topLevelData[] = $item;
}
}
if (count($this->children) > 0) {
foreach ($topLevelData as $key => $item) {
$topLevelData[$key]['children'] = $this->getChildren($this->children[$item[$pk]] ?? [], $pk);
}
return $topLevelData;
} else {
return $data;
}
}
/**
* 获取 children 数组
* 辅助 assembleChild 组装 children
* @param array $data
* @param string $pk
* @return array
*/
protected function getChildren(array $data, string $pk = 'id'): array
{
if (!$data) return [];
foreach ($data as $key => $item) {
if (array_key_exists($item[$pk], $this->children)) {
$data[$key]['children'] = $this->getChildren($this->children[$item[$pk]], $pk);
}
}
return $data;
}
}

133
extend/ba/Version.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace ba;
/**
* 版本类
*/
class Version
{
/**
* 比较两个版本号
* @param $v1 string 要求的版本号
* @param $v2 bool | string 被比较版本号
* @return bool 是否达到要求的版本号
*/
public static function compare(string $v1, bool|string $v2): bool
{
if (!$v2) {
return false;
}
// 删除开头的 V
if (strtolower($v1[0]) == 'v') {
$v1 = substr($v1, 1);
}
if (strtolower($v2[0]) == 'v') {
$v2 = substr($v2, 1);
}
if ($v1 == "*" || $v1 == $v2) {
return true;
}
// 丢弃'-'后面的内容
if (str_contains($v1, '-')) $v1 = explode('-', $v1)[0];
if (str_contains($v2, '-')) $v2 = explode('-', $v2)[0];
$v1 = explode('.', $v1);
$v2 = explode('.', $v2);
// 将号码逐个进行比较
for ($i = 0; $i < count($v1); $i++) {
if (!isset($v2[$i])) {
break;
}
if ($v1[$i] == $v2[$i]) {
continue;
}
if ($v1[$i] > $v2[$i]) {
return false;
}
if ($v1[$i] < $v2[$i]) {
return true;
}
}
if (count($v1) != count($v2)) {
return !(count($v1) > count($v2));
}
return false;
}
/**
* 是否是一个数字版本号
* @param $version
* @return bool
*/
public static function checkDigitalVersion($version): bool
{
if (!$version) {
return false;
}
if (strtolower($version[0]) == 'v') {
$version = substr($version, 1);
}
$rule1 = '/\.{2,10}/'; // 是否有两个的`.`
$rule2 = '/^\d+(\.\d+){0,10}$/';
if (!preg_match($rule1, (string)$version)) {
return !!preg_match($rule2, (string)$version);
}
return false;
}
/**
* @return string
*/
public static function getCnpmVersion(): string
{
$execOut = Terminal::getOutputFromProc('version.cnpm');
if ($execOut) {
$preg = '/cnpm@(.+?) \(/is';
preg_match($preg, $execOut, $result);
return $result[1] ?? '';
} else {
return '';
}
}
/**
* 获取依赖版本号
* @param string $name 支持npm、cnpm、yarn、pnpm、node
* @return string
*/
public static function getVersion(string $name): string
{
if ($name == 'cnpm') {
return self::getCnpmVersion();
} elseif (in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
$execOut = Terminal::getOutputFromProc('version.' . $name);
if ($execOut) {
if (strripos($execOut, 'npm WARN') !== false) {
$preg = '/\d+(\.\d+){0,2}/';
preg_match($preg, $execOut, $matches);
if (isset($matches[0]) && self::checkDigitalVersion($matches[0])) {
return $matches[0];
}
}
$execOut = preg_split('/\r\n|\r|\n/', $execOut);
// 检测两行,第一行可能会是个警告消息
for ($i = 0; $i < 2; $i++) {
if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) {
return $execOut[$i];
}
}
} else {
return '';
}
}
return '';
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

47
public/index.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2019 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// [ 应用入口文件 ]
namespace think;
$server = isset($_REQUEST['server']) || isset($_SERVER['HTTP_SERVER']) || substr($_SERVER['REQUEST_URI'], 1, 9) == 'index.php' || $_SERVER['REQUEST_METHOD'] == 'OPTIONS';
if (!$server) {
/*
* 用户访问前端
* 不在tp加载后判断为了安全的使用 exit()(常驻内存运行时不走本文件)
*/
$rootPath = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR;
// 安装检测-s
if (!is_file($rootPath . 'install.lock') && is_file($rootPath . 'install' . DIRECTORY_SEPARATOR . 'index.html')) {
header("location:" . DIRECTORY_SEPARATOR . 'install' . DIRECTORY_SEPARATOR);
exit();
}
// 安装检测-e
// 检测是否已编译前端(如果存在 index.html则访问-s
if (is_file($rootPath . 'index.html')) {
header("location:" . DIRECTORY_SEPARATOR . 'index.html');
exit();
}
// 检测是否已编译前端-e
}
require __DIR__ . '/../vendor/autoload.php';
// 执行HTTP应用并响应
$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAC+lBMVEX///9No/1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Fn/1En/1GoP1Dnv1Dnv1Dnv1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Jof1Dnv1Dnv1Zqf1Dnv1cq/1OpP1Qpf1Tp/1Mo/1PpP1En/2antB2
w/9+vfeAvvVxvv6FltWas+F41f+Dyv+FzP9zwP5PpP1Vp/1Vp/1erP1erP1ss/1Fn/1Vp/3TlW9i
tP6/mLFfsv5Lov1psv1Mo/2Gzf+ck8Nruv5Jof1Spv28nrpJov1jr/1ttP1Tpv3Clpywj7Vbqv1M
o/1nmud7xf99x/9Rpf1Iof22ka9br/68k69Kov1OpP1VqP3FpoZ2ldhUp/2VkM5rs/3EmZZouP5K
ov1Qpf11leJMnfe3psVOpP1nsf1Vp/3AkaSIzv9bq/xRpf1Up/1bmu+Cyv+qkbpNpP3Gn4+TksRW
qP1HoP1GoP1dsf5Tpv1/yP+7kqtNpP3EqYJUp/2lk7xMo/1Spv1WqP1Uqv1WqP1jvP7FjqXifIWn
msOQmM3neH/LiZ6BpuLWgZFwqe7ag5DThpatl72UotS8hKJSpv05mf0/nP114f+Fzv+I0P+B1v+K
z/89m/1Anf1Dnv2Z5XaF0PyLz/+Uxe1+2f9Cnv07mv3BwVCd52d43v8+m/2v4zqq12aHzfnBsnqp
7jSurtKMzf+G1P86mv2h6Vm12jymtty2w2212kCGzvSj412s2l+s2GK9uXSh61Zz5P9Cnf2m3l6r
3lqu11+otNiwzmeo41at2F+Zv+p83f+k6FWz2kSKz+OCzf+v2FGLz96Pyvaz2kmw2U6N0Naos9qQ
xvLFrnWy2U+Q0c2fu+SS0cGw2VWV0bmV0ras2GGX0q+w2ViY0qr+YVz/XVb5Z2b/V03Nk6iawOH8
ZGL0bm37Z2WG6bT1bGzXiZmd8lv/XWL4amm/u2r/XWn/WlHFtWL/V277Y27rc3jDnLar3F/LrGSZ
+VTya23////Mjm1rAAAAlnRSTlMABAsRHCczOkFGST43LyQXDwhTX2ZvdWlkW08sVQJiIBJYaty+
zuHWd7zDwsW0l+Xl5uy6i7axf28x/cD4meOV8kKE8amp9NjwfGEjmdy7lO+FzdOQ98SP2X/npvaL
yaE24qPq4ox4/odNotP5jd7Gf+K26uyeqnr7k9TZzoH3nbHtlLqGrqPe+smh/t+r753z6c68y0O0
Xjp1AAAAAWJLR0QAiAUdSAAAAAd0SU1FB+YEBBcDHr5guP0AAA+vSURBVHja7V15fFTVFQ7ZzL6R
vABJALUI2koVWUOriIo7SgVqQFErm4hgRQOlYBBLpdpFu8wgcTIVzVhSsNKgUkRJQYjWarW2tNTW
2oRYhqaaGitd+KPzZubet913z/dme6G/fD9//hTecr+593z3nHvOuzctrR/96Ec/+hHGgPSMzKzs
U3Jy8/LycvMLCouKS0rdbpNTlBQV5pcNLK9QjKgcNHhIQVXmyUEnPaugulyRomJgXnZmjdsNlaG0
KH9ghYKhsjo70+32ilFSWIaSYCjPLeprHZNeWO2QBOuY3CK3265D0RCnfWHol/xitwmEUVowNA4W
EVRnuc0iLSMnns7QMDTbVWvJyE0IizAqCwa4RaMkgTTCVLJdoTEgPzGDyjDAXLCVqsqE01BRnWIF
K45x2gCQk0qrPyVpNEIoH5ay7hicTB4h5KamUwqSTCOEQSnwJ0vLks8jhIJk88hMjlhZUZbc6bEw
RTRCKE+mEOekjkcISZsda1JjHhqSZCglA1PMI6TDyeAxPPGuFY2yxPModoOHopyacB6u0Ahh8P8J
jwQzyXCPR0JHV7o79sGQOIuPf5kkPuQniEfygigUhU6ae9rpnxJjxOkMZ4wcxZ995lmfFuAzZ6Nt
G/3Zc84VYcx57Iqx48ZzTJgoxmkCIpNqPV4Knsm8IZ/b9OhmET4PEjm/cfNjQlzArpgCtGe8qEsu
9DX5CXibpnIiP3z80S2CdlyE8bj4iSe3CHlcwq4Y52km4A9ME/v6kz0UEb/nUj40nvrRo0Im2OC6
bKuYxxh2weW1LVRjmgNXiK3kyiYvdW+T7yr2pqt/vG27kMnlAI/p2xuJgTWC/lUDI+3s/Rrg5mvZ
m2Y8/RMxky/QPGY8s3WHkMd17IqZXnKcN3kn2REZMMtHD67x7F2zt9n0yRdJItc3/lTcIXXsijnA
MJ9rL8FXtDZTt/tq+dC5YeezQibnUjxufM7GQrh9zQ2QDWnxD5dMJqcH6B9iHnvbTc/v3LXtZwIm
lxBEzt8qll5N8ZChMUE2K97cAgzNsex1t+x+YdceAZOLRkl5TLeT3i+xKwD9DNwqn9/HA2NzPnvf
ghdf2iPsk/NkPBYu2iu2dD4kF9Py2ea7TU5kuJ9+RmAJe+PtP98nZLL0DgmR2Y/bWDoXiWXAr7k8
jcAEoFensaFz59Mv7Rcy+bLE0l+2kV4u21chLsaVFJGaabSdBeayd9514KCYib3Ldf6TYktfyqV3
BaA4SygeqgSTj/FO4RJ898729j0Cix9jx+OeV14lnKx6eg5oqR1OE0kbCYzQEeyto5//xf72Pa+1
//J1U/PsXK7LtooNhDNfuQqQ3okAj7RJgATrXK7d+0J98tobvzIzEbtcd71pI71fYVesBoz0DIQH
JMGt3OVS7T3E5K1fm5lcJ+KxcNEmMY+vsivGemnPNXAhRgSQYIPLtS/C5G0TE5HLtcZOeteyK+YD
lj4Z4wFJsM7lulftkhCTt0x9InC5bvzNJrH0nuXA0gHp5bgV+Fm4vasSHGbyhsnirRJ8vU18u5Rd
MAqx9GtgHmkTA23kQNVcLlWCI0yMfTLG7HLd84rNXMidrOXAWFhVghMpn+/E5QpLcLvA4s0ul530
cq8XcLL8rfU5MI9MRDvaAg3s/SGXK9onJouvM/BY91sb6eVjEHCyWu9TKmAiZYoyD3G5uAQfCtt7
2OLfft1Ogu9ctJWQ3oYAMIOtVxS06K4UnV/vZy2Yvu1glMnvXvu9vk/0ErymkZLeKYDGrA5dVw0S
Cadux9Fd0uJfyZpwQ8TeLUx0EkzHt8BE7G0LL6yBlcOnwj8Pl+CbovYeZSLQI1uvl3OFBkFkqRNb
Cy6NPHeJIwlm9m5mspRJ8D2HbbxevpIFmKVvVeRxWMqEFQUgEjyHW/LTUXs3M2Fztp30OnGymltZ
IASNLZZNB0S9WQuxuL2rTN76g8ZkrVx6v6bgv1sr/92qAB4DKhz0dWAWu3ght3eVyS6NSfgnt11w
4E5WA72S1eSdya4eAhAZxs1zZa0TCdbsPcTknV1/5EzUSGP2drH0LuW6Nw2U3giQOVFXb7IB0MNm
PgvcsvsFrU/eefZPjMkYidfL49v7gVf5dY4CUNClL9S4D/iZeKJhwYvc3tvb39WYbP668kAjIb11
zUAIdL+uZXSZSqnuamU9vTAT9hkiWMMl2MBk8znrXraRXj7xI/HtFH3L6Mm9SH+5cinkxTEJPqTr
EpXJn6N98opYebWVrJn0MkGbr0HfMNpIjAWkUyG/ml297sBBPZP3oky2NG4OdYhgcPEgE1nJmm9o
mEKWpZly0cBysm8Vl567NQmOMPkLs/gdT1rnEe5kIfGtd7GxYeRMYq5ZRByg5ezi0ToJ1vfJYzs2
PbfIbCfO4tvlpnZR0VW66XplLu1yeZt5rvdqvb1rfbJj7xMdndtNfeIkieCbZQ6bqVqbIjMRZT69
hKpJ8Ay9BHMmIR5HOrve32sgwte5p7Y5cYUYKgki1jJSZMVMk+DZ7x60Mtn716OdwWMdTxgCEp55
AJTRs8LSLCVdTiTfegeQKNYkeOG9L7W3m5j8TeXRHez6+yadcnHpReYq73prs4gKekEBDeRybWBX
X3zA2CX73/3gqQ8/DHZ3d/d0HtYcFR6nKNc6crJQ2aoRVZICLpdBgt/T83jhg3989JHKozvYu/FV
7jqe7eDhLf46QavkTkq68OtUxOXiud4FL+okOMTj439+EuYRQtczzJl3lkQYJ2qUXH8zhLVyQC5M
GPWaeAQ7jzdGKzf4SpajZScD5N5WpvAeSFi4C6FFvXoe6r+DXdH4Sotvgfytlns1QD6RZImJTKW9
bN37WNRr6o/QPx3BzeEucRLfepaJ2zRUSiRbfBOUwecjIBr1GnlEyHT9S+0SHt8CKzUWJ4uhXErE
7uOpUUhNBbfJmw6E7H3/vy08uruPHnvz1S1LudeLxLfzbJokn9ptv6uoR1SSN/CW3ftCPP5j4RHq
kvf3ak4WsppZuzImIvbfeiLzFl94XHBop5BHd/eRjsPnsKuAIjkb6VUhD63ybIkg5QiaBE/f9l8h
j1CXbOSWjhTJTVESTQSKevkCmnLvx0Ie3d2933Dw07RpafDEEQGi3mbN5Xrwk6MiHsHOh7iThfgL
y5QYici+h3YW9X7zRI+ASE/nt9gFSBKhaXGsRPIlRKCyNi6WM44fEXRI77fZ30OWvlzSGrlqST/C
BQoNdS7XmhNWJkc6v8P+GrB0lkSIhYj8O8M5TlZtFj5kIRLsup797Vi6HFa3ziTCICmRItmtSDGu
Lte77vgxE4+OHq1Sw0nYKcRAKRHiEyRnK5sP95jsvetM9ldIOsy3XtoWufdbIv8Utw6xUL7WPNrY
JcHOjfxBs2KMb3WQp0hKB8nvHkevDekCU6ME9/Ry6QWU3CuMb3UgPvOhvrlH/FXucs3Q23uw9xHe
sUgSYTLREmLLjiHE7YAE61yu2bouOdbBV7Lill4VxCfJ5PYBSPW9tnC+8Ygmvd9lfwiIHyG9Koic
VRV1P5JF1qLei5m9BztOcK93DrAIu4JqRwXx4X4m9QDI+Z7Gr36ESXDX99gfAUkEXf7WDvL5UJ+c
tgPkJOkk+EhUetmYh6Lm1VQr6K8sB5GPAAJUnXbeHrb3nl7uZAHS20JJbwinUETyyEc4W629U5Xg
YO8D7P+RIjlD/tYG5D4K2fQz0PqwCO46cSx49Ci3dKBIThLfaiihiNDWjlXsabLzcGhgOYpvAw10
E8opHmlpwJfSi2kJ9nvq2dUPHu89wQNHZFjOp1ug5NFEkE+MoapWPjU/0uEsvh0LtAAoPUO2BoKS
AVqu9/vsv+qQnNE8oAFKBk0E2hWh3gNUfk+13AY5WSuB9wMmYs20C4G4XJeab0JK2VrnIq+Hapih
LfKAmLupxRziIR+BzUHejlQ56SvPZACiXnPQ3QDEty2kk6UCrMWGtqqo89OfaZt88Vjzt1YA4qsC
8FIUyOUyGi5QJIc4WSrAXeqwsQVVOOuWCoEulCQRjCML3aMO2walwUfnzXSLt46L5OwBb/IE7gfm
6CMTIL5tRpwsFfBeaCXY86DcMmubs1hfiqEoD1bmTwJwudhoqQdWX2RJBD2QMmxn5g6Vvo2Dr1yO
vRU2dRXl2DM3AN/1hhXVWZJIDkc7CaHbGIK5XqBITveJJgFn++eDG1EhEuxdnEjpRWd1hmzwsYgf
OALJq/hA6aVKAGPtEsQz99cCA2sZ9j6nHYJbCVBwBSwAwdLruEPA+AqLemmg0otFVEYUgY+upyWY
BJ1EiIJauhYC3Wf5vviZ0EmEKBzt4caA7mm4nv5mguJBJhGioJbgbYBuiguU20hB5W81xLoFMGjv
a4GUoAzW5RYbxLzZLLIOrAIIYiUg87cM+OffFmDRu6IAu9tIOgRIIoQRz9bl4OACcr220L7JTNbA
UoHuKrsiZgluDtRjr4BWSe0B7nYPfDthA1h6gWVrKcCNTAFHXQggfxuBg/hWjBrMTOqA3W1E0Oo9
5EjANt/gBB+bBKPSm5BtmEHvEahcEnQIJr2ViTmfIBt6GVBLZgEa38Zr6AzYoULAwqMJUP5Wgdes
AUBnEQDlNuaBhS0txuS72wBaegQyhEZLx+LbxJ6wgERZlwM5W0OHQPlbsugkCUzGO3JUsKXFRPPA
RheQ+9EAOVnJOLmDKndU1IVH3OWC8rfJOTQtn34xHvVCTlayDn3MJt+8to1OFEY7hM7fVg5PEo+0
tGHkQiqQPwgDyN+emsxjazPI422ACudwh5D526Qca6MDFcYvgaJe2smKO/4gQa1uA1lP2skamIrT
BDPkc+NMpLyOcLKSPawY5KcI0i4X4WSl7hzBtGLZNE9XOMuTCLkpPQQ1WyLEVLmN1MkanOoTqdMl
0RbhcrVusL2zwo2DXIttwy15hVnA/iOwHJeObh9mZyqyqLfJ9vvb3ESF5jGgSExFFvXa5W/dpKEi
U+jd20uwWHor80vcpaEiI9+6GLnSbxf1iqR3cKFrx04bUZNlsXs7CbZKb2VuqgVXivTCauPMYlNu
Y8rfVuYVuXqcuZhL1RDdGBN/X2FIIgzN6YMsIqjJLKhmZETlNlr+dlBeocsqRZMprsoZXC4ut1Hj
24qhZQVZfZ2EhvRhVdatVLz+H2RlujR5x4EBllyv/HygvgvzSSbU+UB9F8aTTNoCt8X/SHdwoeE7
P89qt9sTO/SJRa/3ZrebEzuKdRLsqXe7NfHgDt4l2PlAfRbaYVInq/QysPP8Tl7pZRjZGnGyTlrp
ZZgUrsKmj2br+1D3+HRyPlCfhXqYlJPzgfouJnh8q04+l1eEWz1XxP+QvoCJ4NFsfR5Xnhb/M/rR
j370I4L/AYWr1zfavhjTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIyLTA0LTA0VDE1OjAzOjMwKzA4
OjAwP7kofQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMi0wNC0wNFQxNTowMzozMCswODowME7kkMEA
AAAgdEVYdHNvZnR3YXJlAGh0dHBzOi8vaW1hZ2VtYWdpY2sub3JnvM8dnQAAABh0RVh0VGh1bWI6
OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpIZWlnaHQAMjAwfdcV
aQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAyMDDuJkU0AAAAGXRFWHRUaHVtYjo6TWltZXR5
cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2NDkwNTU4MTD76yvxAAAAEnRF
WHRUaHVtYjo6U2l6ZQA0NTEzQkLz/Q6yAAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBwL3Rt
cC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTY0Nzg0ODMyNDI4NTI2NzhfODZfWzBdhSsH1AAAAABJRU5E
rkJggg==" ></image>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/install/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

14
public/install/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -0,0 +1,12 @@
{
"name": "npm-install-test",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite"
},
"dependencies": {
"vue": "^3.2.25"
},
"devDependencies": {}
}

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

21
public/router.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2019 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// $Id$
@ini_set('zlib.output_compression', 'Off');
if (is_file($_SERVER["DOCUMENT_ROOT"] . $_SERVER["SCRIPT_NAME"])) {
return false;
} else {
$_SERVER["SCRIPT_FILENAME"] = __DIR__ . '/index.php';
require __DIR__ . "/index.php";
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

2
runtime/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

10
think Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env php
<?php
namespace think;
// 命令行入口文件
// 加载基础文件
require __DIR__ . '/vendor/autoload.php';
// 应用初始化
(new App())->console->run();

15
web/.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
# https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
indent_style = tab
insert_final_newline = false
trim_trailing_whitespace = false

5
web/.env Normal file
View File

@@ -0,0 +1,5 @@
# port 端口号
VITE_PORT = 1818
# open 运行 npm run dev 时自动打开浏览器
VITE_OPEN = false

8
web/.env.development Normal file
View File

@@ -0,0 +1,8 @@
# 本地环境
ENV = 'development'
# base路径
VITE_BASE_PATH = './'
# 本地环境接口地址 - 尾部无需带'/'
VITE_AXIOS_BASE_URL = 'http://localhost:8000'

11
web/.env.production Normal file
View File

@@ -0,0 +1,11 @@
# 线上环境
ENV = 'production'
# base路径
VITE_BASE_PATH = '/'
# 导出路径
VITE_OUT_DIR = 'dist'
# 线上环境接口地址 - 'getCurrentDomain:表示获取当前域名'
VITE_AXIOS_BASE_URL = 'getCurrentDomain'

2
web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
# 若不开启且使用 pnpm 安装依赖后element-plus 和 vue-i18n(useI18n) 共存时组件的类型定义将丢失
shamefully-hoist=true

36
web/.prettierrc.js Normal file
View File

@@ -0,0 +1,36 @@
export default {
printWidth: 150,
// 指定每个缩进级别的空格数
tabWidth: 4,
// 使用制表符而不是空格缩进行
useTabs: false,
// 在语句末尾打印分号
semi: false,
// 使用单引号而不是双引号
singleQuote: true,
// 更改引用对象属性的时间 可选值"<as-needed|consistent|preserve>"
quoteProps: 'as-needed',
// 在JSX中使用单引号而不是双引号
jsxSingleQuote: false,
// 多行时尽可能打印尾随逗号。(例如,单行数组永远不会出现逗号结尾。) 可选值"<none|es5|all>"默认none
trailingComma: 'es5',
// 在对象文字中的括号之间打印空格
bracketSpacing: true,
// 在单独的箭头函数参数周围包括括号 always(x) => x \ avoidx => x
arrowParens: 'always',
// 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码
rangeStart: 0,
rangeEnd: Infinity,
// 指定要使用的解析器,不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准 always\never\preserve
proseWrap: 'preserve',
// 指定HTML文件的全局空格敏感度 css\strict\ignore
htmlWhitespaceSensitivity: 'css',
// Vue文件脚本和样式标签缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf 结尾是 可选值"<auto|lf|crlf|cr>"
endOfLine: 'lf',
}

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["vue.volar", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

15
web/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.validate": ["javascript", "vue", "typescript"]
}

108
web/eslint.config.js Normal file
View File

@@ -0,0 +1,108 @@
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import eslintPluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import ts from 'typescript-eslint'
export default [
// 三大基本推荐规则
js.configs.recommended,
...ts.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
// 忽略规则
{
ignores: ['node_modules', 'dist', 'public'],
},
// 全局变量
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
// vue
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: {
// ts 解析器
parser: ts.parser,
// 允许 jsx
ecmaFeatures: {
jsx: true,
},
},
},
},
// eslint + prettier 的兼容性问题解决规则
eslintConfigPrettier,
eslintPluginPrettierRecommended,
// ts
{
files: ['**/*.{ts,tsx,vue}'],
rules: {
'no-empty': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-prototype-builtins': 'off',
'no-use-before-define': 'off',
'no-case-declarations': 'off',
'no-console': 'off',
'no-control-regex': 'off',
'vue/v-on-event-hyphenation': 'off',
'vue/custom-event-name-casing': 'off',
'vue/component-definition-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/require-default-prop': 'off',
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-v-html': 'off',
'vue/comment-directive': 'off',
'vue/multi-word-component-names': 'off',
'vue/require-prop-types': 'off',
'vue/html-indent': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
// prettier 规则
{
files: ['**/*.{ts,tsx,vue,js}'],
rules: {
'prettier/prettier': [
'warn', // 使用警告而不是错误
{
endOfLine: 'auto', // eslint 无需检查文件换行符
},
],
},
},
]

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<!-- 已响应站点前端内容,建议使用浏览器查看;调试 API 请参考https://doc.buildadmin.com/senior/server/apiDebug.html -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

63
web/package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "build-admin",
"version": "2.3.6",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "esno ./src/utils/build.ts && vite --force",
"build": "vite build && esno ./src/utils/build.ts",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "npx prettier --write .",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.1",
"@vueuse/core": "12.0.0",
"axios": "1.9.0",
"echarts": "5.5.1",
"element-plus": "2.9.1",
"font-awesome": "4.7.0",
"lodash-es": "4.17.21",
"mitt": "3.0.1",
"nprogress": "0.2.0",
"pinia": "2.3.0",
"pinia-plugin-persistedstate": "4.2.0",
"qrcode.vue": "3.6.0",
"screenfull": "6.0.2",
"sortablejs": "1.15.6",
"v-code-diff": "1.13.1",
"vue": "3.5.13",
"vue-i18n": "11.1.3",
"vue-router": "4.5.0"
},
"devDependencies": {
"@eslint/js": "9.17.0",
"@types/lodash-es": "4.17.12",
"@types/node": "22.10.2",
"@types/nprogress": "0.2.3",
"@types/sortablejs": "1.15.8",
"@vitejs/plugin-vue": "5.2.3",
"async-validator": "4.2.5",
"eslint": "9.17.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-vue": "9.32.0",
"esno": "4.8.0",
"globals": "15.14.0",
"prettier": "3.4.2",
"sass": "1.83.0",
"typescript": "5.7.2",
"typescript-eslint": "8.18.1",
"vite": "6.3.5",
"vue-tsc": "2.1.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"v-code-diff",
"vue-demi"
]
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

36
web/src/App.vue Normal file
View File

@@ -0,0 +1,36 @@
<template>
<el-config-provider :value-on-clear="() => null" :locale="lang">
<router-view></router-view>
</el-config-provider>
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { setTitleFromRoute } from '/@/utils/common'
import iconfontInit from '/@/utils/iconfont'
import { init as viteInit } from '/@/utils/vite'
// modules import mark, Please do not remove.
const route = useRoute()
const config = useConfig()
// 初始化 element 的语言包
const { getLocaleMessage } = useI18n()
const lang = getLocaleMessage(config.lang.defaultLang) as any
onMounted(() => {
viteInit()
iconfontInit()
// Modules onMounted mark, Please do not remove.
})
// 监听路由变化时更新浏览器标题
watch(
() => route.path,
() => {
setTitleFromRoute()
}
)
</script>

View File

@@ -0,0 +1,8 @@
import createAxios from '/@/utils/axios'
export function getAdminRules() {
return createAxios({
url: '/admin/auth.Rule/index',
method: 'get',
})
}

142
web/src/api/backend/crud.ts Normal file
View File

@@ -0,0 +1,142 @@
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import createAxios from '/@/utils/axios'
export const url = '/admin/crud.Crud/'
export function generate(data: anyObj) {
return createAxios(
{
url: url + 'generate',
method: 'post',
data: data,
},
{
showSuccessMessage: true,
}
)
}
export function getFileData(table: string, commonModel = 0) {
return createAxios({
url: url + 'getFileData',
method: 'get',
params: {
table: table,
commonModel: commonModel,
},
})
}
export function generateCheck(data: anyObj) {
return createAxios(
{
url: url + 'generateCheck',
method: 'post',
data: data,
},
{
showCodeMessage: false,
}
)
}
export function parseFieldData(data: anyObj) {
return createAxios({
url: url + 'parseFieldData',
method: 'post',
data: data,
})
}
export function postLogStart(id: string, type: string) {
const data: anyObj = {
id,
type,
}
if (type == 'Cloud history') {
const baAccount = useBaAccount()
data['token'] = baAccount.getToken('auth')
}
return createAxios({
url: url + 'logStart',
method: 'post',
data: data,
})
}
export function postDel(id: number) {
return createAxios({
url: url + 'delete',
method: 'post',
data: {
id: id,
},
})
}
export function checkCrudLog(table: string, connection: string) {
return createAxios({
url: url + 'checkCrudLog',
method: 'get',
params: {
table: table,
connection: connection,
},
})
}
export function uploadLog(data: anyObj) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/v6.Crud/uploadLog',
data: data,
method: 'post',
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function uploadCompleted(data: anyObj) {
return createAxios({
url: url + 'uploadCompleted',
data: data,
method: 'post',
})
}
export function logs(data: anyObj = {}) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/v6.Crud/logs',
data: data,
method: 'post',
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function delLog(data: anyObj = {}) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/v6.Crud/del',
data: data,
method: 'post',
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}

View File

@@ -0,0 +1,10 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/Dashboard/'
export function index() {
return createAxios({
url: url + 'index',
method: 'get',
})
}

View File

@@ -0,0 +1,72 @@
import { useAdminInfo } from '/@/stores/adminInfo'
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import createAxios from '/@/utils/axios'
export const url = '/admin/Index/'
export function index() {
return createAxios({
url: url + 'index',
method: 'get',
})
}
export function login(method: 'get' | 'post', params: object = {}) {
return createAxios({
url: url + 'login',
data: params,
method: method,
})
}
export function logout() {
const adminInfo = useAdminInfo()
return createAxios({
url: url + 'logout',
method: 'POST',
data: {
refreshToken: adminInfo.getToken('refresh'),
},
})
}
export function baAccountCheckIn(params: object = {}) {
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/user/checkIn',
data: params,
method: 'post',
},
{
showSuccessMessage: true,
}
)
}
export function baAccountGetUserInfo() {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/user/info',
method: 'get',
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function baAccountLogout() {
const siteConfig = useSiteConfig()
const baAccount = useBaAccount()
return createAxios({
url: siteConfig.apiUrl + '/api/user/logout',
method: 'POST',
data: {
refreshToken: baAccount.getToken('refresh'),
},
})
}

View File

@@ -0,0 +1,190 @@
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import createAxios from '/@/utils/axios'
const storeUrl = '/api/v7.store/'
const moduleControllerUrl = '/admin/module/'
export function index(params: anyObj = {}) {
return createAxios({
url: moduleControllerUrl + 'index',
method: 'get',
params: params,
})
}
export function modules(params: anyObj = {}) {
const siteConfig = useSiteConfig()
return createAxios({
url: siteConfig.apiUrl + storeUrl + 'modules',
method: 'get',
params: params,
})
}
export function info(params: anyObj) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'info',
method: 'get',
params: params,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function createOrder(params: object = {}) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'order',
method: 'post',
params: params,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function payOrder(orderId: number, payType: string) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'pay',
method: 'post',
params: {
order_id: orderId,
pay_type: payType,
},
},
{
anotherToken: baAccount.getToken('auth'),
showSuccessMessage: true,
}
)
}
export function payCheck(sn: string) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/pay/check',
method: 'get',
params: {
sn: sn,
},
},
{
anotherToken: baAccount.getToken('auth'),
showCodeMessage: false,
}
)
}
/**
* 获取模块的可安装版本列表
*/
export function preDownload(data: anyObj) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'preDownload',
method: 'POST',
data,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function getInstallState(uid: string) {
return createAxios({
url: moduleControllerUrl + 'state',
method: 'get',
params: {
uid: uid,
},
})
}
export function postInstallModule(uid: string, orderId: number, version: string, update: boolean, extend: anyObj = {}) {
const baAccount = useBaAccount()
return createAxios(
{
url: moduleControllerUrl + 'install',
method: 'POST',
data: {
uid,
update,
version,
orderId,
token: baAccount.getToken('auth'),
extend,
},
timeout: 3000 * 10,
},
{
showCodeMessage: false,
}
)
}
export function postUninstall(uid: string) {
return createAxios(
{
url: moduleControllerUrl + 'uninstall',
method: 'post',
params: {
uid: uid,
},
},
{
showSuccessMessage: true,
}
)
}
export function changeState(params: anyObj) {
return createAxios(
{
url: moduleControllerUrl + 'changeState',
method: 'post',
data: params,
},
{
showCodeMessage: false,
}
)
}
export function dependentInstallComplete(uid: string) {
return createAxios({
url: moduleControllerUrl + 'dependentInstallComplete',
method: 'post',
params: {
uid: uid,
},
})
}
export function upload(file: string) {
const baAccount = useBaAccount()
return createAxios({
url: moduleControllerUrl + 'upload',
method: 'post',
params: {
file: file,
token: baAccount.getToken('auth'),
},
})
}

View File

@@ -0,0 +1,37 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/routine.AdminInfo/'
export const actionUrl = new Map([
['index', url + 'index'],
['edit', url + 'edit'],
['log', '/admin/auth.AdminLog/index'],
])
export function index() {
return createAxios({
url: actionUrl.get('index'),
method: 'get',
})
}
export function log(filter: anyObj = {}) {
return createAxios<TableDefaultData>({
url: actionUrl.get('log'),
method: 'get',
params: filter,
})
}
export function postData(data: anyObj) {
return createAxios(
{
url: actionUrl.get('edit'),
method: 'post',
data: data,
},
{
showSuccessMessage: true,
}
)
}

View File

@@ -0,0 +1,59 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/routine.Config/'
export const actionUrl = new Map([
['index', url + 'index'],
['add', url + 'add'],
['edit', url + 'edit'],
['del', url + 'del'],
['sendTestMail', url + 'sendTestMail'],
])
export function index() {
return createAxios({
url: actionUrl.get('index'),
method: 'get',
})
}
export function postData(action: string, data: anyObj) {
return createAxios(
{
url: actionUrl.get(action),
method: 'post',
data: data,
},
{
showSuccessMessage: true,
}
)
}
export function del(ids: string[]) {
return createAxios(
{
url: actionUrl.get('del'),
method: 'DELETE',
params: {
ids: ids,
},
},
{
showSuccessMessage: true,
}
)
}
export function postSendTestMail(data: anyObj, mail: string) {
return createAxios(
{
url: actionUrl.get('sendTestMail'),
method: 'POST',
data: Object.assign({}, data, { testMail: mail }),
},
{
showSuccessMessage: true,
}
)
}

View File

@@ -0,0 +1,10 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/security.DataRecycle/'
export function add() {
return createAxios({
url: url + 'add',
method: 'get',
})
}

View File

@@ -0,0 +1,28 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/security.DataRecycleLog/'
export function restore(ids: string[]) {
return createAxios(
{
url: url + 'restore',
method: 'POST',
data: {
ids: ids,
},
},
{
showSuccessMessage: true,
}
)
}
export function info(id: string) {
return createAxios({
url: url + 'info',
method: 'get',
params: {
id: id,
},
})
}

View File

@@ -0,0 +1,10 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/security.SensitiveData/'
export function add() {
return createAxios({
url: url + 'add',
method: 'get',
})
}

View File

@@ -0,0 +1,28 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/security.SensitiveDataLog/'
export function rollback(ids: string[]) {
return createAxios(
{
url: url + 'rollback',
method: 'POST',
data: {
ids: ids,
},
},
{
showSuccessMessage: true,
}
)
}
export function info(id: string) {
return createAxios({
url: url + 'info',
method: 'get',
params: {
id: id,
},
})
}

View File

@@ -0,0 +1,8 @@
import createAxios from '/@/utils/axios'
export function getUserRules() {
return createAxios({
url: '/admin/user.Rule/index',
method: 'get',
})
}

View File

@@ -0,0 +1,13 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.MoneyLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

View File

@@ -0,0 +1,13 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.ScoreLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

378
web/src/api/common.ts Normal file
View File

@@ -0,0 +1,378 @@
import createAxios from '/@/utils/axios'
import { isAdminApp, checkFileMimetype } from '/@/utils/common'
import { getUrl } from '/@/utils/axios'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useUserInfo } from '/@/stores/userInfo'
import { ElNotification, type UploadRawFile } from 'element-plus'
import { useSiteConfig } from '/@/stores/siteConfig'
import { state as uploadExpandState, fileUpload as uploadExpand } from '/@/components/mixins/baUpload'
import type { AxiosRequestConfig } from 'axios'
import { uuid } from '/@/utils/random'
import { i18n } from '../lang'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
/*
* 公共请求函数和Url定义
*/
// Admin模块
export const adminUploadUrl = '/admin/ajax/upload'
export const adminBuildSuffixSvgUrl = adminBaseRoutePath + '/ajax/buildSuffixSvg'
export const adminAreaUrl = '/admin/ajax/area'
export const getTablePkUrl = '/admin/ajax/getTablePk'
export const getTableListUrl = '/admin/ajax/getTableList'
export const getTableFieldListUrl = '/admin/ajax/getTableFieldList'
export const getDatabaseConnectionListUrl = '/admin/ajax/getDatabaseConnectionList'
export const terminalUrl = adminBaseRoutePath + '/ajax/terminal'
export const changeTerminalConfigUrl = '/admin/ajax/changeTerminalConfig'
export const clearCacheUrl = '/admin/ajax/clearCache'
// 公共
export const captchaUrl = '/api/common/captcha'
export const clickCaptchaUrl = '/api/common/clickCaptcha'
export const checkClickCaptchaUrl = '/api/common/checkClickCaptcha'
export const refreshTokenUrl = '/api/common/refreshToken'
// api模块(前台)
export const apiUploadUrl = '/api/ajax/upload'
export const apiBuildSuffixSvgUrl = '/api/ajax/buildSuffixSvg'
export const apiAreaUrl = '/api/ajax/area'
export const apiSendSms = '/api/Sms/send'
export const apiSendEms = '/api/Ems/send'
/**
* 上传文件
*/
export function fileUpload(fd: FormData, params: anyObj = {}, forceLocal = false, config: AxiosRequestConfig = {}): ApiPromise {
let errorMsg = ''
const file = fd.get('file') as UploadRawFile
const siteConfig = useSiteConfig()
if (!file.name || typeof file.size == 'undefined') {
errorMsg = i18n.global.t('utils.The data of the uploaded file is incomplete!')
} else if (!checkFileMimetype(file.name, file.type)) {
errorMsg = i18n.global.t('utils.The type of uploaded file is not allowed!')
} else if (file.size > siteConfig.upload.maxSize) {
errorMsg = i18n.global.t('utils.The size of the uploaded file exceeds the allowed range!')
}
if (errorMsg) {
return new Promise((resolve, reject) => {
ElNotification({
type: 'error',
message: errorMsg,
zIndex: SYSTEM_ZINDEX,
})
reject(errorMsg)
})
}
if (!forceLocal && uploadExpandState() == 'enable') {
return uploadExpand(fd, params, config)
}
return createAxios({
url: isAdminApp() ? adminUploadUrl : apiUploadUrl,
method: 'POST',
data: fd,
params: params,
timeout: 0,
...config,
})
}
/**
* 生成文件后缀icon的svg图片
* @param suffix 后缀名
* @param background 背景色,如:rgb(255,255,255)
*/
export function buildSuffixSvgUrl(suffix: string, background = '') {
const adminInfo = useAdminInfo()
return (
getUrl() +
(isAdminApp() ? adminBuildSuffixSvgUrl : apiBuildSuffixSvgUrl) +
'?batoken=' +
adminInfo.getToken() +
'&suffix=' +
suffix +
(background ? '&background=' + background : '') +
'&server=1'
)
}
/**
* 获取地区数据
*/
export function getArea(values: number[]) {
const params: { province?: number; city?: number; uuid?: string } = {}
if (values[0]) {
params.province = values[0]
}
if (values[1]) {
params.city = values[1]
}
params.uuid = uuid()
return createAxios({
url: isAdminApp() ? adminAreaUrl : apiAreaUrl,
method: 'GET',
params: params,
})
}
/**
* 发送短信
*/
export function sendSms(mobile: string, templateCode: string, extend: anyObj = {}) {
return createAxios(
{
url: apiSendSms,
method: 'POST',
data: {
mobile: mobile,
template_code: templateCode,
...extend,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 发送邮件
*/
export function sendEms(email: string, event: string, extend: anyObj = {}) {
return createAxios(
{
url: apiSendEms,
method: 'POST',
data: {
email: email,
event: event,
...extend,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 缓存清理接口
*/
export function postClearCache(type: string) {
return createAxios(
{
url: clearCacheUrl,
method: 'POST',
data: {
type: type,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 构建命令执行窗口url
*/
export function buildTerminalUrl(commandKey: string, uuid: string, extend: string) {
const adminInfo = useAdminInfo()
return (
getUrl() + terminalUrl + '?command=' + commandKey + '&uuid=' + uuid + '&extend=' + extend + '&batoken=' + adminInfo.getToken() + '&server=1'
)
}
/**
* 请求修改终端配置
*/
export function postChangeTerminalConfig(data: { manager?: string; port?: string }) {
return createAxios({
url: changeTerminalConfigUrl,
method: 'POST',
data: data,
})
}
/**
* 远程下拉框数据获取
*/
export function getSelectData(remoteUrl: string, q: string, params: anyObj = {}) {
return createAxios({
url: remoteUrl,
method: 'get',
params: {
select: true,
quickSearch: q,
...params,
},
})
}
export function buildCaptchaUrl() {
return getUrl() + captchaUrl + '?server=1'
}
export function getCaptchaData(id: string, apiBaseURL: string) {
return createAxios({
url: apiBaseURL + clickCaptchaUrl,
method: 'get',
params: {
id,
},
})
}
export function checkClickCaptcha(id: string, info: string, unset: boolean, apiBaseURL: string) {
return createAxios(
{
url: apiBaseURL + checkClickCaptchaUrl,
method: 'post',
data: {
id,
info,
unset,
},
},
{
showCodeMessage: false,
}
)
}
export function getTablePk(table: string, connection = '') {
return createAxios({
url: getTablePkUrl,
method: 'get',
params: {
table: table,
connection: connection,
},
})
}
/**
* 获取数据表的字段
* @param table 数据表名
* @param clean 只要干净的字段注释(只要字段标题)
*/
export function getTableFieldList(table: string, clean = true, connection = '') {
return createAxios({
url: getTableFieldListUrl,
method: 'get',
params: {
table: table,
clean: clean ? 1 : 0,
connection: connection,
},
})
}
export function refreshToken() {
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
return createAxios({
url: refreshTokenUrl,
method: 'POST',
data: {
refreshToken: isAdminApp() ? adminInfo.getToken('refresh') : userInfo.getToken('refresh'),
},
})
}
/**
* 快速生成一个控制器的 增、删、改、查、排序 接口的请求方法
* 本 class 实例通常直接传递给 baTable 使用,开发者可重写本类的方法,亦可直接向 baTable 传递自定义的 API 请求类
* 表格相关网络请求无需局限于本类,开发者可于 /src/api/ 目录创建自定义的接口请求函数,并于需要的地方导入使用即可
*/
export class baTableApi {
private controllerUrl
public actionUrl
constructor(controllerUrl: string) {
this.controllerUrl = controllerUrl
this.actionUrl = new Map([
['index', controllerUrl + 'index'],
['add', controllerUrl + 'add'],
['edit', controllerUrl + 'edit'],
['del', controllerUrl + 'del'],
['sortable', controllerUrl + 'sortable'],
])
}
/**
* 表格查看接口的请求方法
* @param filter 数据过滤条件
*/
index(filter: BaTable['filter'] = {}) {
return createAxios<TableDefaultData>({
url: this.actionUrl.get('index'),
method: 'get',
params: filter,
})
}
/**
* 获取被编辑行数据
* @param params 被编辑行主键等
*/
edit(params: anyObj) {
return createAxios({
url: this.actionUrl.get('edit'),
method: 'get',
params,
})
}
/**
* 表格删除接口的请求方法
* @param ids 被删除数据的主键数组
*/
del(ids: string[]) {
return createAxios(
{
url: this.actionUrl.get('del'),
method: 'DELETE',
params: {
ids,
},
},
{
showSuccessMessage: true,
}
)
}
/**
* 向指定接口 POST 数据,本方法虽然较为通用,但请不要局限于此,开发者可于 /src/api/ 目录创建自定义的接口请求函数,并于需要的地方导入使用即可
* @param action 请求的接口,比如 add、edit
* @param data 要 POST 的数据
*/
postData(action: string, data: anyObj) {
return createAxios(
{
url: this.actionUrl.has(action) ? this.actionUrl.get(action) : this.controllerUrl + action,
method: 'post',
data,
},
{
showSuccessMessage: true,
}
)
}
/**
* 表格行排序接口的请求方法
*/
sortable(data: anyObj) {
return createAxios({
url: this.actionUrl.get('sortable'),
method: 'post',
data,
})
}
}

View File

@@ -0,0 +1,56 @@
import createAxios from '/@/utils/axios'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { debounce, setTitleFromRoute } from '/@/utils/common'
import { handleFrontendRoute } from '/@/utils/router'
import { useUserInfo } from '/@/stores/userInfo'
import router from '/@/router/index'
import { isEmpty } from 'lodash-es'
export const indexUrl = '/api/index/'
/**
* 前台初始化请求,获取站点配置信息,动态路由信息等
* 1. 首次初始化携带了会员token时一共只初始化一次
* 2. 首次初始化未带会员token将在登录后再初始化一次
*/
export function initialize(callback?: (res: ApiResponse) => void, requiredLogin?: boolean) {
debounce(() => {
if (router.currentRoute.value.meta.initialize === false) return
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
if (!userInfo.isLogin() && siteConfig.initialize) return
if (userInfo.isLogin() && siteConfig.userInitialize) return
const memberCenter = useMemberCenter()
createAxios({
url: indexUrl + 'index',
method: 'get',
params: {
requiredLogin: requiredLogin ? 1 : 0,
},
}).then((res) => {
handleFrontendRoute(res.data.rules, res.data.menus)
siteConfig.dataFill(res.data.site)
memberCenter.setStatus(res.data.openMemberCenter)
if (!isEmpty(res.data.userInfo)) {
userInfo.dataFill(res.data.userInfo)
// 请求到会员信息才设置会员中心初始化是成功的
siteConfig.setUserInitialize(true)
}
if (!res.data.openMemberCenter) memberCenter.setLayoutMode('Disable')
siteConfig.setInitialize(true)
// 根据当前路由重设页面标题
setTitleFromRoute()
typeof callback == 'function' && callback(res)
})
}, 200)()
}

View File

@@ -0,0 +1,120 @@
import createAxios from '/@/utils/axios'
import { useUserInfo } from '/@/stores/userInfo'
export const userUrl = '/api/user/'
export const accountUrl = '/api/account/'
export function checkIn(method: 'get' | 'post', params: object = {}) {
return createAxios({
url: userUrl + 'checkIn',
data: params,
method: method,
})
}
export function overview() {
return createAxios({
url: accountUrl + 'overview',
method: 'get',
})
}
export function postProfile(params: anyObj) {
return createAxios(
{
url: accountUrl + 'profile',
method: 'POST',
data: params,
},
{
showSuccessMessage: true,
}
)
}
export function getProfile() {
return createAxios({
url: accountUrl + 'profile',
method: 'get',
})
}
export function postVerification(data: anyObj) {
return createAxios({
url: accountUrl + 'verification',
method: 'post',
data: data,
})
}
export function postChangeBind(data: anyObj) {
return createAxios(
{
url: accountUrl + 'changeBind',
method: 'post',
data: data,
},
{
showSuccessMessage: true,
}
)
}
export function changePassword(params: anyObj) {
return createAxios(
{
url: accountUrl + 'changePassword',
method: 'POST',
data: params,
},
{
showSuccessMessage: true,
}
)
}
export function getBalanceLog(page: number, pageSize: number) {
return createAxios({
url: accountUrl + 'balance',
method: 'GET',
params: {
page: page,
limit: pageSize,
},
})
}
export function getIntegralLog(page: number, pageSize: number) {
return createAxios({
url: accountUrl + 'integral',
method: 'GET',
params: {
page: page,
limit: pageSize,
},
})
}
export function postLogout() {
const userInfo = useUserInfo()
return createAxios({
url: userUrl + 'logout',
method: 'POST',
data: {
refreshToken: userInfo.getToken('refresh'),
},
})
}
export function retrievePassword(params: anyObj) {
return createAxios(
{
url: accountUrl + 'retrievePassword',
method: 'POST',
data: params,
},
{
showSuccessMessage: true,
}
)
}

BIN
web/src/assets/bg-dark.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
web/src/assets/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="dark-icon">
<path d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56z" fill="currentColor" /></svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1642660648463" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9874" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M568.88 421.173l166.58-166.58 33.94 33.94-166.58 166.581z" p-id="9875" fill="#2c2c2c"></path><path d="M539.267 484.666l0.058-48 192.05 0.235-0.059 48z" p-id="9876" fill="#2c2c2c"></path><path d="M539.097 292.671l48-0.059 0.235 192.05-48 0.059zM254.597 735.473l166.58-166.58 33.941 33.941-166.58 166.58z" p-id="9877" fill="#2c2c2c"></path><path d="M292.605 587.091l0.059-48 192.05 0.235-0.059 48z" p-id="9878" fill="#2c2c2c"></path><path d="M436.659 539.346l48-0.059 0.234 192.05-48 0.059zM855.95 651.77h48v252.05h-48z" p-id="9879" fill="#2c2c2c"></path><path d="M651.81 855.91h252.05v48H651.81zM119.61 119.67h48v252.05h-48z" p-id="9880" fill="#2c2c2c"></path><path d="M119.7 119.58h252.05v48H119.7zM651.81 119.58h252.05v48H651.81z" p-id="9881" fill="#2c2c2c"></path><path d="M855.95 119.67h48v252.05h-48zM119.7 855.91h252.05v48H119.7z" p-id="9882" fill="#2c2c2c"></path><path d="M119.61 651.77h48v252.05h-48z" p-id="9883" fill="#2c2c2c"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="1.2em" height="1.2em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24" data-v-1e752d22=""><path d="M18.5 10l4.4 11h-2.155l-1.201-3h-4.09l-1.199 3h-2.154L16.5 10h2zM10 2v2h6v2h-1.968a18.222 18.222 0 0 1-3.62 6.301a14.864 14.864 0 0 0 2.336 1.707l-.751 1.878A17.015 17.015 0 0 1 9 13.725a16.676 16.676 0 0 1-6.201 3.548l-.536-1.929a14.7 14.7 0 0 0 5.327-3.042A18.078 18.078 0 0 1 4.767 8h2.24A16.032 16.032 0 0 0 9 10.877a16.165 16.165 0 0 0 2.91-4.876L2 6V4h6V2h2zm7.5 10.885L16.253 16h2.492L17.5 12.885z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 693 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="200" width="200" viewBox="0 0 24 24" class="light-icon">
<path
d="M6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAC+lBMVEX///9No/1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Fn/1En/1GoP1Dnv1Dnv1Dnv1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Jof1Dnv1Dnv1Zqf1Dnv1cq/1OpP1Qpf1Tp/1Mo/1PpP1En/2antB2
w/9+vfeAvvVxvv6FltWas+F41f+Dyv+FzP9zwP5PpP1Vp/1Vp/1erP1erP1ss/1Fn/1Vp/3TlW9i
tP6/mLFfsv5Lov1psv1Mo/2Gzf+ck8Nruv5Jof1Spv28nrpJov1jr/1ttP1Tpv3Clpywj7Vbqv1M
o/1nmud7xf99x/9Rpf1Iof22ka9br/68k69Kov1OpP1VqP3FpoZ2ldhUp/2VkM5rs/3EmZZouP5K
ov1Qpf11leJMnfe3psVOpP1nsf1Vp/3AkaSIzv9bq/xRpf1Up/1bmu+Cyv+qkbpNpP3Gn4+TksRW
qP1HoP1GoP1dsf5Tpv1/yP+7kqtNpP3EqYJUp/2lk7xMo/1Spv1WqP1Uqv1WqP1jvP7FjqXifIWn
msOQmM3neH/LiZ6BpuLWgZFwqe7ag5DThpatl72UotS8hKJSpv05mf0/nP114f+Fzv+I0P+B1v+K
z/89m/1Anf1Dnv2Z5XaF0PyLz/+Uxe1+2f9Cnv07mv3BwVCd52d43v8+m/2v4zqq12aHzfnBsnqp
7jSurtKMzf+G1P86mv2h6Vm12jymtty2w2212kCGzvSj412s2l+s2GK9uXSh61Zz5P9Cnf2m3l6r
3lqu11+otNiwzmeo41at2F+Zv+p83f+k6FWz2kSKz+OCzf+v2FGLz96Pyvaz2kmw2U6N0Naos9qQ
xvLFrnWy2U+Q0c2fu+SS0cGw2VWV0bmV0ras2GGX0q+w2ViY0qr+YVz/XVb5Z2b/V03Nk6iawOH8
ZGL0bm37Z2WG6bT1bGzXiZmd8lv/XWL4amm/u2r/XWn/WlHFtWL/V277Y27rc3jDnLar3F/LrGSZ
+VTya23////Mjm1rAAAAlnRSTlMABAsRHCczOkFGST43LyQXDwhTX2ZvdWlkW08sVQJiIBJYaty+
zuHWd7zDwsW0l+Xl5uy6i7axf28x/cD4meOV8kKE8amp9NjwfGEjmdy7lO+FzdOQ98SP2X/npvaL
yaE24qPq4ox4/odNotP5jd7Gf+K26uyeqnr7k9TZzoH3nbHtlLqGrqPe+smh/t+r753z6c68y0O0
Xjp1AAAAAWJLR0QAiAUdSAAAAAd0SU1FB+YEBBcDHr5guP0AAA+vSURBVHja7V15fFTVFQ7ZzL6R
vABJALUI2koVWUOriIo7SgVqQFErm4hgRQOlYBBLpdpFu8wgcTIVzVhSsNKgUkRJQYjWarW2tNTW
2oRYhqaaGitd+KPzZubet913z/dme6G/fD9//hTecr+593z3nHvOuzctrR/96Ec/+hHGgPSMzKzs
U3Jy8/LycvMLCouKS0rdbpNTlBQV5pcNLK9QjKgcNHhIQVXmyUEnPaugulyRomJgXnZmjdsNlaG0
KH9ghYKhsjo70+32ilFSWIaSYCjPLeprHZNeWO2QBOuY3CK3265D0RCnfWHol/xitwmEUVowNA4W
EVRnuc0iLSMnns7QMDTbVWvJyE0IizAqCwa4RaMkgTTCVLJdoTEgPzGDyjDAXLCVqsqE01BRnWIF
K45x2gCQk0qrPyVpNEIoH5ay7hicTB4h5KamUwqSTCOEQSnwJ0vLks8jhIJk88hMjlhZUZbc6bEw
RTRCKE+mEOekjkcISZsda1JjHhqSZCglA1PMI6TDyeAxPPGuFY2yxPModoOHopyacB6u0Ahh8P8J
jwQzyXCPR0JHV7o79sGQOIuPf5kkPuQniEfygigUhU6ae9rpnxJjxOkMZ4wcxZ995lmfFuAzZ6Nt
G/3Zc84VYcx57Iqx48ZzTJgoxmkCIpNqPV4Knsm8IZ/b9OhmET4PEjm/cfNjQlzArpgCtGe8qEsu
9DX5CXibpnIiP3z80S2CdlyE8bj4iSe3CHlcwq4Y52km4A9ME/v6kz0UEb/nUj40nvrRo0Im2OC6
bKuYxxh2weW1LVRjmgNXiK3kyiYvdW+T7yr2pqt/vG27kMnlAI/p2xuJgTWC/lUDI+3s/Rrg5mvZ
m2Y8/RMxky/QPGY8s3WHkMd17IqZXnKcN3kn2REZMMtHD67x7F2zt9n0yRdJItc3/lTcIXXsijnA
MJ9rL8FXtDZTt/tq+dC5YeezQibnUjxufM7GQrh9zQ2QDWnxD5dMJqcH6B9iHnvbTc/v3LXtZwIm
lxBEzt8qll5N8ZChMUE2K97cAgzNsex1t+x+YdceAZOLRkl5TLeT3i+xKwD9DNwqn9/HA2NzPnvf
ghdf2iPsk/NkPBYu2iu2dD4kF9Py2ea7TU5kuJ9+RmAJe+PtP98nZLL0DgmR2Y/bWDoXiWXAr7k8
jcAEoFensaFz59Mv7Rcy+bLE0l+2kV4u21chLsaVFJGaabSdBeayd9514KCYib3Ldf6TYktfyqV3
BaA4SygeqgSTj/FO4RJ898729j0Cix9jx+OeV14lnKx6eg5oqR1OE0kbCYzQEeyto5//xf72Pa+1
//J1U/PsXK7LtooNhDNfuQqQ3okAj7RJgATrXK7d+0J98tobvzIzEbtcd71pI71fYVesBoz0DIQH
JMGt3OVS7T3E5K1fm5lcJ+KxcNEmMY+vsivGemnPNXAhRgSQYIPLtS/C5G0TE5HLtcZOeteyK+YD
lj4Z4wFJsM7lulftkhCTt0x9InC5bvzNJrH0nuXA0gHp5bgV+Fm4vasSHGbyhsnirRJ8vU18u5Rd
MAqx9GtgHmkTA23kQNVcLlWCI0yMfTLG7HLd84rNXMidrOXAWFhVghMpn+/E5QpLcLvA4s0ul530
cq8XcLL8rfU5MI9MRDvaAg3s/SGXK9onJouvM/BY91sb6eVjEHCyWu9TKmAiZYoyD3G5uAQfCtt7
2OLfft1Ogu9ctJWQ3oYAMIOtVxS06K4UnV/vZy2Yvu1glMnvXvu9vk/0ErymkZLeKYDGrA5dVw0S
Cadux9Fd0uJfyZpwQ8TeLUx0EkzHt8BE7G0LL6yBlcOnwj8Pl+CbovYeZSLQI1uvl3OFBkFkqRNb
Cy6NPHeJIwlm9m5mspRJ8D2HbbxevpIFmKVvVeRxWMqEFQUgEjyHW/LTUXs3M2Fztp30OnGymltZ
IASNLZZNB0S9WQuxuL2rTN76g8ZkrVx6v6bgv1sr/92qAB4DKhz0dWAWu3ght3eVyS6NSfgnt11w
4E5WA72S1eSdya4eAhAZxs1zZa0TCdbsPcTknV1/5EzUSGP2drH0LuW6Nw2U3giQOVFXb7IB0MNm
PgvcsvsFrU/eefZPjMkYidfL49v7gVf5dY4CUNClL9S4D/iZeKJhwYvc3tvb39WYbP668kAjIb11
zUAIdL+uZXSZSqnuamU9vTAT9hkiWMMl2MBk8znrXraRXj7xI/HtFH3L6Mm9SH+5cinkxTEJPqTr
EpXJn6N98opYebWVrJn0MkGbr0HfMNpIjAWkUyG/ml297sBBPZP3oky2NG4OdYhgcPEgE1nJmm9o
mEKWpZly0cBysm8Vl567NQmOMPkLs/gdT1rnEe5kIfGtd7GxYeRMYq5ZRByg5ezi0ToJ1vfJYzs2
PbfIbCfO4tvlpnZR0VW66XplLu1yeZt5rvdqvb1rfbJj7xMdndtNfeIkieCbZQ6bqVqbIjMRZT69
hKpJ8Ay9BHMmIR5HOrve32sgwte5p7Y5cYUYKgki1jJSZMVMk+DZ7x60Mtn716OdwWMdTxgCEp55
AJTRs8LSLCVdTiTfegeQKNYkeOG9L7W3m5j8TeXRHez6+yadcnHpReYq73prs4gKekEBDeRybWBX
X3zA2CX73/3gqQ8/DHZ3d/d0HtYcFR6nKNc6crJQ2aoRVZICLpdBgt/T83jhg3989JHKozvYu/FV
7jqe7eDhLf46QavkTkq68OtUxOXiud4FL+okOMTj439+EuYRQtczzJl3lkQYJ2qUXH8zhLVyQC5M
GPWaeAQ7jzdGKzf4SpajZScD5N5WpvAeSFi4C6FFvXoe6r+DXdH4Sotvgfytlns1QD6RZImJTKW9
bN37WNRr6o/QPx3BzeEucRLfepaJ2zRUSiRbfBOUwecjIBr1GnlEyHT9S+0SHt8CKzUWJ4uhXErE
7uOpUUhNBbfJmw6E7H3/vy08uruPHnvz1S1LudeLxLfzbJokn9ptv6uoR1SSN/CW3ftCPP5j4RHq
kvf3ak4WsppZuzImIvbfeiLzFl94XHBop5BHd/eRjsPnsKuAIjkb6VUhD63ybIkg5QiaBE/f9l8h
j1CXbOSWjhTJTVESTQSKevkCmnLvx0Ie3d2933Dw07RpafDEEQGi3mbN5Xrwk6MiHsHOh7iThfgL
y5QYici+h3YW9X7zRI+ASE/nt9gFSBKhaXGsRPIlRKCyNi6WM44fEXRI77fZ30OWvlzSGrlqST/C
BQoNdS7XmhNWJkc6v8P+GrB0lkSIhYj8O8M5TlZtFj5kIRLsup797Vi6HFa3ziTCICmRItmtSDGu
Lte77vgxE4+OHq1Sw0nYKcRAKRHiEyRnK5sP95jsvetM9ldIOsy3XtoWufdbIv8Utw6xUL7WPNrY
JcHOjfxBs2KMb3WQp0hKB8nvHkevDekCU6ME9/Ry6QWU3CuMb3UgPvOhvrlH/FXucs3Q23uw9xHe
sUgSYTLREmLLjiHE7YAE61yu2bouOdbBV7Lill4VxCfJ5PYBSPW9tnC+8Ygmvd9lfwiIHyG9Koic
VRV1P5JF1qLei5m9BztOcK93DrAIu4JqRwXx4X4m9QDI+Z7Gr36ESXDX99gfAUkEXf7WDvL5UJ+c
tgPkJOkk+EhUetmYh6Lm1VQr6K8sB5GPAAJUnXbeHrb3nl7uZAHS20JJbwinUETyyEc4W629U5Xg
YO8D7P+RIjlD/tYG5D4K2fQz0PqwCO46cSx49Ci3dKBIThLfaiihiNDWjlXsabLzcGhgOYpvAw10
E8opHmlpwJfSi2kJ9nvq2dUPHu89wQNHZFjOp1ug5NFEkE+MoapWPjU/0uEsvh0LtAAoPUO2BoKS
AVqu9/vsv+qQnNE8oAFKBk0E2hWh3gNUfk+13AY5WSuB9wMmYs20C4G4XJeab0JK2VrnIq+Hapih
LfKAmLupxRziIR+BzUHejlQ56SvPZACiXnPQ3QDEty2kk6UCrMWGtqqo89OfaZt88Vjzt1YA4qsC
8FIUyOUyGi5QJIc4WSrAXeqwsQVVOOuWCoEulCQRjCML3aMO2walwUfnzXSLt46L5OwBb/IE7gfm
6CMTIL5tRpwsFfBeaCXY86DcMmubs1hfiqEoD1bmTwJwudhoqQdWX2RJBD2QMmxn5g6Vvo2Dr1yO
vRU2dRXl2DM3AN/1hhXVWZJIDkc7CaHbGIK5XqBITveJJgFn++eDG1EhEuxdnEjpRWd1hmzwsYgf
OALJq/hA6aVKAGPtEsQz99cCA2sZ9j6nHYJbCVBwBSwAwdLruEPA+AqLemmg0otFVEYUgY+upyWY
BJ1EiIJauhYC3Wf5vviZ0EmEKBzt4caA7mm4nv5mguJBJhGioJbgbYBuiguU20hB5W81xLoFMGjv
a4GUoAzW5RYbxLzZLLIOrAIIYiUg87cM+OffFmDRu6IAu9tIOgRIIoQRz9bl4OACcr220L7JTNbA
UoHuKrsiZgluDtRjr4BWSe0B7nYPfDthA1h6gWVrKcCNTAFHXQggfxuBg/hWjBrMTOqA3W1E0Oo9
5EjANt/gBB+bBKPSm5BtmEHvEahcEnQIJr2ViTmfIBt6GVBLZgEa38Zr6AzYoULAwqMJUP5Wgdes
AUBnEQDlNuaBhS0txuS72wBaegQyhEZLx+LbxJ6wgERZlwM5W0OHQPlbsugkCUzGO3JUsKXFRPPA
RheQ+9EAOVnJOLmDKndU1IVH3OWC8rfJOTQtn34xHvVCTlayDn3MJt+8to1OFEY7hM7fVg5PEo+0
tGHkQiqQPwgDyN+emsxjazPI422ACudwh5D526Qca6MDFcYvgaJe2smKO/4gQa1uA1lP2skamIrT
BDPkc+NMpLyOcLKSPawY5KcI0i4X4WSl7hzBtGLZNE9XOMuTCLkpPQQ1WyLEVLmN1MkanOoTqdMl
0RbhcrVusL2zwo2DXIttwy15hVnA/iOwHJeObh9mZyqyqLfJ9vvb3ESF5jGgSExFFvXa5W/dpKEi
U+jd20uwWHor80vcpaEiI9+6GLnSbxf1iqR3cKFrx04bUZNlsXs7CbZKb2VuqgVXivTCauPMYlNu
Y8rfVuYVuXqcuZhL1RDdGBN/X2FIIgzN6YMsIqjJLKhmZETlNlr+dlBeocsqRZMprsoZXC4ut1Hj
24qhZQVZfZ2EhvRhVdatVLz+H2RlujR5x4EBllyv/HygvgvzSSbU+UB9F8aTTNoCt8X/SHdwoeE7
P89qt9sTO/SJRa/3ZrebEzuKdRLsqXe7NfHgDt4l2PlAfRbaYVInq/QysPP8Tl7pZRjZGnGyTlrp
ZZgUrsKmj2br+1D3+HRyPlCfhXqYlJPzgfouJnh8q04+l1eEWz1XxP+QvoCJ4NFsfR5Xnhb/M/rR
j370I4L/AYWr1zfavhjTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIyLTA0LTA0VDE1OjAzOjMwKzA4
OjAwP7kofQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMi0wNC0wNFQxNTowMzozMCswODowME7kkMEA
AAAgdEVYdHNvZnR3YXJlAGh0dHBzOi8vaW1hZ2VtYWdpY2sub3JnvM8dnQAAABh0RVh0VGh1bWI6
OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpIZWlnaHQAMjAwfdcV
aQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAyMDDuJkU0AAAAGXRFWHRUaHVtYjo6TWltZXR5
cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2NDkwNTU4MTD76yvxAAAAEnRF
WHRUaHVtYjo6U2l6ZQA0NTEzQkLz/Q6yAAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBwL3Rt
cC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTY0Nzg0ODMyNDI4NTI2NzhfODZfWzBdhSsH1AAAAABJRU5E
rkJggg==" ></image>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1652027514472" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1635" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff2?t=1630033759944") format("woff2"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff?t=1630033759944") format("woff"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.ttf?t=1630033759944") format("truetype"); }
</style></defs><path d="M810.666667 213.333333a42.666667 42.666667 0 0 1 42.666666 42.666667v512a42.666667 42.666667 0 0 1-42.666666 42.666667H213.333333a42.666667 42.666667 0 0 1-42.666666-42.666667V256a42.666667 42.666667 0 0 1 42.666666-42.666667h597.333334z m0 42.666667H213.333333v512h597.333334V256z m-106.666667 362.666667a21.333333 21.333333 0 1 1 0 42.666666h-170.666667a21.333333 21.333333 0 1 1 0-42.666666h170.666667zM304.896 376.064a21.333333 21.333333 0 0 1 30.208 0l120.661333 120.661333a21.333333 21.333333 0 0 1 0 30.165334l-120.661333 120.704a21.333333 21.333333 0 1 1-30.208-30.208l105.6-105.557334L304.896 406.186667a21.333333 21.333333 0 0 1 0-30.165334z" fill="#000000" p-id="1636"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
web/src/assets/qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,85 @@
<template>
<div>
<el-row :gutter="10">
<el-col :span="10" class="ba-array-key">{{ state.keyTitle }}</el-col>
<el-col :span="10" class="ba-array-value">{{ state.valueTitle }}</el-col>
</el-row>
<el-row class="ba-array-item" v-for="(item, idx) in state.value" :gutter="10" :key="idx">
<el-col :span="10">
<el-input v-model="item.key"></el-input>
</el-col>
<el-col :span="10">
<el-input v-model="item.value"></el-input>
</el-col>
<el-col :span="4">
<el-button @click="onDelArrayItem(idx)" size="small" icon="el-icon-Delete" circle />
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="10" :offset="10">
<el-button v-blur class="ba-add-array-item" @click="onAddArrayItem" icon="el-icon-Plus">{{ t('Add') }}</el-button>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
type baInputArray = { key: string; value: string }
interface Props {
modelValue: baInputArray[]
keyTitle?: string
valueTitle?: string
}
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
keyTitle: '',
valueTitle: '',
})
const state = reactive({
value: props.modelValue,
keyTitle: props.keyTitle ? props.keyTitle : t('utils.ArrayKey'),
valueTitle: props.valueTitle ? props.valueTitle : t('utils.ArrayValue'),
})
const onAddArrayItem = () => {
state.value.push({
key: '',
value: '',
})
}
const onDelArrayItem = (idx: number) => {
state.value.splice(idx, 1)
}
watch(
() => props.modelValue,
(newVal) => {
state.value = newVal
}
)
</script>
<style scoped lang="scss">
.ba-array-key,
.ba-array-value {
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
color: var(--el-text-color-secondary);
}
.ba-array-item {
margin-bottom: 6px;
}
.ba-add-array-item {
float: right;
}
</style>

View File

@@ -0,0 +1,518 @@
<template>
<div class="w100">
<el-upload
ref="upload"
class="ba-upload"
:class="[
type,
state.attrs.disabled ? 'is-disabled' : '',
hideImagePlusOnOverLimit && state.attrs.limit && state.fileList.length >= state.attrs.limit ? 'hide-image-plus' : '',
]"
v-model:file-list="state.fileList"
:auto-upload="false"
@change="onElChange"
@remove="onElRemove"
@preview="onElPreview"
@exceed="onElExceed"
v-bind="state.attrs"
:key="state.key"
>
<template v-if="!$slots.default" #default>
<template v-if="type == 'image' || type == 'images'">
<div v-if="!hideSelectFile" @click.stop="showSelectFile()" class="ba-upload-select-image">
{{ $t('utils.choice') }}
</div>
<Icon class="ba-upload-icon" name="el-icon-Plus" size="30" color="#c0c4cc" />
</template>
<template v-else>
<el-button v-blur type="primary">
<Icon name="el-icon-Plus" color="#ffffff" />
<span>{{ $t('Upload') }}</span>
</el-button>
<el-button v-blur v-if="!hideSelectFile" @click.stop="showSelectFile()" type="success">
<Icon name="fa fa-th-list" size="14px" color="#ffffff" />
<span class="ml-6">{{ $t('utils.choice') }}</span>
</el-button>
</template>
</template>
<template v-for="(slot, name) in $slots" #[name]="scopedData">
<slot :name="name" v-bind="scopedData"></slot>
</template>
</el-upload>
<el-dialog v-model="state.preview.show" :append-to-body="true" :destroy-on-close="true" class="ba-upload-preview">
<div class="ba-upload-preview-scroll ba-scroll-style">
<img :src="state.preview.url" class="ba-upload-preview-img" alt="" />
</div>
</el-dialog>
<SelectFile v-model="state.selectFile.show" v-bind="state.selectFile" @choice="onChoice" />
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, watch, useAttrs, nextTick, useTemplateRef } from 'vue'
import { genFileId } from 'element-plus'
import type { UploadUserFile, UploadProps, UploadRawFile, UploadFiles } from 'element-plus'
import { stringToArray } from '/@/components/baInput/helper'
import { fullUrl, arrayFullUrl, getFileNameFromPath, getArrayKey } from '/@/utils/common'
import { fileUpload } from '/@/api/common'
import SelectFile from '/@/components/baInput/components/selectFile.vue'
import { uuid } from '/@/utils/random'
import { cloneDeep, isEmpty } from 'lodash-es'
import type { AxiosProgressEvent } from 'axios'
import Sortable from 'sortablejs'
// 禁用 Attributes 自动继承
defineOptions({
inheritAttrs: false,
})
interface Props extends /* @vue-ignore */ Partial<UploadProps> {
type: 'image' | 'images' | 'file' | 'files'
// 上传请求时的额外携带数据
data?: anyObj
modelValue: string | string[]
// 返回绝对路径
returnFullUrl?: boolean
// 隐藏附件选择器
hideSelectFile?: boolean
// 可自定义 el-upload 的其他属性已废弃v2.2.0 删除,请直接传递为 props
attr?: Partial<Writeable<UploadProps>>
// 强制上传到本地存储
forceLocal?: boolean
// 在上传数量达到限制时隐藏图片上传按钮
hideImagePlusOnOverLimit?: boolean
}
interface UploadFileExt extends UploadUserFile {
serverUrl?: string
}
interface UploadProgressEvent extends AxiosProgressEvent {
percent: number
}
const props = withDefaults(defineProps<Props>(), {
type: 'image',
data: () => {
return {}
},
modelValue: () => [],
returnFullUrl: false,
hideSelectFile: false,
attr: () => {
return {}
},
forceLocal: false,
hideImagePlusOnOverLimit: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const attrs = useAttrs()
const upload = useTemplateRef('upload')
const state: {
key: string
// 返回值类型通过v-model类型动态计算
defaultReturnType: 'string' | 'array'
// 预览弹窗
preview: {
show: boolean
url: string
}
// 文件列表
fileList: UploadFileExt[]
// 绑定到 el-upload 的属性对象
attrs: Partial<UploadProps>
// 正在上传的文件数量
uploading: number
// 显示选择文件窗口
selectFile: {
show: boolean
type?: 'image' | 'file'
limit?: number
returnFullUrl: boolean
}
events: anyObj
} = reactive({
key: uuid(),
defaultReturnType: 'string',
preview: {
show: false,
url: '',
},
fileList: [],
attrs: {},
uploading: 0,
selectFile: {
show: false,
type: 'file',
returnFullUrl: props.returnFullUrl,
},
events: {},
})
/**
* 需要管理的事件列表(使用 triggerEvent 触发)
*/
const eventNameMap = {
// el-upload 的钩子函数(它们是 props并不是 emit以上已经使用所以需要手动触发
change: ['onChange', 'on-change'],
remove: ['onRemove', 'on-remove'],
preview: ['onPreview', 'on-preview'],
exceed: ['onExceed', 'on-exceed'],
// 由于自定义了上传方法,需要手动触发的钩子
beforeUpload: ['beforeUpload', 'onBeforeUpload', 'before-upload', 'on-before-upload'],
progress: ['onProgress', 'on-progress'],
success: ['onSuccess', 'on-success'],
error: ['onError', 'on-error'],
}
const onElChange = (file: UploadFileExt, files: UploadFiles) => {
// 将 file 换为 files 中的对象,以便修改属性等操作
const fileIndex = getArrayKey(files, 'uid', file.uid!)
if (fileIndex === false) return
file = files[fileIndex] as UploadFileExt
if (!file || !file.raw) return
if (triggerEvent('beforeUpload', [file]) === false) return
let fd = new FormData()
fd.append('file', file.raw)
fd = formDataAppend(fd)
file.status = 'uploading'
state.uploading++
fileUpload(fd, { uuid: uuid() }, props.forceLocal, {
onUploadProgress: (evt: AxiosProgressEvent) => {
const progressEvt = evt as UploadProgressEvent
if (evt.total && evt.total > 0 && ['ready', 'uploading'].includes(file.status!)) {
progressEvt.percent = (evt.loaded / evt.total) * 100
file.status = 'uploading'
file.percentage = Math.round(progressEvt.percent)
triggerEvent('progress', [progressEvt, file, files])
}
},
})
.then((res) => {
if (res.code == 1) {
file.serverUrl = res.data.file.url
file.status = 'success'
emits('update:modelValue', getAllUrls())
triggerEvent('success', [res, file, files])
} else {
file.status = 'fail'
files.splice(fileIndex, 1)
triggerEvent('error', [res, file, files])
}
})
.catch((res) => {
file.status = 'fail'
files.splice(fileIndex, 1)
triggerEvent('error', [res, file, files])
})
.finally(() => {
state.uploading--
onChange(file, files)
})
}
const onElRemove = (file: UploadUserFile, files: UploadFiles) => {
triggerEvent('remove', [file, files])
onChange(file, files)
nextTick(() => {
emits('update:modelValue', getAllUrls())
})
}
const onElPreview = (file: UploadFileExt) => {
triggerEvent('preview', [file])
if (!file || !file.serverUrl) {
return
}
if (props.type == 'file' || props.type == 'files') {
window.open(fullUrl(file.serverUrl))
return
}
state.preview.show = true
state.preview.url = fullUrl(file.serverUrl)
}
const onElExceed = (files: UploadUserFile[]) => {
const file = files[0] as UploadRawFile
file.uid = genFileId()
upload.value!.handleStart(file)
triggerEvent('exceed', [file, state.fileList])
}
const onChoice = (files: string[]) => {
let oldValArr = getAllUrls('array') as string[]
files = oldValArr.concat(files)
init(files)
emits('update:modelValue', getAllUrls())
onChange(files, state.fileList)
state.selectFile.show = false
}
/**
* 初始化文件/图片的排序功能
*/
const initSort = () => {
if (state.attrs.showFileList === false) {
return false
}
nextTick(() => {
let uploadListEl = upload.value?.$el.querySelector('.el-upload-list')
let uploadItemEl = uploadListEl.getElementsByClassName('el-upload-list__item')
if (uploadItemEl.length >= 2) {
Sortable.create(uploadListEl, {
animation: 200,
draggable: '.el-upload-list__item',
onEnd: (evt: Sortable.SortableEvent) => {
if (evt.oldIndex != evt.newIndex) {
state.fileList[evt.newIndex!] = [
state.fileList[evt.oldIndex!],
(state.fileList[evt.oldIndex!] = state.fileList[evt.newIndex!]),
][0]
emits('update:modelValue', getAllUrls())
}
},
})
}
})
}
const triggerEvent = (name: string, args: any[]) => {
const events = eventNameMap[name as keyof typeof eventNameMap]
if (events) {
for (const key in events) {
// 执行函数,只在 false 时 return
if (typeof state.events[events[key]] === 'function' && state.events[events[key]](...args) === false) return false
}
}
}
onMounted(() => {
// 即将废弃的 props.attr Start
const addProps: anyObj = {}
if (!isEmpty(props.attr)) {
const evtArr = ['onPreview', 'onRemove', 'onSuccess', 'onError', 'onChange', 'onExceed', 'beforeUpload', 'onProgress']
for (const key in props.attr) {
if (evtArr.includes(key)) {
state.events[key] = props.attr[key as keyof typeof props.attr]
} else {
addProps[key] = props.attr[key as keyof typeof props.attr]
}
}
console.warn('图片/文件上传组件的 props.attr 已经弃用,并将于 v2.2.0 版本彻底删除,请将 props.attr 的部分直接作为 props 传递!')
}
// 即将废弃的 props.attr End
let events: string[] = []
let bindAttrs: anyObj = {}
for (const key in eventNameMap) {
events = [...events, ...eventNameMap[key as keyof typeof eventNameMap]]
}
for (const attrKey in attrs) {
if (events.includes(attrKey)) {
state.events[attrKey] = attrs[attrKey]
} else {
bindAttrs[attrKey] = attrs[attrKey]
}
}
if (props.type == 'image' || props.type == 'file') {
bindAttrs = { ...bindAttrs, limit: 1 }
} else {
bindAttrs = { ...bindAttrs, multiple: true }
}
if (props.type == 'image' || props.type == 'images') {
state.selectFile.type = 'image'
bindAttrs = { ...bindAttrs, accept: 'image/*', listType: 'picture-card' }
}
state.attrs = { ...bindAttrs, ...addProps }
// 设置附件选择器的 limit
if (state.attrs.limit) {
state.selectFile.limit = state.attrs.limit
}
init(props.modelValue)
initSort()
})
const limitExceed = () => {
if (state.attrs.limit && state.fileList.length > state.attrs.limit) {
state.fileList = state.fileList.slice(state.fileList.length - state.attrs.limit)
return true
}
return false
}
const init = (modelValue: string | string[]) => {
let urls = stringToArray(modelValue as string)
state.fileList = []
state.defaultReturnType = typeof modelValue === 'string' || props.type == 'file' || props.type == 'image' ? 'string' : 'array'
for (const key in urls) {
state.fileList.push({
name: getFileNameFromPath(urls[key]),
url: fullUrl(urls[key]),
serverUrl: urls[key],
})
}
// 超出过滤 || 确定返回的URL完整
if (limitExceed() || props.returnFullUrl) {
emits('update:modelValue', getAllUrls())
}
state.key = uuid()
}
/**
* 获取当前所有图片路径的列表
*/
const getAllUrls = (returnType: string = state.defaultReturnType) => {
limitExceed()
let urlList = []
for (const key in state.fileList) {
if (state.fileList[key].serverUrl) urlList.push(state.fileList[key].serverUrl)
}
if (props.returnFullUrl) urlList = arrayFullUrl(urlList as string[])
return returnType === 'string' ? urlList.join(',') : (urlList as string[])
}
const formDataAppend = (fd: FormData) => {
if (props.data && !isEmpty(props.data)) {
for (const key in props.data) {
fd.append(key, props.data[key])
}
}
return fd
}
/**
* 文件状态改变时的钩子,选择文件、上传成功和上传失败时都会被调用
*/
const onChange = (file: string | string[] | UploadFileExt, files: UploadFileExt[]) => {
initSort()
triggerEvent('change', [file, files])
}
const getRef = () => {
return upload.value
}
const showSelectFile = () => {
if (state.attrs.disabled) return
state.selectFile.show = true
}
defineExpose({
getRef,
showSelectFile,
})
watch(
() => props.modelValue,
(newVal) => {
if (state.uploading > 0) return
if (newVal === undefined || newVal === null) {
return init('')
}
let newValArr = arrayFullUrl(stringToArray(cloneDeep(newVal)))
let oldValArr = arrayFullUrl(getAllUrls('array'))
if (newValArr.sort().toString() != oldValArr.sort().toString()) {
init(newVal)
}
}
)
</script>
<style scoped lang="scss">
.ba-upload-select-image {
position: absolute;
top: 0px;
border: 1px dashed var(--el-border-color);
border-top: 1px dashed transparent;
width: var(--el-upload-picture-card-size);
height: 30px;
line-height: 30px;
border-radius: 6px;
border-bottom-right-radius: 20px;
border-bottom-left-radius: 20px;
text-align: center;
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-regular);
user-select: none;
&:hover {
color: var(--el-color-primary);
border: 1px dashed var(--el-color-primary);
border-top: 1px dashed var(--el-color-primary);
}
}
.ba-upload :deep(.el-upload:hover .ba-upload-icon) {
color: var(--el-color-primary) !important;
}
:deep(.ba-upload-preview) .el-dialog__body {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
height: auto;
}
.ba-upload-preview-scroll {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
height: auto;
overflow: auto;
max-height: 70vh;
}
.ba-upload-preview-img {
max-width: 100%;
max-height: 100%;
}
:deep(.el-dialog__headerbtn) {
top: 2px;
width: 37px;
height: 37px;
}
.ba-upload.image :deep(.el-upload--picture-card),
.ba-upload.images :deep(.el-upload--picture-card) {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ba-upload.file :deep(.el-upload-list),
.ba-upload.files :deep(.el-upload-list) {
margin-left: -10px;
}
.ba-upload.files,
.ba-upload.images {
:deep(.el-upload-list__item) {
user-select: none;
.el-upload-list__item-actions,
.el-upload-list__item-name {
cursor: move;
}
}
}
.ml-6 {
margin-left: 6px;
}
.ba-upload.hide-image-plus :deep(.el-upload--picture-card) {
display: none;
}
.ba-upload.is-disabled :deep(.el-upload),
.ba-upload.is-disabled :deep(.el-upload) .el-button,
.ba-upload.is-disabled :deep(.el-upload--picture-card) {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,39 @@
<!-- 多编辑器共存支持 -->
<!-- 所有编辑器的代码位于 /@/components/mixins/editor 文件夹一个文件为一种编辑器文件名则为编辑器名称 -->
<!-- 向本组件传递 editorType文件名/编辑器名称自动加载对应的编辑器进行渲染 -->
<template>
<div>
<component v-bind="$attrs" :is="mixins[state.editorType]" />
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { Component } from 'vue'
interface Props {
editorType?: string
}
const props = withDefaults(defineProps<Props>(), {
editorType: 'default',
})
const state = reactive({
editorType: props.editorType,
})
const mixins: Record<string, Component> = {}
const mixinComponents: Record<string, any> = import.meta.glob('../../mixins/editor/**.vue', { eager: true })
for (const key in mixinComponents) {
const fileName = key.replace('../../mixins/editor/', '').replace('.vue', '')
mixins[fileName] = mixinComponents[key].default
// 未安装富文本编辑器时,值为 default安装之后则值为最后一个编辑器的名称
if (props.editorType == 'default' && fileName != 'default') {
state.editorType = fileName
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,286 @@
<template>
<el-popover :placement="placement" trigger="focus" :hide-after="0" :width="state.selectorWidth" :visible="state.popoverVisible">
<div @mouseover.stop="state.iconSelectorMouseover = true" @mouseout.stop="state.iconSelectorMouseover = false" class="icon-selector">
<div class="icon-selector-box">
<div class="selector-header">
<div class="selector-title">{{ title ? title : $t('utils.Please select an icon') }}</div>
<div class="selector-tab">
<span
:title="'Element Puls ' + $t('utils.Icon')"
@click="onChangeTab('ele')"
:class="state.iconType == 'ele' ? 'active' : ''"
>
ele
</span>
<span
:title="'Font Awesome ' + $t('utils.Icon')"
@click="onChangeTab('awe')"
:class="state.iconType == 'awe' ? 'active' : ''"
>
awe
</span>
<span :title="$t('utils.Ali iconcont Icon')" @click="onChangeTab('ali')" :class="state.iconType == 'ali' ? 'active' : ''">
ali
</span>
<span :title="$t('utils.Local icon title')" @click="onChangeTab('local')" :class="state.iconType == 'local' ? 'active' : ''">
local
</span>
</div>
</div>
<el-scrollbar class="selector-body">
<div v-if="renderFontIconNames.length > 0">
<div class="icon-selector-item" :title="item" @click="onIcon(item)" v-for="(item, key) in renderFontIconNames" :key="key">
<Icon :name="item" />
</div>
</div>
</el-scrollbar>
</div>
</div>
<template #reference>
<el-input
v-model="state.inputValue"
:size="size"
:disabled="disabled"
:placeholder="$t('Search') + $t('utils.Icon')"
ref="selectorInput"
@focus="onInputFocus"
@blur="onInputBlur"
:class="'size-' + size"
>
<template #prepend>
<div class="icon-prepend">
<Icon :key="'icon' + state.iconKey" :name="state.prependIcon ? state.prependIcon : state.defaultModelValue" />
<div v-if="showIconName" class="name">{{ state.prependIcon ? state.prependIcon : state.defaultModelValue }}</div>
</div>
</template>
<template #append>
<Icon @click="onInputRefresh" name="el-icon-RefreshRight" />
</template>
</el-input>
</template>
</el-popover>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import type { Placement } from 'element-plus'
import { computed, nextTick, onMounted, reactive, useTemplateRef, watch } from 'vue'
import { getAwesomeIconfontNames, getElementPlusIconfontNames, getIconfontNames, getLocalIconfontNames } from '/@/utils/iconfont'
type IconType = 'ele' | 'awe' | 'ali' | 'local'
interface Props {
size?: 'default' | 'small' | 'large'
disabled?: boolean
title?: string
type?: IconType
placement?: Placement
modelValue?: string
showIconName?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'default',
disabled: false,
title: '',
type: 'ele',
placement: 'bottom',
modelValue: '',
showIconName: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
}>()
const selectorInput = useTemplateRef('selectorInput')
const state: {
iconType: IconType
selectorWidth: number
popoverVisible: boolean
inputFocus: boolean
iconSelectorMouseover: boolean
fontIconNames: string[]
inputValue: string
prependIcon: string
defaultModelValue: string
iconKey: number
} = reactive({
iconType: props.type,
selectorWidth: 0,
popoverVisible: false,
inputFocus: false,
iconSelectorMouseover: false,
fontIconNames: [],
inputValue: '',
prependIcon: props.modelValue,
defaultModelValue: props.modelValue || 'fa fa-circle-o',
iconKey: 0, // 给icon标签准备个key以随时使用 h 函数重新生成元素
})
const onInputFocus = () => {
state.inputFocus = state.popoverVisible = true
}
const onInputBlur = () => {
state.inputFocus = false
state.popoverVisible = state.iconSelectorMouseover
}
const onInputRefresh = () => {
state.iconKey++
state.prependIcon = state.defaultModelValue
state.inputValue = ''
emits('update:modelValue', state.defaultModelValue)
emits('change', state.defaultModelValue)
}
const onChangeTab = (name: IconType) => {
state.iconType = name
state.fontIconNames = []
if (name == 'ele') {
getElementPlusIconfontNames().then((res) => {
state.fontIconNames = res
})
} else if (name == 'awe') {
getAwesomeIconfontNames().then((res) => {
state.fontIconNames = res.map((name) => `fa ${name}`)
})
} else if (name == 'ali') {
getIconfontNames().then((res) => {
state.fontIconNames = res.map((name) => `iconfont ${name}`)
})
} else if (name == 'local') {
getLocalIconfontNames().then((res) => {
state.fontIconNames = res
})
}
}
const onIcon = (icon: string) => {
state.iconSelectorMouseover = state.popoverVisible = false
state.iconKey++
state.prependIcon = icon
state.inputValue = ''
emits('update:modelValue', icon)
emits('change', icon)
nextTick(() => {
selectorInput.value?.blur()
})
}
const renderFontIconNames = computed(() => {
if (!state.inputValue) return state.fontIconNames
let inputValue = state.inputValue.trim().toLowerCase()
return state.fontIconNames.filter((icon: string) => {
if (icon.toLowerCase().indexOf(inputValue) !== -1) {
return icon
}
})
})
// 获取 input 的宽度
const getInputWidth = () => {
nextTick(() => {
state.selectorWidth = selectorInput.value?.$el.offsetWidth < 260 ? 260 : selectorInput.value?.$el.offsetWidth
})
}
const popoverVisible = () => {
state.popoverVisible = state.inputFocus || state.iconSelectorMouseover ? true : false
}
watch(
() => props.modelValue,
() => {
state.iconKey++
if (props.modelValue != state.prependIcon) state.defaultModelValue = props.modelValue
if (props.modelValue == '') state.defaultModelValue = 'fa fa-circle-o'
state.prependIcon = props.modelValue
}
)
/**
* 1. 图标选择面板一旦显示就监听 document 的点击事件
* 2. 点击后输入框和面板会失去焦点,面板将自动隐藏
* 3. 面板隐藏后删除点击事件监听
*/
let removeClickHidePopoverListenerFn = () => {}
watch(
() => state.popoverVisible,
() => {
if (state.popoverVisible) {
removeClickHidePopoverListenerFn = useEventListener(document, 'click', popoverVisible)
} else {
removeClickHidePopoverListenerFn()
}
}
)
onMounted(() => {
getInputWidth()
getElementPlusIconfontNames().then((res) => {
state.fontIconNames = res
})
})
</script>
<style scoped lang="scss">
.size-small {
height: 24px;
}
.size-large {
height: 40px;
}
.size-default {
height: 32px;
}
.icon-prepend {
display: flex;
align-items: center;
justify-content: center;
.name {
padding-left: 5px;
}
}
.selector-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.selector-tab {
margin-left: auto;
span {
padding: 0 5px;
cursor: pointer;
user-select: none;
&.active,
&:hover {
color: var(--el-color-primary);
text-decoration: underline;
}
}
}
.selector-body {
height: 250px;
}
.icon-selector-item {
display: inline-block;
padding: 10px 10px 6px 10px;
margin: 3px;
border: 1px solid var(--ba-border-color);
border-radius: var(--el-border-radius-base);
cursor: pointer;
font-size: 18px;
.icon {
height: 18px;
width: 18px;
}
&:hover {
border: 1px solid var(--el-color-primary);
}
}
:deep(.el-input-group__prepend) {
padding: 0 10px;
}
:deep(.el-input-group__append) {
padding: 0 10px;
}
</style>

View File

@@ -0,0 +1,352 @@
<template>
<div class="w100">
<!-- el-select 的远程下拉只在有搜索词时才会加载数据显示出 option 列表 -->
<!-- 使用 el-popover 在无数据/无搜索词时显示一个无数据的提醒 -->
<el-popover
width="100%"
placement="bottom"
popper-class="remote-select-popper"
:visible="state.focusStatus && !state.loading && !state.keyword && !state.options.length"
:teleported="false"
:content="$t('utils.No data')"
:hide-after="0"
>
<template #reference>
<el-select
ref="selectRef"
class="w100"
remote
clearable
filterable
automatic-dropdown
remote-show-suffix
v-model="state.value"
:loading="state.loading"
:disabled="props.disabled || !state.initializeFlag"
@blur="onBlur"
@focus="onFocus"
@clear="onClear"
@change="onChangeSelect"
@keydown.esc.capture="onKeyDownEsc"
:remote-method="onRemoteMethod"
v-bind="$attrs"
>
<el-option
class="remote-select-option"
v-for="item in state.options"
:label="item[field]"
:value="item[state.primaryKey].toString()"
:key="item[state.primaryKey]"
>
<el-tooltip placement="right" effect="light" v-if="!isEmpty(tooltipParams)">
<template #content>
<p v-for="(tooltipParam, key) in tooltipParams" :key="key">{{ key }}: {{ item[tooltipParam] }}</p>
</template>
<div>{{ item[field] }}</div>
</el-tooltip>
</el-option>
<template v-if="state.total && props.pagination" #footer>
<el-pagination class="select-pagination" @current-change="onSelectCurrentPageChange" v-bind="getPaginationAttr()" />
</template>
</el-select>
</template>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import type { ElSelect, PaginationProps } from 'element-plus'
import { debounce, isEmpty } from 'lodash-es'
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
import { InputAttr } from '../index'
import { getSelectData } from '/@/api/common'
import { useConfig } from '/@/stores/config'
import { getArrayKey } from '/@/utils/common'
import { shortUuid } from '/@/utils/random'
const attrs = useAttrs()
const config = useConfig()
const selectRef = useTemplateRef('selectRef')
type ElSelectProps = Omit<Partial<InstanceType<typeof ElSelect>['$props']>, 'modelValue'>
type valueTypes = string | number | string[] | number[]
interface Props extends /* @vue-ignore */ ElSelectProps {
pk?: string
field?: string
params?: anyObj
remoteUrl: string
modelValue: valueTypes | null
pagination?: boolean | PaginationProps
tooltipParams?: anyObj
labelFormatter?: (optionData: anyObj, optionKey: string) => string
// 按下 ESC 键时直接使下拉框脱焦(默认是清理搜索词或关闭下拉面板,并且不会脱焦,造成 dialog 的按下 ESC 关闭失效)
escBlur?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pk: 'id',
field: 'name',
params: () => {
return {}
},
remoteUrl: '',
modelValue: '',
tooltipParams: () => {
return {}
},
pagination: true,
disabled: false,
escBlur: true,
})
/**
* 点击清空按钮后的值,同时也是缺省值‌
*/
const valueOnClear = computed(() => {
let valueOnClear = attrs.valueOnClear as InputAttr['valueOnClear']
if (valueOnClear === undefined) {
valueOnClear = attrs.multiple ? () => [] : () => null
}
return typeof valueOnClear == 'function' ? valueOnClear() : valueOnClear
})
/**
* 被认为是空值的值列表
*/
const emptyValues = computed(() => (attrs.emptyValues as InputAttr['emptyValues']) || [null, undefined, ''])
const state: {
// 主表字段名(不带表别名)
primaryKey: string
options: anyObj[]
loading: boolean
total: number
currentPage: number
pageSize: number
params: anyObj
keyword: string
value: valueTypes
initializeFlag: boolean
optionValidityFlag: boolean
focusStatus: boolean
} = reactive({
primaryKey: props.pk,
options: [],
loading: false,
total: 0,
currentPage: props.params.page || 1,
pageSize: props.params.limit || 10,
params: props.params,
keyword: '',
value: valueOnClear.value,
initializeFlag: false,
optionValidityFlag: false,
focusStatus: false,
})
let io: IntersectionObserver | null = null
const instance = getCurrentInstance()
const emits = defineEmits<{
(e: 'update:modelValue', value: valueTypes): void
(e: 'row', value: any): void
}>()
/**
* 获取分页组件属性
*/
const getPaginationAttr = (): Partial<PaginationProps> => {
const defaultPaginationAttr: Partial<PaginationProps> = {
pagerCount: 5,
total: state.total,
pageSize: state.pageSize,
currentPage: state.currentPage,
layout: 'total, ->, prev, pager, next',
size: config.layout.shrink ? 'small' : 'default',
}
if (typeof props.pagination === 'boolean') {
return defaultPaginationAttr
}
return { ...defaultPaginationAttr, ...props.pagination }
}
const onChangeSelect = (val: valueTypes) => {
val = updateValue(val)
if (typeof instance?.vnode.props?.onRow == 'function') {
if (typeof val == 'number' || typeof val == 'string') {
const dataKey = getArrayKey(state.options, state.primaryKey, '' + val)
emits('row', dataKey !== false ? toRaw(state.options[dataKey]) : {})
} else {
const valueArr = []
for (const key in val) {
const dataKey = getArrayKey(state.options, state.primaryKey, '' + val[key])
if (dataKey !== false) {
valueArr.push(toRaw(state.options[dataKey]))
}
}
emits('row', valueArr)
}
}
}
const onKeyDownEsc = (e: KeyboardEvent) => {
if (props.escBlur) {
e.stopPropagation()
selectRef.value?.blur()
}
}
const onFocus = () => {
state.focusStatus = true
if (!state.optionValidityFlag) {
getData()
}
}
const onClear = () => {
// 点击清理按钮后,内部 input 呈聚焦状态但选项面板不会展开特此处理element-plus@2.9.1
nextTick(() => {
selectRef.value?.blur()
selectRef.value?.focus()
})
}
const onBlur = () => {
state.keyword = ''
state.focusStatus = false
}
const onRemoteMethod = (q: string) => {
if (state.keyword != q) {
state.keyword = q
state.currentPage = 1
getData()
}
}
const getData = debounce((initValue: valueTypes = '') => {
state.loading = true
state.params.page = state.currentPage
state.params.initKey = props.pk
state.params.initValue = initValue
getSelectData(props.remoteUrl, state.keyword, state.params)
.then((res) => {
let opts = res.data.options ? res.data.options : res.data.list
if (typeof props.labelFormatter === 'function') {
for (const key in opts) {
opts[key][props.field] = props.labelFormatter(opts[key], key)
}
}
state.options = opts
state.total = res.data.total ?? 0
state.optionValidityFlag = state.keyword || (typeof initValue === 'object' ? !isEmpty(initValue) : initValue) ? false : true
})
.finally(() => {
state.loading = false
state.initializeFlag = true
})
}, 100)
const onSelectCurrentPageChange = (val: number) => {
state.currentPage = val
getData()
}
const updateValue = (newVal: any) => {
if (emptyValues.value.includes(newVal)) {
state.value = valueOnClear.value
} else {
state.value = newVal
// number[] 转 string[] 确保默认值能够选中
if (typeof state.value === 'object') {
for (const key in state.value) {
state.value[key] = '' + state.value[key]
}
} else if (typeof state.value === 'number') {
state.value = '' + state.value
}
}
emits('update:modelValue', state.value)
return state.value
}
onMounted(() => {
// 避免两个远程下拉组件共存时,可能带来的重复请求自动取消
state.params.uuid = shortUuid()
// 去除主键中的表名
let pkArr = props.pk.split('.')
state.primaryKey = pkArr[pkArr.length - 1]
// 初始化值
updateValue(props.modelValue)
getData(state.value)
setTimeout(() => {
if (window?.IntersectionObserver) {
io = new IntersectionObserver((entries) => {
for (const key in entries) {
if (!entries[key].isIntersecting) selectRef.value?.blur()
}
})
if (selectRef.value?.$el instanceof Element) {
io.observe(selectRef.value.$el)
}
}
}, 500)
})
onUnmounted(() => {
io?.disconnect()
})
watch(
() => props.modelValue,
(newVal) => {
/**
* 防止 number 到 string 的类型转换触发默认值多次初始化
* 相当于忽略数据类型进行比较 [1, 2] == ['1', '2']
*/
if (getString(state.value) != getString(newVal)) {
updateValue(newVal)
getData(state.value)
}
}
)
const getString = (val: valueTypes | null) => {
// 确保 [] 和 '' 的返回值不一样
return `${typeof val}:${String(val)}`
}
const getRef = () => {
return selectRef.value
}
const focus = () => {
selectRef.value?.focus()
}
const blur = () => {
selectRef.value?.blur()
}
defineExpose({
blur,
focus,
getRef,
})
</script>
<style scoped lang="scss">
:deep(.remote-select-popper) {
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: center;
}
.remote-select-option {
white-space: pre;
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div>
<el-dialog
@close="emits('update:modelValue', false)"
width="60%"
:model-value="modelValue"
class="ba-upload-select-dialog"
:title="t('utils.Select File')"
:append-to-body="true"
:destroy-on-close="true"
top="4vh"
>
<TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('utils.Original name') })"
>
<el-tooltip :content="t('utils.choice')" placement="top">
<el-button
@click="onChoice"
:disabled="baTable.table.selection!.length > 0 ? false : true"
v-blur
class="table-header-operate"
type="primary"
>
<Icon name="fa fa-check" />
<span class="table-header-operate-text">{{ t('utils.choice') }}</span>
</el-button>
</el-tooltip>
<div class="ml-10" v-if="limit !== 0">
{{ t('utils.You can also select') }}
<span class="selection-count">{{ limit - baTable.table.selection!.length }}</span>
{{ t('utils.items') }}
</div>
</TableHeader>
<Table ref="tableRef" @selection-change="onSelectionChange" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, provide, watch, nextTick, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import baTableClass from '/@/utils/baTable'
import { previewRenderFormatter } from '/@/views/backend/routine/attachment'
import { baTableApi } from '/@/api/common'
interface Props {
type?: 'image' | 'file'
limit?: number
modelValue: boolean
returnFullUrl?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'file',
limit: 0,
modelValue: false,
returnFullUrl: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'choice', value: string[]): void
}>()
const { t } = useI18n()
const state = reactive({
ready: false,
tableSelectable: true,
})
const tableRef = useTemplateRef('tableRef')
const optBtn: OptButton[] = [
{
render: 'tipButton',
name: 'choice',
text: t('utils.choice'),
type: 'primary',
icon: 'fa fa-check',
class: 'table-row-choice',
disabledTip: false,
click: (row: TableRow) => {
const elTableRef = tableRef.value?.getRef()
elTableRef?.clearSelection()
emits('choice', props.returnFullUrl ? [row.full_url] : [row.url])
},
},
]
const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
acceptQuery: false,
column: [
{
type: 'selection',
selectable: (row: TableRow) => {
if (props.limit == 0) return true
if (baTable.table.selection) {
for (const key in baTable.table.selection) {
if (row.id == baTable.table.selection[key].id) {
return true
}
}
}
return state.tableSelectable
},
align: 'center',
operator: false,
},
{ label: t('Id'), prop: 'id', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), width: 70 },
{ label: t('utils.Breakdown'), prop: 'topic', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('utils.preview'),
prop: 'suffix',
align: 'center',
formatter: previewRenderFormatter,
render: 'image',
operator: false,
},
{
label: t('utils.type'),
prop: 'mimetype',
align: 'center',
operator: 'LIKE',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.size'),
prop: 'size',
align: 'center',
formatter: (row: TableRow, column: TableColumn, cellValue: string) => {
var size = parseFloat(cellValue)
var i = Math.floor(Math.log(size) / Math.log(1024))
return parseInt((size / Math.pow(1024, i)).toFixed(i < 2 ? 0 : 2)) * 1 + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]
},
operator: 'RANGE',
sortable: 'custom',
operatorPlaceholder: 'bytes',
},
{
label: t('utils.Last upload time'),
prop: 'last_upload_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
width: 160,
sortable: 'custom',
},
{
show: false,
label: t('utils.Upload (Reference) times'),
prop: 'quote',
align: 'center',
width: 150,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('utils.Original name'),
prop: 'name',
align: 'center',
showOverflowTooltip: true,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optBtn,
operator: false,
},
],
defaultOrder: { prop: 'last_upload_time', order: 'desc' },
})
provide('baTable', baTable)
const getData = () => {
if (props.type == 'image') {
baTable.table.filter!.search = [{ field: 'mimetype', val: 'image', operator: 'LIKE' }]
}
baTable.table.ref = tableRef.value
baTable.table.filter!.limit = 8
baTable.getData()?.then(() => {
baTable.initSort()
})
state.ready = true
}
const onChoice = () => {
if (baTable.table.selection?.length) {
let files: string[] = []
for (const key in baTable.table.selection) {
files.push(props.returnFullUrl ? baTable.table.selection[key].full_url : baTable.table.selection[key].url)
}
emits('choice', files)
const elTableRef = tableRef.value?.getRef()
elTableRef?.clearSelection()
}
}
const onSelectionChange = (selection: TableRow[]) => {
if (props.limit == 0) return
if (selection.length > props.limit) {
const elTableRef = tableRef.value?.getRef()
elTableRef?.toggleRowSelection(selection[selection.length - 1], false)
}
state.tableSelectable = !(selection.length >= props.limit)
}
onMounted(() => {
baTable.mount()
})
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !state.ready) {
nextTick(() => {
getData()
})
}
}
)
</script>
<style>
.ba-upload-select-dialog .el-dialog__body {
padding: 10px 20px;
}
.table-header-operate-text {
margin-left: 6px;
}
.ml-10 {
margin-left: 10px;
}
.selection-count {
color: var(--el-color-primary);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,206 @@
import type { FieldData } from './index'
export const npuaFalse = () => {
return {
null: false,
primaryKey: false,
unsigned: false,
autoIncrement: false,
}
}
/**
* 所有 Input 支持的类型对应的数据字段类型等数据(默认/示例设计)
*/
export const fieldData: FieldData = {
string: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
password: {
type: 'varchar',
length: 32,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
number: {
type: 'int',
length: 10,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
radio: {
type: 'enum',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
checkbox: {
type: 'set',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
switch: {
type: 'tinyint',
length: 1,
precision: 0,
default: '0',
defaultType: 'INPUT',
...npuaFalse(),
unsigned: true,
},
textarea: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
array: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
datetime: {
type: 'bigint',
length: 16,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
unsigned: true,
},
year: {
type: 'year',
length: 4,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
date: {
type: 'date',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
time: {
type: 'time',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
select: {
type: 'enum',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
selects: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
remoteSelect: {
type: 'int',
length: 10,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
unsigned: true,
},
remoteSelects: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
editor: {
type: 'text',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
},
city: {
type: 'varchar',
length: 100,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
image: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
images: {
type: 'varchar',
length: 1500,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
file: {
type: 'varchar',
length: 255,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
files: {
type: 'varchar',
length: 1500,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
icon: {
type: 'varchar',
length: 50,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
color: {
type: 'varchar',
length: 50,
precision: 0,
defaultType: 'EMPTY STRING',
...npuaFalse(),
},
}
export const stringToArray = (val: string | string[]) => {
if (typeof val === 'string') {
return val == '' ? [] : val.split(',')
} else {
return val as string[]
}
}

View File

@@ -0,0 +1,218 @@
import type { Component, CSSProperties } from 'vue'
/**
* 支持的输入框类型
* 若您正在设计数据表,可以找到 ./helper.ts 文件来参考对应类型的:数据字段设计示例
*/
export const inputTypes = [
'string',
'password',
'number',
'radio',
'checkbox',
'switch',
'textarea',
'array',
'datetime',
'year',
'date',
'time',
'select',
'selects',
'remoteSelect',
'remoteSelects',
'editor',
'city',
'image',
'images',
'file',
'files',
'icon',
'color',
]
export type ModelValueTypes = string | number | boolean | object
export interface InputData {
// 内容,比如radio的选项列表数据,格式为对象或者数组:{ a: '选项1', b: '选项2' } or [{value: '1', label: 2, disabled: false}, {...}]
content?: any
// 需要生成子级元素时,子级元素属性(比如radio)
childrenAttr?: anyObj
// 城市选择器等级,1=省,2=市,3=区
level?: number
}
/**
* input可用属性,用于代码提示,渲染不同输入组件时,需要的属性是不一样的
* https://element-plus.org/zh-CN/component/input.html#input-属性
*/
export interface InputAttr extends InputData {
id?: string
name?: string
type?: string
placeholder?: string
maxlength?: string | number
minlength?: string | number
showWordLimit?: boolean
clearable?: boolean
showPassword?: boolean
disabled?: boolean
size?: 'large' | 'default' | 'small'
prefixIcon?: string | Component
suffixIcon?: string | Component
rows?: number
border?: boolean
autosize?: boolean | anyObj
autocomplete?: string
readonly?: boolean
max?: string | number
min?: string | number
step?: string | number
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
autofocus?: boolean
form?: string
label?: string
tabindex?: string | number
validateEvent?: boolean
inputStyle?: anyObj
activeValue?: string | number | boolean
inactiveValue?: string | number | boolean
emptyValues?: any[]
valueOnClear?: string | number | boolean | Function
// DateTimePicker属性
editable?: boolean
startPlaceholder?: string
endPlaceholder?: string
timeArrowControl?: boolean
format?: string
popperClass?: string
rangeSeparator?: string
defaultValue?: Date
defaultTime?: Date | Date[]
valueFormat?: string
unlinkPanels?: boolean
clearIcon?: string | Component
shortcuts?: { text: string; value: Date | Function }[]
disabledDate?: Function
cellClassName?: Function
teleported?: boolean
// select属性
multiple?: boolean
valueKey?: string
collapseTags?: string
collapseTagsTooltip?: boolean
multipleLimit?: number
effect?: 'dark' | 'light'
filterable?: boolean
allowCreate?: boolean
filterMethod?: Function
remote?: false // 禁止使用远程搜索,如需使用请使用单独封装好的 remoteSelect 组件
remoteMethod?: false
labelFormatter?: (optionData: anyObj, optionKey: string) => string
noMatchText?: string
noDataText?: string
reserveKeyword?: boolean
defaultFirstOption?: boolean
popperAppendToBody?: boolean
persistent?: boolean
automaticDropdown?: boolean
fitInputWidth?: boolean
tagType?: 'success' | 'info' | 'warning' | 'danger'
params?: anyObj
// 远程select属性
pk?: string
field?: string
remoteUrl?: string
tooltipParams?: anyObj
escBlur?: boolean
// 图标选择器属性
showIconName?: boolean
placement?: string
title?: string
// 颜色选择器
showAlpha?: boolean
colorFormat?: string
predefine?: string[]
// 图片文件上传属性
action?: string
headers?: anyObj
method?: string
data?: anyObj
withCredentials?: boolean
showFileList?: boolean
drag?: boolean
accept?: string
listType?: string
autoUpload?: boolean
limit?: number
hideSelectFile?: boolean
returnFullUrl?: boolean
forceLocal?: boolean
hideImagePlusOnOverLimit?: boolean
// editor属性
height?: string
mode?: string
editorStyle?: CSSProperties
style?: CSSProperties
toolbarConfig?: anyObj
editorConfig?: anyObj
editorType?: string
preview?: boolean
language?: string
theme?: 'light' | 'dark'
toolbarsExclude?: string[]
fileForceLocal?: boolean
// array组件属性
keyTitle?: string
valueTitle?: string
// 返回数据类型
dataType?: string
// 是否渲染为 buttonradio 和 checkbox
button?: boolean
// 事件
onPreview?: Function
onRemove?: Function
onSuccess?: Function
onError?: Function
onProgress?: Function
onExceed?: Function
onBeforeUpload?: Function
onBeforeRemove?: Function
onChange?: Function
onInput?: Function
onVisibleChange?: Function
onRemoveTag?: Function
onClear?: Function
onBlur?: Function
onFocus?: Function
onCalendarChange?: Function
onPanelChange?: Function
onActiveChange?: Function
onRow?: Function
[key: string]: any
}
/**
* Input 支持的类型对应的数据字段设计数据
*/
export interface FieldData {
[key: string]: {
// 数据类型
type: string
// 长度
length: number
// 小数点
precision: number
// 默认值
default?: string
// 默认值类型:INPUT=输入,EMPTY STRING=空字符串,NULL=NULL,NONE=无
defaultType: 'INPUT' | 'EMPTY STRING' | 'NULL' | 'NONE'
// 允许 null
null: boolean
// 主键
primaryKey: boolean
// 无符号
unsigned: boolean
// 自动递增
autoIncrement: boolean
}
}

View File

@@ -0,0 +1,525 @@
<script lang="ts">
import { isArray, isString } from 'lodash-es'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
import { getArea } from '/@/api/common'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
import Array from '/@/components/baInput/components/array.vue'
import BaUpload from '/@/components/baInput/components/baUpload.vue'
import Editor from '/@/components/baInput/components/editor.vue'
import IconSelector from '/@/components/baInput/components/iconSelector.vue'
import RemoteSelect from '/@/components/baInput/components/remoteSelect.vue'
export default defineComponent({
name: 'baInput',
props: {
// 输入框类型,支持的输入框见 inputTypes
type: {
type: String,
required: true,
validator: (value: string) => {
return inputTypes.includes(value)
},
},
// 双向绑定值
modelValue: {
type: null,
required: true,
},
// 输入框的附加属性
attr: {
type: Object as PropType<InputAttr>,
default: () => {},
},
// 额外数据,radio、checkbox的选项等数据
data: {
type: Object as PropType<InputData>,
default: () => {},
},
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
// 合并 props.attr 和 props.data
const attrs = computed(() => {
return { ...props.attr, ...props.data }
})
// 通用值更新函数
const onValueUpdate = (value: ModelValueTypes) => {
emit('update:modelValue', value)
}
// 基础用法 string textarea password
const bases = () => {
return () =>
createVNode(
resolveComponent('el-input'),
{
type: props.type == 'string' ? 'text' : props.type,
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
}
// radio checkbox
const rc = () => {
if (!attrs.value.content) {
console.warn('请传递 ' + props.type + ' 的 content')
}
const vNodes = computed(() => {
const vNode: VNode[] = []
const contentIsArray = isArray(attrs.value.content)
const type = attrs.value.button ? props.type + '-button' : props.type
for (const key in attrs.value.content) {
let nodeProps = {}
if (contentIsArray) {
if (typeof attrs.value.content[key].value == 'number') {
console.warn(props.type + ' 的 content.value 不能是数字')
}
nodeProps = {
...attrs.value.content[key],
border: attrs.value.border ? attrs.value.border : false,
...(attrs.value.childrenAttr || {}),
}
} else {
nodeProps = {
value: key,
label: attrs.value.content[key],
border: attrs.value.border ? attrs.value.border : false,
...(attrs.value.childrenAttr || {}),
}
}
vNode.push(createVNode(resolveComponent('el-' + type), nodeProps, slots))
}
return vNode
})
return () => {
const valueComputed = computed(() => {
if (props.type == 'radio') {
if (props.modelValue == undefined) return ''
return '' + props.modelValue
} else {
let modelValueArr: anyObj = []
for (const key in props.modelValue) {
modelValueArr[key] = '' + props.modelValue[key]
}
return modelValueArr
}
})
return createVNode(
resolveComponent('el-' + props.type + '-group'),
{
...attrs.value,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
() => vNodes.value
)
}
}
// select selects
const select = () => {
if (!attrs.value.content) {
console.warn('请传递 ' + props.type + '的 content')
}
const vNodes = computed(() => {
const vNode: VNode[] = []
for (const key in attrs.value.content) {
vNode.push(
createVNode(
resolveComponent('el-option'),
{
key: key,
label: attrs.value.content[key],
value: key,
...(attrs.value.childrenAttr || {}),
},
slots
)
)
}
return vNode
})
return () => {
const valueComputed = computed(() => {
if (props.type == 'select') {
if (props.modelValue == undefined) return ''
return '' + props.modelValue
} else {
let modelValueArr: anyObj = []
for (const key in props.modelValue) {
modelValueArr[key] = '' + props.modelValue[key]
}
return modelValueArr
}
})
return createVNode(
resolveComponent('el-select'),
{
class: 'w100',
multiple: props.type == 'select' ? false : true,
clearable: true,
...attrs.value,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
() => vNodes.value
)
}
}
// datetime
const datetime = () => {
let valueFormat = 'YYYY-MM-DD HH:mm:ss'
switch (props.type) {
case 'date':
valueFormat = 'YYYY-MM-DD'
break
case 'year':
valueFormat = 'YYYY'
break
}
return () =>
createVNode(
resolveComponent('el-date-picker'),
{
class: 'w100',
type: props.type,
'value-format': valueFormat,
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
}
// upload
const upload = () => {
return () =>
createVNode(
BaUpload,
{
type: props.type,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
}
// remoteSelect remoteSelects
const remoteSelect = () => {
return () =>
createVNode(
RemoteSelect,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
multiple: props.type == 'remoteSelect' ? false : true,
...attrs.value,
},
slots
)
}
const buildFun = new Map([
['string', bases],
[
'number',
() => {
return () =>
createVNode(
resolveComponent('el-input-number'),
{
class: 'w100',
'controls-position': 'right',
...attrs.value,
modelValue: isString(props.modelValue) ? Number(props.modelValue) : props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
},
],
['textarea', bases],
['password', bases],
['radio', rc],
['checkbox', rc],
[
'switch',
() => {
// 值类型:string,number,boolean,custom
const valueType = computed(() => {
if (typeof attrs.value.activeValue !== 'undefined' && typeof attrs.value.inactiveValue !== 'undefined') {
return 'custom'
}
return typeof props.modelValue
})
// 要传递给 el-switch 组件的绑定值,该组件对传入值有限制,先做处理
const valueComputed = computed(() => {
if (valueType.value === 'boolean' || valueType.value === 'custom') {
return props.modelValue
} else {
let valueTmp = parseInt(props.modelValue as string)
return isNaN(valueTmp) || valueTmp <= 0 ? false : true
}
})
return () =>
createVNode(
resolveComponent('el-switch'),
{
...attrs.value,
modelValue: valueComputed.value,
'onUpdate:modelValue': (value: boolean) => {
let newValue: boolean | string | number = value
switch (valueType.value) {
case 'string':
newValue = value ? '1' : '0'
break
case 'number':
newValue = value ? 1 : 0
}
emit('update:modelValue', newValue)
},
},
slots
)
},
],
['datetime', datetime],
[
'year',
() => {
return () => {
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue))
return createVNode(
resolveComponent('el-date-picker'),
{
class: 'w100',
type: props.type,
'value-format': 'YYYY',
...attrs.value,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
}
},
],
['date', datetime],
[
'time',
() => {
return () =>
createVNode(
resolveComponent('el-time-picker'),
{
class: 'w100',
clearable: true,
format: 'HH:mm:ss',
valueFormat: 'HH:mm:ss',
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
},
],
['select', select],
['selects', select],
[
'array',
() => {
return () =>
createVNode(
Array,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
['remoteSelect', remoteSelect],
['remoteSelects', remoteSelect],
[
'city',
() => {
type Node = { value?: number; label?: string; leaf?: boolean }
let maxLevel = attrs.value.level ? attrs.value.level - 1 : 2
const lastLazyValue: {
value: string | number[] | unknown
nodes: Node[]
key: string
currentRequest: any
} = reactive({
value: 'ready',
nodes: [],
key: '',
currentRequest: null,
})
// 请求到的node备份-s
let nodeEbak: anyObj = {}
const getNodes = (level: number, key: string) => {
if (nodeEbak[level] && nodeEbak[level][key]) {
return nodeEbak[level][key]
}
return false
}
const setNodes = (level: number, key: string, nodes: Node[] = []) => {
if (!nodeEbak[level]) {
nodeEbak[level] = {}
}
nodeEbak[level][key] = nodes
}
// 请求到的node备份-e
return () =>
createVNode(
resolveComponent('el-cascader'),
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
class: 'w100',
clearable: true,
// city 数据使用 varchar 存储,所以清空时使用 empty string 而不是 null
valueOnClear: '',
props: {
lazy: true,
lazyLoad(node: any, resolve: any) {
// lazyLoad会频繁触发,在本地存储请求结果,供重复触发时直接读取
const { level, pathValues } = node
let key = pathValues.join(',')
key = key ? key : 'init'
let locaNode = getNodes(level, key)
if (locaNode) {
return resolve(locaNode)
}
if (lastLazyValue.key == key && lastLazyValue.value == props.modelValue) {
if (lastLazyValue.currentRequest) {
return lastLazyValue.currentRequest
}
return resolve(lastLazyValue.nodes)
}
let nodes: Node[] = []
lastLazyValue.key = key
lastLazyValue.value = props.modelValue
lastLazyValue.currentRequest = getArea(pathValues).then((res) => {
let toStr = false
if (props.modelValue && typeof (props.modelValue as anyObj)[0] === 'string') {
toStr = true
}
for (const key in res.data) {
if (toStr) {
res.data[key].value = res.data[key].value.toString()
}
res.data[key].leaf = level >= maxLevel
nodes.push(res.data[key])
}
lastLazyValue.nodes = nodes
lastLazyValue.currentRequest = null
setNodes(level, key, nodes)
resolve(nodes)
})
},
},
...attrs.value,
},
slots
)
},
],
['image', upload],
['images', upload],
['file', upload],
['files', upload],
[
'icon',
() => {
return () =>
createVNode(
IconSelector,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
[
'color',
() => {
return () =>
createVNode(
resolveComponent('el-color-picker'),
{
modelValue: props.modelValue,
'onUpdate:modelValue': (newValue: string | null) => {
// color 数据使用 varchar 存储,点击清空时的 null 值使用 empty string 代替
emit('update:modelValue', newValue === null ? '' : newValue)
},
...attrs.value,
},
slots
)
},
],
[
'editor',
() => {
return () =>
createVNode(
Editor,
{
class: 'w100',
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
[
'default',
() => {
console.warn('暂不支持' + props.type + '的输入框类型,你可以自行在 BaInput 组件内添加逻辑')
},
],
])
let action = buildFun.get(props.type) || buildFun.get('default')
return action!.call(this)
},
})
</script>
<style scoped lang="scss">
.ba-upload-image :deep(.el-upload--picture-card) {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ba-upload-file :deep(.el-upload-list) {
margin-left: -10px;
}
</style>

View File

@@ -0,0 +1,47 @@
import { createVNode, render } from 'vue'
import ClickCaptchaConstructor from './index.vue'
import { shortUuid } from '/@/utils/random'
interface ClickCaptchaOptions {
// 验证码弹窗的自定义class
class?: string
// 前端验证成功时立即清理验证码数据,不可再进行二次验证,不开启则 600s 后自动清理数据
unset?: boolean
// 验证失败的提示信息
error?: string
// 验证成功的提示信息
success?: string
// 验证码 API 的基础 URL默认为当前服务端 URLVITE_AXIOS_BASE_URL
apiBaseURL?: string
}
/**
* 弹出点击验证码
* @param uuid 开发者自定义的唯一标识
* @param callback 验证成功的回调,业务接口可通过 captchaInfo 进行二次验证
* @param options
*/
const clickCaptcha = (uuid: string, callback?: (captchaInfo: string) => void, options: ClickCaptchaOptions = {}) => {
const container = document.createElement('div')
const vnode = createVNode(ClickCaptchaConstructor, {
uuid,
callback,
...options,
key: shortUuid(),
onDestroy: () => {
render(null, container)
},
})
render(vnode, container)
document.body.appendChild(container.firstElementChild!)
}
/**
* 组件的 props 类型定义
*/
export interface Props extends ClickCaptchaOptions {
uuid: string
callback?: (captchaInfo: string) => void
}
export default clickCaptcha

View File

@@ -0,0 +1,221 @@
<template>
<div :id="uuid">
<div class="ba-click-captcha" :class="props.class">
<div v-if="state.loading" class="loading">{{ i18n.global.t('utils.Loading') }}</div>
<div v-else class="captcha-img-box">
<img
class="captcha-img"
@click.prevent="onRecord($event)"
:src="state.captcha.base64"
:alt="i18n.global.t('validate.Captcha loading failed, please click refresh button')"
/>
<span
v-for="(item, index) in state.xy"
:key="index"
class="step"
@click="onCancelRecord(index)"
:style="`left:${parseFloat(item.split(',')[0]) - 13}px;top:${parseFloat(item.split(',')[1]) - 13}px`"
>
{{ index + 1 }}
</span>
</div>
<div class="captcha-prompt" v-if="state.tip">
{{ state.tip }}
</div>
<div v-else class="captcha-prompt">
{{ i18n.global.t('validate.Please click') }}
<span v-for="(text, index) in state.captcha.text" :key="index" :class="state.xy.length > index ? 'clicaptcha-clicked' : ''">
{{ text }}
</span>
</div>
<div class="captcha-refresh-box">
<div class="captcha-refresh-line captcha-refresh-line-l"></div>
<i class="fa fa-refresh captcha-refresh-btn" :title="i18n.global.t('Refresh')" @click="load"></i>
<div class="captcha-refresh-line captcha-refresh-line-r"></div>
</div>
</div>
<div class="ba-layout-shade" @click="onClose"></div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Props } from './index'
import { checkClickCaptcha, getCaptchaData } from '/@/api/common'
import { i18n } from '/@/lang'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const props = withDefaults(defineProps<Props>(), {
uuid: '',
callback: () => {},
class: '',
unset: false,
error: i18n.global.t('validate.The correct area is not clicked, please try again!'),
success: i18n.global.t('validate.Verification is successful!'),
apiBaseURL: '',
})
const state: {
loading: boolean
xy: string[]
tip: string
captcha: {
id: string
text: string
base64: string
width: number
height: number
}
} = reactive({
loading: true,
xy: [],
tip: '',
captcha: {
id: '',
text: '',
base64: '',
width: 350,
height: 200,
},
})
const emits = defineEmits<{
(e: 'destroy'): void
}>()
const load = () => {
state.loading = true
getCaptchaData(props.uuid, props.apiBaseURL).then((res) => {
state.xy = []
state.tip = ''
state.loading = false
state.captcha = res.data
})
}
const onRecord = (event: MouseEvent) => {
if (state.xy.length < state.captcha.text.length) {
state.xy.push(event.offsetX + ',' + event.offsetY)
if (state.xy.length == state.captcha.text.length) {
const captchaInfo = [state.xy.join('-'), (event.target as HTMLImageElement).width, (event.target as HTMLImageElement).height].join(';')
checkClickCaptcha(props.uuid, captchaInfo, props.unset, props.apiBaseURL)
.then(() => {
state.tip = props.success
setTimeout(() => {
props.callback?.(captchaInfo)
onClose()
}, 1500)
})
.catch(() => {
state.tip = props.error
setTimeout(() => {
load()
}, 1500)
})
}
}
}
const onCancelRecord = (index: number) => {
state.xy.splice(index, 1)
}
const onClose = () => {
emits('destroy')
}
const captchaBoxTop = computed(() => (state.captcha.height + 200) / 2 + 'px')
const captchaBoxLeft = computed(() => (state.captcha.width + 24) / 2 + 'px')
load()
</script>
<style scoped lang="scss">
.ba-click-captcha {
padding: 12px;
border: 1px solid var(--el-border-color-extra-light);
background-color: var(--el-color-white);
position: fixed;
z-index: v-bind('SYSTEM_ZINDEX');
left: calc(50% - v-bind('captchaBoxLeft'));
top: calc(50% - v-bind('captchaBoxTop'));
border-radius: 10px;
box-shadow:
0 0 0 1px hsla(0, 0%, 100%, 0.3) inset,
0 0.5em 1em rgba(0, 0, 0, 0.6);
.loading {
color: var(--el-color-info);
width: 350px;
text-align: center;
line-height: 200px;
}
.captcha-img-box {
position: relative;
.captcha-img {
width: v-bind('state.captcha.width') px;
height: v-bind('state.captcha.height') px;
border: none;
cursor: pointer;
}
.step {
box-sizing: border-box;
position: absolute;
width: 20px;
height: 20px;
line-height: 20px;
font-size: var(--el-font-size-small);
font-weight: bold;
text-align: center;
color: var(--el-color-white);
border: 1px solid var(--el-border-color-extra-light);
background-color: var(--el-color-primary);
border-radius: 30px;
box-shadow: 0 0 10px var(--el-color-white);
user-select: none;
cursor: pointer;
}
}
.captcha-prompt {
height: 40px;
line-height: 40px;
font-size: var(--el-font-size-base);
text-align: center;
color: var(--el-color-info);
span {
margin-left: 10px;
font-size: var(--el-font-size-medium);
font-weight: bold;
color: var(--el-color-error);
&.clicaptcha-clicked {
color: var(--el-color-primary);
}
}
}
.captcha-refresh-box {
position: relative;
margin-top: 10px;
.captcha-refresh-line {
position: absolute;
top: 16px;
width: 140px;
height: 1px;
background-color: #ccc;
}
.captcha-refresh-line-l {
left: 5px;
}
.captcha-refresh-line-r {
right: 5px;
}
.captcha-refresh-btn {
cursor: pointer;
display: block;
margin: 0 auto;
width: 32px;
height: 32px;
font-size: 32px;
color: var(--el-color-info);
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<transition name="el-zoom-in-center">
<div
class="el-popper is-pure is-light el-dropdown__popper ba-contextmenu"
:style="`top: ${state.axis.y + 5}px;left: ${state.axis.x - 14}px;width:${props.width}px`"
:key="Math.random()"
v-show="state.show"
aria-hidden="false"
data-popper-placement="bottom"
>
<ul class="el-dropdown-menu">
<template v-for="(item, idx) in props.items" :key="idx">
<li class="el-dropdown-menu__item" :class="item.disabled ? 'is-disabled' : ''" tabindex="-1" @click="onMenuItemClick(item)">
<Icon size="12" :name="item.icon" />
<span>{{ item.label }}</span>
</li>
</template>
</ul>
<span v-if="state.showArrow" class="el-popper__arrow" :style="{ left: `${state.arrowAxis}px` }"></span>
</div>
</transition>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { reactive, toRaw } from 'vue'
import type { Axis, ContextMenuItemClickEmitArg, Props } from './interface'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const props = withDefaults(defineProps<Props>(), {
width: 150,
items: () => [],
})
const emits = defineEmits<{
// 菜单项被点击
(e: 'menuClick', item: ContextMenuItemClickEmitArg): void
// 右击菜单隐藏回调,它可能在组件内部被触发,所以提供 emit
(e: 'hideContextmenu'): void
}>()
const state: {
show: boolean
axis: {
x: number
y: number
}
sourceData: any
showArrow: boolean
arrowAxis: number
} = reactive({
show: false,
axis: {
x: 0,
y: 0,
},
sourceData: null,
showArrow: true,
arrowAxis: 10,
})
/**
* 删除事件监听的函数
*/
const removeEventListenerFn: Record<string, () => void> = {
click: () => {},
scroll: () => {},
keydown: () => {},
}
/**
* 显示右击菜单
* @param sourceData 来源数据,开发者可于右击菜单项被点击的事件中访问到它
* @param axis 右击坐标信息
*/
const onShowContextmenu = (sourceData: any, axis: Axis) => {
state.showArrow = true
state.sourceData = sourceData
const yOffset = document.documentElement.clientHeight - axis.y - (props.items.length * 40 + 20)
const xOffset = document.documentElement.clientWidth - axis.x - (props.width + 20)
if (yOffset < 0) {
axis.y += yOffset
state.showArrow = false
}
if (xOffset < 0) {
axis.x += xOffset
state.showArrow = false
}
state.axis = axis
state.show = true
removeEventListenerFn.click = useEventListener(document, 'click', onHideContextmenu)
removeEventListenerFn.scroll = useEventListener(document, 'scroll', onHideContextmenu)
removeEventListenerFn.keydown = useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') {
onHideContextmenu()
}
})
}
/**
* 隐藏右击菜单
*/
const onHideContextmenu = () => {
state.show = false
for (const key in removeEventListenerFn) {
removeEventListenerFn[key]()
}
emits('hideContextmenu')
}
const onMenuItemClick = (item: ContextMenuItemClickEmitArg) => {
if (item.disabled) return
item.sourceData = toRaw(state.sourceData)
emits('menuClick', item)
}
defineExpose({
onShowContextmenu,
onHideContextmenu,
})
</script>
<style scoped lang="scss">
.ba-contextmenu {
position: fixed;
z-index: v-bind('SYSTEM_ZINDEX');
}
.el-popper,
.el-popper.is-light .el-popper__arrow::before {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border: none;
}
.el-dropdown-menu__item {
padding: 8px 20px;
user-select: none;
}
.el-dropdown-menu__item .icon {
margin-right: 5px;
}
.el-dropdown-menu__item:not(.is-disabled) {
&:hover {
background-color: var(--el-dropdown-menuItem-hover-fill);
color: var(--el-dropdown-menuItem-hover-color);
.fa {
color: var(--el-dropdown-menuItem-hover-color) !important;
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
export interface Axis {
x: number
y: number
}
export interface ContextMenuItem {
name: string
label: string
icon?: string
disabled?: boolean
}
export interface ContextMenuItemClickEmitArg<T = any> extends ContextMenuItem {
sourceData?: T
}
export interface Props {
width?: number
items: ContextMenuItem[]
}

View File

@@ -0,0 +1,304 @@
<!-- 一个用于创建 FormItem 数据的组件 -->
<!-- 使用场景举例系统配置->添加配置项 -->
<template>
<div>
<!-- 本组件不被 el-form 包含方便您在其他 el-form 的任意位置使用且没有带了一个 el-form 时会出现的负担 -->
<!-- formitem 已经设置了 prop 属性以便外部 el-form 添加表单验证规则 -->
<FormItem
v-if="form.name.show"
:label="form.name.title"
type="string"
v-model="form.name.value"
:placeholder="t('Please input field', { field: form.name.title })"
:input-attr="{
onChange: updateValue,
...props.options?.name?.inputAttr,
}"
prop="name"
/>
<FormItem
v-if="form.title.show"
:label="form.title.title"
type="string"
v-model="form.title.value"
:placeholder="t('Please input field', { field: form.title.title })"
:input-attr="{
onChange: updateValue,
...props.options?.title?.inputAttr,
}"
prop="title"
/>
<FormItem
v-if="form.type.show"
:label="form.type.title"
type="select"
v-model="form.type.value"
:placeholder="t('Please select field', { field: form.type.title })"
:input-attr="{
onChange: updateValue,
content: state.inputTypes,
...props.options?.type?.inputAttr,
}"
prop="type"
/>
<FormItem
v-if="form.dict.show && dictExistsType.includes(form.type.value!)"
:label="form.dict.title"
type="textarea"
v-model="form.dict.value"
:input-attr="{
rows: 3,
placeholder: t('utils.One line at a time, without quotation marks, for example: key1=value1'),
onChange: updateValue,
...props.options?.dict?.inputAttr,
}"
prop="dict"
@keyup.enter.stop=""
/>
<FormItem
v-if="form.tip.show"
:label="form.tip.title"
type="string"
v-model="form.tip.value"
:placeholder="t('Please input field', { field: form.tip.title })"
:input-attr="{
onChange: updateValue,
...props.options?.tip?.inputAttr,
}"
prop="tip"
/>
<FormItem
v-if="form.rule.show"
:label="form.rule.title"
type="selects"
v-model="form.rule.value"
:placeholder="t('Please select field', { field: form.rule.title })"
:input-attr="{
onChange: updateValue,
content: state.validators,
...props.options?.rule?.inputAttr,
}"
prop="rule"
/>
<FormItem
v-if="form.extend.show"
:label="form.extend.title"
type="textarea"
v-model="form.extend.value"
:input-attr="{
onChange: updateValue,
placeholder: t('utils.One attribute per line without quotation marks(formitem)'),
...props.options?.extend?.inputAttr,
}"
prop="extend"
@keyup.enter.stop=""
/>
<FormItem
v-if="form.inputExtend.show"
:label="form.inputExtend.title"
type="textarea"
v-model="form.inputExtend.value"
:input-attr="{
onChange: updateValue,
placeholder: t('utils.Extended properties of Input, one line without quotation marks, such as: size=large'),
...props.options?.inputExtend?.inputAttr,
}"
prop="inputExtend"
@keyup.enter.stop=""
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import FormItem from '/@/components/formItem/index.vue'
import { inputTypes } from '/@/components/baInput'
import { validatorType } from '/@/utils/validate'
import type { InputAttr } from '/@/components/baInput'
import { i18n } from '/@/lang'
const { t } = i18n.global
type OptionItem = {
// 是否显示(被创建的数据是否需要这一项)
show?: boolean
// 被创建数据的标题默认使用props.dataTitle + 本 title自定义此 title 后,单使用此 title
title?: string
// 输入框额外属性
inputAttr?: InputAttr
}
type ValidatesOptionItem = Omit<OptionItem, 'value'> & {
value?: string[]
}
interface Props {
// 被创建数据的标题,作为所有表单项的前缀(默认值)
dataTitle?: string
// 默认值
modelValue: {
name?: string
title?: string
type?: string
tip?: string
rule?: string[]
extend?: string
dict?: string
inputExtend?: string
}
// 表单项配置
options?: {
// 变量名
name?: OptionItem
// 标题
title?: OptionItem
// 类型
type?: OptionItem
// 提示信息
tip?: OptionItem
// 验证规则
rule?: ValidatesOptionItem
// FormItem 的扩展属性
extend?: OptionItem
// 字典数据(单选、复选等类型的字典)
dict?: OptionItem
// Input 的扩展属性
inputExtend?: OptionItem
}
excludeInputTypes?: string[]
excludeValidatorRule?: string[]
}
const props = withDefaults(defineProps<Props>(), {
dataTitle: i18n.global.t('utils.Var'),
modelValue: () => {
return {
name: '',
title: '',
type: '',
tip: '',
rule: [],
extend: '',
dict: '',
inputExtend: '',
}
},
name: () => {
return {}
},
title: () => {
return {}
},
type: () => {
return {}
},
tip: () => {
return {}
},
rule: () => {
return {}
},
extend: () => {
return {}
},
dict: () => {
return {}
},
inputExtend: () => {
return {}
},
excludeInputTypes: () => [],
excludeValidatorRule: () => [],
})
const dictExistsType = ['radio', 'checkbox', 'select', 'selects']
const form = reactive({
name: {
show: props.options?.name?.show === false ? false : true,
value: props.modelValue.name,
title: props.options?.name?.title ?? props.dataTitle + t('utils.Name'), // 变量名
},
title: {
show: props.options?.title?.show === false ? false : true,
value: props.modelValue.title,
title: props.options?.title?.title ?? props.dataTitle + t('utils.Title'), // 变量标题
},
type: {
show: props.options?.type?.show === false ? false : true,
value: props.modelValue.type,
title: props.options?.type?.title ?? props.dataTitle + t('utils.type'), // 变量类型
},
tip: {
show: props.options?.tip?.show === false ? false : true,
value: props.modelValue.tip,
title: props.options?.tip?.title ?? t('utils.Tip'), // 提示信息
},
rule: {
show: props.options?.rule?.show === false ? false : true,
value: props.modelValue.rule,
title: props.options?.rule?.title ?? t('utils.Rule'), // 验证规则
},
extend: {
show: props.options?.extend?.show === false ? false : true,
value: props.modelValue.extend,
title: props.options?.extend?.title ?? 'FormItem ' + t('utils.Extend'), // FormItem 扩展属性
},
dict: {
show: props.options?.dict?.show === false ? false : true,
value: props.modelValue.dict,
title: props.options?.dict?.title ?? t('utils.Dict'), // 字典数据
},
inputExtend: {
show: props.options?.inputExtend?.show === false ? false : true,
value: props.modelValue.inputExtend,
title: props.options?.inputExtend?.title ?? 'Input ' + t('utils.Extend'), // Input 扩展属性
},
})
const state = reactive({
validators: {},
inputTypes: {},
})
const emits = defineEmits<{
(e: 'update:modelValue', value: Props['modelValue']): void
}>()
const updateValue = () => {
emits('update:modelValue', {
name: form.name.value ?? '',
title: form.title.value ?? '',
type: form.type.value ?? '',
tip: form.tip.value ?? '',
rule: form.rule.value ?? [],
extend: form.extend.value ?? '',
dict: dictExistsType.includes(form.type.value ?? '') ? (form.dict.value ?? '') : '',
inputExtend: form.inputExtend.value ?? '',
})
}
const dataPretreatment = () => {
let inputTypesKey: anyObj = {}
for (const key in inputTypes) {
if (!props.excludeInputTypes.includes(inputTypes[key])) {
inputTypesKey[inputTypes[key]] = inputTypes[key]
}
}
state.inputTypes = inputTypesKey
let validators: anyObj = {}
for (const key in validatorType) {
if (!props.excludeValidatorRule.includes(key)) {
validators[key] = validatorType[key as keyof typeof validatorType]
}
}
state.validators = validators
updateValue()
}
dataPretreatment()
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,13 @@
import type { CSSProperties } from 'vue'
import type { FormItemProps, ElTooltipProps } from 'element-plus'
export interface FormItemAttr extends Partial<Writeable<FormItemProps>> {
// 通用属性名称的键入提示
id?: string
class?: string
style?: CSSProperties
// 块级输入帮助信息
blockHelp?: string
// 输入提示信息(使用 el-tooltip 渲染)
tip?: string | Partial<ElTooltipProps>
}

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { formItemProps } from 'element-plus'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, resolveComponent } from 'vue'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
import BaInput from '/@/components/baInput/index.vue'
import type { FormItemAttr } from '/@/components/formItem'
export default defineComponent({
name: 'formItem',
props: {
// 输入框类型,支持的输入框见 inputTypes
type: {
type: String,
required: true,
validator: (value: string) => {
return inputTypes.includes(value)
},
},
// 双向绑定值
modelValue: {
required: true,
},
// 输入框的附加属性
inputAttr: {
type: Object as PropType<InputAttr>,
default: () => {},
},
blockHelp: {
type: String,
default: '',
},
tip: [String, Object],
// el-form-item 的附加属性(还可以直接通过当前组件的 props 传递)
attr: {
type: Object as PropType<FormItemAttr>,
default: () => {},
},
// 额外数据(已和 props.inputAttr 合并,还可以通过它进行传递)
data: {
type: Object as PropType<InputData>,
default: () => {},
},
// 内部输入框的 placeholder相当于 props.inputAttr.placeholder 的别名)
placeholder: {
type: String,
default: '',
},
...formItemProps,
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
// 通过 props 和 props.attr 两种方式传递的属性汇总为 attrs
const excludeProps = ['type', 'modelValue', 'inputAttr', 'attr', 'data', 'placeholder']
const attrs = computed(() => {
const newAttrs = props.attr || {}
for (const key in props) {
const propValue: any = props[key as keyof typeof props]
if (!excludeProps.includes(key) && (propValue || propValue === false)) {
newAttrs[key as keyof typeof props.attr] = propValue
}
}
return newAttrs
})
const onValueUpdate = (value: ModelValueTypes) => {
emit('update:modelValue', value)
}
// el-form-item 的插槽
const formItemSlots: { [key: string]: () => VNode | VNode[] } = {}
// default 插槽
formItemSlots.default = () => {
let inputNode = createVNode(
BaInput,
{
type: props.type,
attr: { placeholder: props.placeholder, ...props.inputAttr, ...props.data },
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
if (attrs.value.blockHelp) {
return [
inputNode,
createVNode(
'div',
{
class: 'block-help',
},
attrs.value.blockHelp
),
]
}
return inputNode
}
if (attrs.value.tip) {
const createTipNode = () => {
const tipProps = typeof attrs.value.tip === 'string' ? { content: attrs.value.tip, placement: 'top' } : attrs.value.tip
return createVNode(resolveComponent('el-tooltip'), tipProps, {
default: () => [
createVNode('i', {
class: 'fa fal fa-question-circle',
}),
],
})
}
// label 插槽
formItemSlots.label = () => {
return createVNode(
'span',
{
class: 'ba-form-item-label',
},
[
createVNode('span', null, attrs.value.label),
createVNode(
'span',
{
class: 'ba-form-item-label-tip',
},
[createTipNode()]
),
]
)
}
}
return () =>
createVNode(
resolveComponent('el-form-item'),
{
class: 'ba-input-item-' + props.type,
...attrs.value,
},
formItemSlots
)
},
})
</script>
<style scoped lang="scss">
.ba-form-item-label-tip {
padding-left: 6px;
color: var(--el-text-color-secondary);
i {
cursor: pointer;
}
}
.ba-form-item-not-support {
line-height: 15px;
}
.ba-input-item-array :deep(.el-form-item__content) {
display: block;
padding-bottom: 32px;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { createVNode, resolveComponent, defineComponent, computed, type CSSProperties } from 'vue'
import Svg from '/@/components/icon/svg/index.vue'
import { isExternal } from '/@/utils/common'
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String,
required: true,
},
size: {
type: String,
default: '18px',
},
color: {
type: String,
default: '#000000',
},
},
setup(props) {
const iconStyle = computed((): CSSProperties => {
const { size, color } = props
let s = `${size.replace('px', '')}px`
return {
fontSize: s,
color: color,
}
})
if (props.name.indexOf('el-icon-') === 0) {
return () => createVNode('el-icon', { class: 'icon el-icon', style: iconStyle.value }, [createVNode(resolveComponent(props.name))])
} else if (props.name.indexOf('local-') === 0 || isExternal(props.name)) {
return () => createVNode(Svg, { name: props.name, size: props.size, color: props.color })
} else {
return () => createVNode('i', { class: [props.name, 'icon'], style: iconStyle.value })
}
},
})
</script>

View File

@@ -0,0 +1,69 @@
import { readFileSync, readdirSync } from 'fs'
let idPerfix = ''
const iconNames: string[] = []
const svgTitle = /<svg([^>+].*?)>/
const clearHeightWidth = /(width|height)="([^>+].*?)"/g
const hasViewBox = /(viewBox="[^>+].*?")/g
const clearReturn = /(\r)|(\n)/g
// 清理 svg 的 fill
const clearFill = /(fill="[^>+].*?")/g
function findSvgFile(dir: string): string[] {
const svgRes = []
const dirents = readdirSync(dir, {
withFileTypes: true,
})
for (const dirent of dirents) {
iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`)
if (dirent.isDirectory()) {
svgRes.push(...findSvgFile(dir + dirent.name + '/'))
} else {
const svg = readFileSync(dir + dirent.name)
.toString()
.replace(clearReturn, '')
.replace(clearFill, 'fill=""')
.replace(svgTitle, ($1, $2) => {
let width = 0
let height = 0
let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {
if (s2 === 'width') {
width = s3
} else if (s2 === 'height') {
height = s3
}
return ''
})
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`
}
return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`
})
.replace('</svg>', '</symbol>')
svgRes.push(svg)
}
}
return svgRes
}
export const svgBuilder = (path: string, perfix = 'local') => {
if (path === '') return
idPerfix = perfix
const res = findSvgFile(path)
return {
name: 'svg-transform',
transformIndexHtml(html: string) {
return html.replace(
'<body>',
`
<body>
<svg id="local-icon" data-icon-name="${iconNames.join(
','
)}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
${res.join('')}
</svg>
`
)
},
}
}

View File

@@ -0,0 +1,49 @@
<template>
<div v-if="isUrl" :style="urlIconStyle" class="url-svg svg-icon icon" />
<svg v-else class="svg-icon icon" :style="iconStyle">
<use :href="iconName" />
</svg>
</template>
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
import { isExternal } from '/@/utils/common'
interface Props {
name: string
size: string
color: string
}
const props = withDefaults(defineProps<Props>(), {
name: '',
size: '18px',
color: '#000000',
})
const s = `${props.size.replace('px', '')}px`
const iconName = computed(() => `#${props.name}`)
const iconStyle = computed((): CSSProperties => {
return {
color: props.color,
fontSize: s,
}
})
const isUrl = computed(() => isExternal(props.name))
const urlIconStyle = computed(() => {
return {
width: s,
height: s,
mask: `url(${props.name}) no-repeat 50% 50%`,
'-webkit-mask': `url(${props.name}) no-repeat 50% 50%`,
}
})
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
fill: currentColor;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,11 @@
import type { AxiosRequestConfig } from 'axios'
export const state: () => 'disable' | 'enable' = () => 'disable'
export function fileUpload(fd: FormData, params: anyObj = {}, config: AxiosRequestConfig = {}): ApiPromise {
// 上传扩展,定义此函数,并将上方的 state 设定为 enable系统可自动使用此函数进行上传
return new Promise((resolve, reject) => {
console.log(fd, params, config)
reject('未定义')
})
}

View File

@@ -0,0 +1,11 @@
<template>
<div class="tips">{{ $t('utils.Please install editor') }}</div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss">
.tips {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div class="login-footer-buried-point"></div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,4 @@
export default function loginMounted(): Promise<boolean> {
// 通常用于会员登录页初始化时接受各种回调或收参跳转,返回 true 将终止会员登录页初始化
return new Promise((resolve) => resolve(false))
}

View File

@@ -0,0 +1,11 @@
interface UserMountedRet {
type: 'jump' | 'break' | 'continue' | 'reload'
[key: string]: any
}
export default function userMounted(): Promise<UserMountedRet> {
// 通常用于会员中心初始化时接受各种回调或收参跳转,返回 true 将终止会员中心初始化
return new Promise((resolve) => {
resolve({ type: 'continue' })
})
}

View File

@@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="table-com-search-wrapper">
<div class="table-com-search">
<el-form
@submit.prevent=""
@keyup.enter="baTable.onTableAction('com-search', { event: 'submit-com-search-form' })"
label-position="top"
:model="baTable.comSearch.form"
>
<el-row>
<template v-for="(item, idx) in baTable.table.column" :key="idx">
<template v-if="item.operator !== false">
<!-- 自定义渲染 componentslot -->
<el-col
v-if="item.comSearchRender == 'customRender' || item.comSearchRender == 'slot'"
v-bind="{
xs: item.comSearchColAttr?.xs ? item.comSearchColAttr?.xs : 24,
sm: item.comSearchColAttr?.sm ? item.comSearchColAttr?.sm : 6,
...item.comSearchColAttr,
}"
>
<!-- 外部可以使用 :deep() 选择器修改css样式 -->
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input">
<!-- 自定义组件/函数渲染 -->
<component
v-if="item.comSearchRender == 'customRender'"
:is="item.comSearchCustomRender"
:renderRow="item"
:renderField="item.prop!"
:renderValue="baTable.comSearch.form[item.prop!]"
/>
<!-- 自定义渲染-slot -->
<slot v-else-if="item.comSearchRender == 'slot'" :name="item.comSearchSlotName"></slot>
</div>
</div>
</el-col>
<!-- 时间日期范围 -->
<el-col
v-else-if="
(item.render == 'datetime' || item.comSearchRender == 'datetime' || item.comSearchRender == 'date') &&
(item.operator == 'RANGE' || item.operator == 'NOT RANGE')
"
:xs="24"
:sm="12"
>
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label w16" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input-range w83">
<el-date-picker
class="datetime-picker w100"
v-model="baTable.comSearch.form[item.prop!]"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
:type="item.comSearchRender == 'date' ? 'daterange' : 'datetimerange'"
:range-separator="$t('To')"
:start-placeholder="getPlaceholder(item.operatorPlaceholder, 0, $t('el.datepicker.startDate'))"
:end-placeholder="getPlaceholder(item.operatorPlaceholder, 1, $t('el.datepicker.endDate'))"
:value-format="item.comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:teleported="false"
v-bind="item.comSearchInputAttr"
/>
</div>
</div>
</el-col>
<!-- 时间范围 -->
<el-col
v-else-if="item.comSearchRender == 'time' && (item.operator == 'RANGE' || item.operator == 'NOT RANGE')"
:xs="24"
:sm="12"
>
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label w16" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input-range w83">
<el-time-picker
class="time-picker w100"
v-model="baTable.comSearch.form[item.prop!]"
is-range
:default-value="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
:range-separator="$t('To')"
:start-placeholder="getPlaceholder(item.operatorPlaceholder, 0, $t('el.datepicker.startTime'))"
:end-placeholder="getPlaceholder(item.operatorPlaceholder, 1, $t('el.datepicker.endTime'))"
value-format="HH:mm:ss"
v-bind="item.comSearchInputAttr"
/>
</div>
</div>
</el-col>
<!-- 其他 -->
<el-col v-else :xs="24" :sm="6">
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<!-- 数字范围 -->
<div v-if="item.operator == 'RANGE' || item.operator == 'NOT RANGE'" class="com-search-col-input-range">
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder)"
type="string"
v-model="baTable.comSearch.form[item.prop! + '-start']"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
<div class="range-separator">{{ $t('To') }}</div>
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder, 1)"
type="string"
v-model="baTable.comSearch.form[item.prop! + '-end']"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
</div>
<!-- 是否 [NOT] NULL -->
<div v-else-if="item.operator == 'NULL' || item.operator == 'NOT NULL'" class="com-search-col-input">
<el-checkbox
v-model="baTable.comSearch.form[item.prop!]"
:label="item.operator"
size="large"
v-bind="item.comSearchInputAttr"
></el-checkbox>
</div>
<div v-else-if="item.operator" class="com-search-col-input">
<!-- 时间日期筛选 -->
<el-date-picker
class="datetime-picker w100"
v-if="item.render == 'datetime' || item.comSearchRender == 'date' || item.comSearchRender == 'datetime'"
v-model="baTable.comSearch.form[item.prop!]"
:type="item.comSearchRender == 'date' ? 'date' : 'datetime'"
:value-format="item.comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
:teleported="false"
v-bind="item.comSearchInputAttr"
/>
<!-- 时间筛选 -->
<el-time-picker
class="time-picker w100"
v-if="item.comSearchRender == 'time'"
v-model="baTable.comSearch.form[item.prop!]"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
value-format="HH:mm:ss"
v-bind="item.comSearchInputAttr"
/>
<!-- tag、tags、select -->
<el-select
class="w100"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else-if="
(item.render == 'tag' || item.render == 'tags' || item.comSearchRender == 'select') &&
item.replaceValue
"
v-model="baTable.comSearch.form[item.prop!]"
:multiple="item.operator == 'IN' || item.operator == 'NOT IN'"
:clearable="true"
v-bind="item.comSearchInputAttr"
>
<el-option v-for="(opt, okey) in item.replaceValue" :key="item.prop! + okey" :label="opt" :value="okey" />
</el-select>
<!-- 远程 select -->
<BaInput
v-else-if="item.comSearchRender == 'remoteSelect'"
type="remoteSelect"
v-model="baTable.comSearch.form[item.prop!]"
:attr="{ ...item.remote, ...item.comSearchInputAttr }"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
/>
<!-- 开关 -->
<el-select
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else-if="item.render == 'switch'"
v-model="baTable.comSearch.form[item.prop!]"
:clearable="true"
class="w100"
v-bind="item.comSearchInputAttr"
>
<template v-if="!isEmpty(item.replaceValue)">
<el-option
v-for="(opt, okey) in item.replaceValue"
:key="item.prop! + okey"
:label="opt"
:value="okey"
/>
</template>
<template v-else>
<el-option :label="$t('utils.open')" value="1" />
<el-option :label="$t('utils.close')" value="0" />
</template>
</el-select>
<!-- 字符串 -->
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else
type="string"
v-model="baTable.comSearch.form[item.prop!]"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
</div>
</div>
</el-col>
</template>
</template>
<el-col :xs="24" :sm="6">
<div class="com-search-col pl-20">
<el-button v-blur @click="baTable.onTableAction('com-search', { event: 'submit-com-search-form' })" type="primary">
{{ $t('Search') }}
</el-button>
<el-button @click="onResetForm()">{{ $t('Reset') }}</el-button>
</div>
</el-col>
</el-row>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type baTableClass from '/@/utils/baTable'
import { isArray, isEmpty, isUndefined } from 'lodash-es'
import BaInput from '/@/components/baInput/index.vue'
const baTable = inject('baTable') as baTableClass
const onResetForm = () => {
/**
* 封装好的 /utils/common.js/onResetForm 工具在此处不能使用,因为未使用 el-form-item
* 改用公共搜索重新初始化函数
*/
baTable.initComSearch()
// 通知 baTable 发起公共搜索
baTable.onTableAction('com-search', { event: 'reset-com-search-form' })
}
const getPlaceholder = (placeholder: string | string[] | undefined, key = 0, defaultValue = '') => {
if (isUndefined(placeholder)) {
return defaultValue
} else if (isArray(placeholder)) {
return placeholder[key]
} else {
return placeholder
}
}
</script>
<style scoped lang="scss">
.table-com-search {
box-sizing: border-box;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
border: 1px solid var(--ba-border-color);
border-bottom: none;
padding: 13px 15px;
font-size: 14px;
.com-search-col {
display: flex;
align-items: center;
padding-top: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
}
.com-search-col-label {
width: 33.33%;
padding: 0 15px;
text-align: right;
overflow: hidden;
white-space: nowrap;
}
.com-search-col-input {
padding: 0 15px;
width: 66.66%;
}
.com-search-col-input-range {
display: flex;
align-items: center;
padding: 0 15px;
width: 66.66%;
.range-separator {
padding: 0 5px;
}
}
}
.pl-20 {
padding-left: 20px;
}
.w16 {
width: 16.5% !important;
}
.w83 {
width: 83.5% !important;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div v-memo="[field]">
<template v-for="(btn, idx) in field.buttons" :key="idx">
<template v-if="btn.display ? btn.display(row, field) : true">
<!-- 常规按钮 -->
<el-button
v-if="btn.render == 'basicButton'"
v-blur
@click="onButtonClick(btn)"
:class="btn.class"
size="small"
class="ba-table-render-buttons-item buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
<!-- 带提示信息的按钮 -->
<el-tooltip
v-if="btn.render == 'tipButton' && ((btn.name == 'edit' && baTable.auth('edit')) || btn.name != 'edit')"
:disabled="btn.title && !btn.disabledTip ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
v-blur
@click="onButtonClick(btn)"
:class="btn.class"
size="small"
class="ba-table-render-buttons-item buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
<!-- 带确认框的按钮 -->
<el-popconfirm
v-if="btn.render == 'confirmButton' && ((btn.name == 'delete' && baTable.auth('del')) || btn.name != 'delete')"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.popconfirm, { row, field, cellValue: btn, column, index })"
@confirm="onButtonClick(btn)"
>
<template #reference>
<div class="buttons-popconfirm-reference-box buttons-ml-6">
<el-tooltip
:disabled="btn.title ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
v-blur
:class="btn.class"
size="small"
class="ba-table-render-buttons-item"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
<!-- 带提示的可拖拽按钮 -->
<el-tooltip
v-if="btn.render == 'moveButton' && ((btn.name == 'weigh-sort' && baTable.auth('sortable')) || btn.name != 'weigh-sort')"
:disabled="btn.title && !btn.disabledTip ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
:class="btn.class"
size="small"
class="ba-table-render-buttons-item move-button buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const { t, te } = useI18n()
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const onButtonClick = (btn: OptButton) => {
if (typeof btn.click === 'function') {
btn.click(props.row, props.field)
return
}
baTable.onTableAction(btn.name as BaTableActionEventName, props)
}
const getTranslation = (key?: string) => {
if (!key) return ''
return te(key) ? t(key) : key
}
</script>
<style scoped lang="scss">
.ba-table-render-buttons-item {
.text {
font-size: 14px;
}
.icon + .text {
padding-left: 5px;
}
&.el-button--small {
padding: 4px 5px;
height: auto;
}
}
.ba-table-render-buttons-move {
cursor: move;
}
.buttons-popconfirm-reference-box {
display: inline-flex;
vertical-align: middle;
}
.buttons-ml-6 + .buttons-ml-6 {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div>
<div :style="{ background: cellValue }" class="ba-table-render-color"></div>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-color {
height: 25px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div>
<component
:is="field.customRender"
:renderRow="row"
:renderField="field"
:renderValue="cellValue"
:renderColumn="column"
:renderIndex="index"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<div v-html="field.customTemplate ? field.customTemplate(row, field, cellValue, column, index) : ''"></div>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div>
{{ !cellValue ? '-' : timeFormat(cellValue, field.timeFormat ?? 'yyyy-mm-dd hh:MM:ss') }}
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
import { timeFormat } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<el-tag effect="dark" type="danger">Field renderer not found</el-tag>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<Icon
color="var(--el-text-color-primary)"
:name="cellValue"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.icon, { row, field, cellValue, column, index })"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div>
<el-image
v-if="cellValue"
:hide-on-click-modal="true"
:preview-teleported="true"
:preview-src-list="[fullUrl(cellValue)]"
:src="fullUrl(cellValue)"
class="ba-table-render-image"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.image, { row, field, cellValue, column, index })"
></el-image>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import { fullUrl } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-image {
height: 36px;
width: 36px;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<template v-if="isArray(cellValue) && cellValue.length">
<el-image
v-for="(item, idx) in cellValue"
:key="idx"
:initial-index="idx"
:preview-teleported="true"
:preview-src-list="arrayFullUrl(cellValue)"
class="ba-table-render-images-item"
:src="fullUrl(item)"
:hide-on-click-modal="true"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.image, { row, field, cellValue, column, index })"
></el-image>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { isArray } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import { arrayFullUrl, fullUrl } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-images-item {
height: 36px;
width: 36px;
margin: 0 5px;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div>
<el-switch
v-if="field.prop"
@change="onChange"
:model-value="cellValue"
:loading="loading"
active-value="1"
inactive-value="0"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue, column, index })"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { inject, ref } from 'vue'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const loading = ref(false)
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const cellValue = ref(getCellValue(props.row, props.field, props.column, props.index))
if (typeof cellValue.value === 'number') {
cellValue.value = cellValue.value.toString()
}
const onChange = (value: string | number | boolean) => {
loading.value = true
baTable.api
.postData('edit', {
[baTable.table.pk!]: props.row[baTable.table.pk!],
[props.field.prop!]: value,
})
.then(() => {
cellValue.value = value
baTable.onTableAction('field-change', { value: value, ...props })
})
.finally(() => {
loading.value = false
})
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div>
<el-tag
v-if="![null, undefined, ''].includes(cellValue)"
:type="getTagType(cellValue, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[cellValue] ?? cellValue) : cellValue }}
</el-tag>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx, TagProps } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const getTagType = (value: string, custom: any): TagProps['type'] => {
return !isEmpty(custom) && custom[value] ? custom[value] : 'primary'
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div>
<template v-if="isArray(cellValue)">
<template v-for="(tag, idx) in cellValue" :key="idx">
<el-tag
v-if="![null, undefined, ''].includes(tag)"
class="m-4"
:type="getTagType(tag, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[tag] ?? tag) : tag }}
</el-tag>
</template>
</template>
<template v-else>
<el-tag
v-if="![null, undefined, ''].includes(cellValue)"
:type="getTagType(cellValue, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[cellValue] ?? cellValue) : cellValue }}
</el-tag>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx, TagProps } from 'element-plus'
import { isArray, isEmpty } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const getTagType = (value: string, custom: any): TagProps['type'] => {
return !isEmpty(custom) && custom[value] ? custom[value] : 'primary'
}
</script>
<style scoped lang="scss">
.m-4 {
margin: 4px;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div>
<el-input :model-value="cellValue" :placeholder="$t('Link address')">
<template #append>
<el-button @click="openUrl(cellValue, field)">
<Icon color="#606266" name="el-icon-Position" />
</el-button>
</template>
</el-input>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
if (props.field.click) {
console.warn('baTable.table.column.click 即将废弃,请使用 el-table 的 @cell-click 或单元格自定义渲染代替')
}
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const openUrl = (url: string, field: TableColumn) => {
if (field.target == '_blank') {
window.open(url)
} else {
window.location.href = url
}
}
</script>

View File

@@ -0,0 +1,243 @@
<template>
<!-- 公共搜索 -->
<el-collapse-transition>
<ComSearch v-if="props.buttons.includes('comSearch') && baTable.table.showComSearch">
<template v-for="(slot, idx) in $slots" :key="idx" #[idx]>
<slot :name="idx"></slot>
</template>
</ComSearch>
</el-collapse-transition>
<!-- 操作按钮组 -->
<div v-bind="$attrs" class="table-header ba-scroll-style">
<slot name="refreshPrepend"></slot>
<el-tooltip v-if="props.buttons.includes('refresh')" :content="t('Refresh')" placement="top">
<el-button v-blur @click="onAction('refresh', { loading: true })" color="#40485b" class="table-header-operate btns-ml-12" type="info">
<Icon name="fa fa-refresh" />
</el-button>
</el-tooltip>
<slot name="refreshAppend"></slot>
<el-tooltip v-if="props.buttons.includes('add') && baTable.auth('add')" :content="t('Add')" placement="top">
<el-button v-blur @click="onAction('add')" class="table-header-operate btns-ml-12" type="primary">
<Icon name="fa fa-plus" />
<span class="table-header-operate-text">{{ t('Add') }}</span>
</el-button>
</el-tooltip>
<el-tooltip v-if="props.buttons.includes('edit') && baTable.auth('edit')" :content="t('Edit selected row')" placement="top">
<el-button v-blur @click="onAction('edit')" :disabled="!enableBatchOpt" class="table-header-operate btns-ml-12" type="primary">
<Icon name="fa fa-pencil" />
<span class="table-header-operate-text">{{ t('Edit') }}</span>
</el-button>
</el-tooltip>
<el-popconfirm
v-if="props.buttons.includes('delete') && baTable.auth('del')"
@confirm="onAction('delete')"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('Are you sure to delete the selected record?')"
:disabled="!enableBatchOpt"
>
<template #reference>
<div class="btns-ml-12">
<el-tooltip :content="t('Delete selected row')" placement="top">
<el-button v-blur :disabled="!enableBatchOpt" class="table-header-operate" type="danger">
<Icon name="fa fa-trash" />
<span class="table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
<el-tooltip
v-if="props.buttons.includes('unfold')"
:content="(baTable.table.expandAll ? t('Shrink') : t('Open')) + t('All submenus')"
placement="top"
>
<el-button
v-blur
@click="baTable.onTableHeaderAction('unfold', { unfold: !baTable.table.expandAll })"
class="table-header-operate btns-ml-12"
:type="baTable.table.expandAll ? 'danger' : 'warning'"
>
<span class="table-header-operate-text">{{ baTable.table.expandAll ? t('Shrink all') : t('Expand all') }}</span>
</el-button>
</el-tooltip>
<!-- slot -->
<slot></slot>
<!-- 右侧搜索框和工具按钮 -->
<div class="table-search">
<slot name="quickSearchPrepend"></slot>
<el-input
v-if="props.buttons.includes('quickSearch')"
v-model="baTable.table.filter!.quickSearch"
class="xs-hidden quick-search"
@input="onSearchInput"
:placeholder="quickSearchPlaceholder ? quickSearchPlaceholder : t('Search')"
clearable
/>
<div class="table-search-button-group" v-if="props.buttons.includes('columnDisplay') || props.buttons.includes('comSearch')">
<el-dropdown v-if="props.buttons.includes('columnDisplay')" :max-height="380" :hide-on-click="false">
<el-button
class="table-search-button-item"
:class="props.buttons.includes('comSearch') ? 'right-border' : ''"
color="#dcdfe6"
plain
v-blur
>
<Icon size="14" name="el-icon-Grid" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="(item, idx) in columnDisplay" :key="idx">
<el-checkbox
v-if="item.prop"
@change="onChangeShowColumn($event, item.prop!)"
:checked="!item.show"
:model-value="item.show"
size="small"
:label="item.label"
/>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip
v-if="props.buttons.includes('comSearch')"
:disabled="baTable.table.showComSearch"
:content="t('Expand generic search')"
placement="top"
>
<el-button
class="table-search-button-item"
@click="baTable.table.showComSearch = !baTable.table.showComSearch"
color="#dcdfe6"
plain
v-blur
>
<Icon size="14" name="el-icon-Search" />
</el-button>
</el-tooltip>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import ComSearch from '/@/components/table/comSearch/index.vue'
import type baTableClass from '/@/utils/baTable'
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
interface Props {
buttons: HeaderOptButton[]
quickSearchPlaceholder?: string
}
const props = withDefaults(defineProps<Props>(), {
buttons: () => {
return ['refresh', 'add', 'edit', 'delete']
},
quickSearchPlaceholder: '',
})
const columnDisplay = computed(() => {
let columnDisplayArr = []
for (let item of baTable.table.column) {
item.type === 'selection' || item.render === 'buttons' || item.enableColumnDisplayControl === false ? '' : columnDisplayArr.push(item)
}
return columnDisplayArr
})
const enableBatchOpt = computed(() => (baTable.table.selection!.length > 0 ? true : false))
const onAction = (event: BaTableHeaderActionEventName, data: anyObj = {}) => {
baTable.onTableHeaderAction(event, data)
}
const onSearchInput = debounce(() => {
baTable.onTableHeaderAction('quick-search', { keyword: baTable.table.filter!.quickSearch })
}, 500)
const onChangeShowColumn = (value: string | number | boolean, field: string) => {
baTable.onTableHeaderAction('change-show-column', { field: field, value: value })
}
</script>
<style scoped lang="scss">
.table-header {
position: relative;
overflow-x: auto;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
border: 1px solid var(--ba-border-color);
border-bottom: none;
padding: 13px 15px;
font-size: 14px;
.table-header-operate-text {
margin-left: 6px;
}
}
.btns-ml-12 + .btns-ml-12 {
margin-left: 12px;
}
.table-search {
display: flex;
margin-left: auto;
.quick-search {
width: auto;
}
}
.table-search-button-group {
display: flex;
margin-left: 12px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
overflow: hidden;
button:focus,
button:active {
background-color: var(--ba-bg-color-overlay);
}
button:hover {
background-color: var(--el-color-info-light-7);
}
.table-search-button-item {
height: 30px;
border: none;
border-radius: 0;
}
.el-button + .el-button {
margin: 0;
}
.right-border {
border-right: 1px solid var(--el-border-color);
}
}
html.dark {
.table-search-button-group {
button:focus,
button:active {
background-color: var(--el-color-info-dark-2);
}
button:hover {
background-color: var(--el-color-info-light-7);
}
button {
background-color: var(--ba-bg-color-overlay);
el-icon {
color: white !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,141 @@
import { TableColumnCtx } from 'element-plus'
import { isUndefined } from 'lodash-es'
import { i18n } from '/@/lang/index'
/**
* 获取单元格值
*/
export const getCellValue = (row: TableRow, field: TableColumn, column: TableColumnCtx<TableRow>, index: number) => {
if (!field.prop) return ''
const prop = field.prop
let cellValue: any = row[prop]
// 字段 prop 带 . 比如 user.nickname
if (prop.indexOf('.') > -1) {
const fieldNameArr = prop.split('.')
cellValue = row[fieldNameArr[0]]
for (let index = 1; index < fieldNameArr.length; index++) {
cellValue = cellValue ? (cellValue[fieldNameArr[index]] ?? '') : ''
}
}
// 若无值,尝试取默认值
if ([undefined, null, ''].includes(cellValue) && field.default !== undefined) {
cellValue = field.default
}
// 渲染前格式化
if (field.renderFormatter && typeof field.renderFormatter == 'function') {
cellValue = field.renderFormatter(row, field, cellValue, column, index)
console.warn('baTable.table.column.renderFormatter 即将废弃,请直接使用兼容 el-table 的 baTable.table.column.formatter 代替')
}
if (field.formatter && typeof field.formatter == 'function') {
cellValue = field.formatter(row, column, cellValue, index)
}
return cellValue
}
/*
* 默认按钮组
*/
export const defaultOptButtons = (optButType: DefaultOptButType[] = ['weigh-sort', 'edit', 'delete']): OptButton[] => {
const optButtonsPre: Map<string, OptButton> = new Map([
[
'weigh-sort',
{
render: 'moveButton',
name: 'weigh-sort',
title: 'Drag sort',
text: '',
type: 'info',
icon: 'fa fa-arrows',
class: 'table-row-weigh-sort',
disabledTip: false,
},
],
[
'edit',
{
render: 'tipButton',
name: 'edit',
title: 'Edit',
text: '',
type: 'primary',
icon: 'fa fa-pencil',
class: 'table-row-edit',
disabledTip: false,
},
],
[
'delete',
{
render: 'confirmButton',
name: 'delete',
title: 'Delete',
text: '',
type: 'danger',
icon: 'fa fa-trash',
class: 'table-row-delete',
popconfirm: {
confirmButtonText: i18n.global.t('Delete'),
cancelButtonText: i18n.global.t('Cancel'),
confirmButtonType: 'danger',
title: i18n.global.t('Are you sure to delete the selected record?'),
},
disabledTip: false,
},
],
])
const optButtons: OptButton[] = []
for (const key in optButType) {
if (optButtonsPre.has(optButType[key])) {
optButtons.push(optButtonsPre.get(optButType[key])!)
}
}
return optButtons
}
/**
* 将带children的数组降维然后寻找index所在的行
*/
export const findIndexRow = (data: TableRow[], findIdx: number, keyIndex: number | TableRow = -1): number | TableRow => {
for (const key in data) {
if (typeof keyIndex == 'number') {
keyIndex++
}
if (keyIndex == findIdx) {
return data[key]
}
if (data[key].children) {
keyIndex = findIndexRow(data[key].children!, findIdx, keyIndex)
if (typeof keyIndex != 'number') {
return keyIndex
}
}
}
return keyIndex
}
/**
* 调用一个接受表格上下文数据的任意属性计算函数
*/
export const invokeTableContextDataFun = <T>(
fun: TableContextDataFun<T> | undefined,
context: TableContextData,
defaultValue: any = {}
): Partial<T> => {
if (isUndefined(fun)) {
return defaultValue
} else if (typeof fun === 'function') {
return fun(context)
}
return fun
}
type DefaultOptButType = 'weigh-sort' | 'edit' | 'delete'

View File

@@ -0,0 +1,246 @@
<template>
<div>
<slot name="neck"></slot>
<el-table
ref="tableRef"
class="ba-data-table w100"
header-cell-class-name="table-header-cell"
:default-expand-all="baTable.table.expandAll"
:data="baTable.table.data"
:row-key="baTable.table.pk"
:border="true"
v-loading="baTable.table.loading"
stripe
@select-all="onSelectAll"
@select="onSelect"
@selection-change="onSelectionChange"
@sort-change="onSortChange"
@row-dblclick="baTable.onTableDblclick"
v-bind="$attrs"
>
<slot name="columnPrepend"></slot>
<template v-for="(item, key) in baTable.table.column">
<template v-if="item.show !== false">
<!-- 渲染为 slot -->
<slot v-if="item.render == 'slot'" :name="item.slotName"></slot>
<el-table-column
v-else
:key="key + '-column'"
v-bind="item"
:column-key="(item['columnKey'] ? item['columnKey'] : `table-column-${item.prop}`) || shortUuid()"
>
<!-- ./fieldRender/ 文件夹内的每个组件为一种字段渲染器组件名称为渲染器名称 -->
<template v-if="item.render" #default="scope">
<component
:row="scope.row"
:field="item"
:column="scope.column"
:index="scope.$index"
:is="fieldRenderer[item.render] ?? fieldRenderer['default']"
:key="getRenderKey(key, item, scope)"
/>
</template>
</el-table-column>
</template>
</template>
<slot name="columnAppend"></slot>
</el-table>
<div v-if="props.pagination" class="table-pagination">
<el-pagination
:currentPage="baTable.table.filter!.page"
:page-size="baTable.table.filter!.limit"
:page-sizes="pageSizes"
background
:layout="config.layout.shrink ? 'prev, next, jumper' : 'sizes,total, ->, prev, pager, next, jumper'"
:total="baTable.table.total"
@size-change="onTableSizeChange"
@current-change="onTableCurrentChange"
></el-pagination>
</div>
<slot name="footer"></slot>
</div>
</template>
<script setup lang="ts">
import type { ElTable } from 'element-plus'
import type { Component } from 'vue'
import { computed, inject, nextTick, useTemplateRef } from 'vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { shortUuid } from '/@/utils/random'
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const baTable = inject('baTable') as baTableClass
type ElTableProps = Partial<InstanceType<typeof ElTable>['$props']>
interface Props extends /* @vue-ignore */ ElTableProps {
pagination?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pagination: true,
})
const fieldRenderer: Record<string, Component> = {}
const fieldRendererComponents: Record<string, any> = import.meta.glob('./fieldRender/**.vue', { eager: true })
for (const key in fieldRendererComponents) {
const fileName = key.replace('./fieldRender/', '').replace('.vue', '')
fieldRenderer[fileName] = fieldRendererComponents[key].default
}
const getRenderKey = (key: number, item: TableColumn, scope: any) => {
if (item.getRenderKey && typeof item.getRenderKey == 'function') {
return item.getRenderKey(scope.row, item, scope.column, scope.$index)
}
if (item.render == 'switch') {
return item.render + item.prop
}
return key + scope.$index + '-' + item.render + '-' + (item.prop ? '-' + item.prop + '-' + scope.row[item.prop] : '')
}
const onTableSizeChange = (val: number) => {
baTable.onTableAction('page-size-change', { size: val })
}
const onTableCurrentChange = (val: number) => {
baTable.onTableAction('current-page-change', { page: val })
}
const onSortChange = ({ order, prop }: { order: string; prop: string }) => {
baTable.onTableAction('sort-change', { prop: prop, order: order ? (order == 'ascending' ? 'asc' : 'desc') : '' })
}
const pageSizes = computed(() => {
let defaultSizes = [10, 20, 50, 100]
if (baTable.table.filter!.limit) {
if (!defaultSizes.includes(baTable.table.filter!.limit)) {
defaultSizes.push(baTable.table.filter!.limit)
}
}
return defaultSizes
})
/*
* 全选和取消全选
* 实现子级同时选择和取消选中
*/
const onSelectAll = (selection: TableRow[]) => {
if (isSelectAll(selection.map((row: TableRow) => row[baTable.table.pk!].toString()))) {
selection.map((row: TableRow) => {
if (row.children) {
selectChildren(row.children, true)
}
})
} else {
tableRef.value?.clearSelection()
}
}
/*
* 是否是全选操作
* 只检查第一个元素是否被选择
* 全选时selectIds为所有元素的id
* 取消全选时selectIds为所有子元素的id
*/
const isSelectAll = (selectIds: string[]) => {
let data = baTable.table.data as TableRow[]
for (const key in data) {
return selectIds.includes(data[key][baTable.table.pk!].toString())
}
return false
}
/*
* 选择子项-递归
*/
const selectChildren = (children: TableRow[], type: boolean) => {
children.map((j: TableRow) => {
toggleSelection(j, type)
if (j.children) {
selectChildren(j.children, type)
}
})
}
/*
* 执行选择操作
*/
const toggleSelection = (row: TableRow, type: boolean) => {
if (row) {
nextTick(() => {
tableRef.value?.toggleRowSelection(row, type)
})
}
}
/*
* 手动选择时,同时选择子级
*/
const onSelect = (selection: TableRow[], row: TableRow) => {
if (
selection.some((item: TableRow) => {
return row[baTable.table.pk!] === item[baTable.table.pk!]
})
) {
if (row.children) {
selectChildren(row.children, true)
}
} else {
if (row.children) {
selectChildren(row.children, false)
}
}
}
/*
* 记录选择的项
*/
const onSelectionChange = (selection: TableRow[]) => {
baTable.onTableAction('selection-change', selection)
}
/*
* 设置折叠所有-递归
*/
const setUnFoldAll = (children: TableRow[], unfold: boolean) => {
for (const key in children) {
tableRef.value?.toggleRowExpansion(children[key], unfold)
if (children[key].children) {
setUnFoldAll(children[key].children!, unfold)
}
}
}
/*
* 折叠所有
*/
const unFoldAll = (unfold: boolean) => {
setUnFoldAll(baTable.table.data!, unfold)
}
const getRef = () => {
return tableRef.value
}
defineExpose({
unFoldAll,
getRef,
})
</script>
<style scoped lang="scss">
.ba-data-table :deep(.table-header-cell) .cell {
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-pagination {
box-sizing: border-box;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
padding: 13px 15px;
}
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div>
<el-dialog v-model="terminal.state.show" :title="t('terminal.Terminal')" class="ba-terminal-dialog main-dialog">
<el-scrollbar ref="terminalScrollbarRef" :max-height="500" class="terminal-scrollbar">
<el-alert
class="terminal-warning-alert"
v-if="!terminal.state.phpDevelopmentServer"
:title="t('terminal.The current terminal is not running under the installation service, and some commands may not be executed')"
type="error"
/>
<el-timeline class="terminal-timeline" v-if="terminal.state.taskList.length">
<el-timeline-item
v-for="(item, idx) in terminal.state.taskList"
:key="idx"
class="task-item"
:class="'task-status-' + item.status"
:type="getTaskStatus(item.status)['statusType']"
center
:timestamp="item.createTime"
placement="top"
>
<el-card>
<div>
<el-tag :type="getTaskStatus(item.status)['statusType']">{{ getTaskStatus(item.status)['statusText'] }}</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="(item.status == taskStatus.Failed || item.status == taskStatus.Unknown) && item.blockOnFailure"
type="warning"
>
{{ t('terminal.Failure to execute this command will block the execution of the queue') }}
</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="item.status == taskStatus.Executing || item.status == taskStatus.Connecting"
type="danger"
>
{{ t('terminal.Do not refresh the browser') }}
</el-tag>
<span class="command">{{ item.command }}</span>
<div class="task-opt">
<el-button
:title="t('Retry')"
v-if="item.status == taskStatus.Failed || item.status == taskStatus.Unknown"
size="small"
v-blur
type="warning"
icon="el-icon-RefreshRight"
circle
@click="terminal.retryTask(idx)"
/>
<el-button
@click="terminal.delTask(idx)"
:title="t('Delete')"
size="small"
v-blur
type="danger"
icon="el-icon-Delete"
circle
/>
</div>
</div>
<template v-if="item.status != taskStatus.Waiting">
<div
v-if="item.status != taskStatus.Connecting && item.status != taskStatus.Executing"
@click="terminal.setTaskShowMessage(idx)"
class="toggle-message-display"
>
<span>{{ t('terminal.Command run log') }}</span>
<Icon :name="item.showMessage ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'" size="16" color="#909399" />
</div>
<div
v-if="
item.status == taskStatus.Connecting ||
item.status == taskStatus.Executing ||
(item.status > taskStatus.Executing && item.showMessage)
"
class="exec-message"
:class="'exec-message-' + item.uuid"
>
<pre v-for="(msg, index) in item.message" :key="index" class="message-item">{{ msg }}</pre>
</div>
</template>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else :image-size="80" :description="t('terminal.No mission yet')" />
</el-scrollbar>
<div class="terminal-buttons">
<el-button class="terminal-menu-item" icon="el-icon-MagicStick" v-blur @click="addTerminalTask('test', true, false)">
{{ t('terminal.Test command') }}
</el-button>
<el-dropdown class="terminal-menu-item">
<el-button icon="el-icon-Download" v-blur>
{{ t('terminal.Install dependent packages') }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="addTerminalTask('web-install', true)" v-if="terminal.state.packageManager != 'none'">
{{ terminal.state.packageManager }} run install
</el-dropdown-item>
<el-dropdown-item @click="addTerminalTask('composer.update', false)">composer update</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="terminal-menu-item" icon="el-icon-Sell" v-blur @click="webBuild()">{{ t('terminal.Republish') }}</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Delete" v-blur @click="terminal.clearSuccessTask()">
{{ t('terminal.Clean up task list') }}
</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Tools" v-blur @click="terminal.toggleConfigDialog()">
{{ t('terminal.Terminal settings') }}
</el-button>
</div>
</el-dialog>
<el-dialog
@close="terminal.toggleConfigDialog(false)"
:model-value="terminal.state.showConfig"
class="ba-terminal-dialog"
:title="t('terminal.Terminal settings')"
>
<el-form label-position="left" label-width="140">
<FormItem
:label="'NPM ' + t('terminal.Source')"
:model-value="terminal.state.npmRegistry"
:key="terminal.state.npmRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'npm'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('npm'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'npm'),
}"
/>
<FormItem
:label="'Composer ' + t('terminal.Source')"
:model-value="terminal.state.composerRegistry"
:key="terminal.state.composerRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'composer'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('composer'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'composer'),
}"
/>
<FormItem
:label="t('terminal.NPM package manager')"
:model-value="terminal.state.packageManager"
v-loading="state.packageManagerLoading"
type="select"
:input-attr="{
border: true,
content: { npm: 'NPM', cnpm: 'CNPM', pnpm: 'PNPM', yarn: 'YARN', ni: 'NI', none: t('terminal.Manual execution') },
teleported: false,
onChange: (val: string) => changePackageManager(val),
}"
:tip="t('terminal.NPM package manager tip')"
/>
<FormItem
:label="t('terminal.Clear successful task')"
:model-value="terminal.state.automaticCleanupTask"
type="radio"
:input-attr="{
border: true,
content: { '0': t('Disable'), '1': t('Enable') },
onChange: terminal.changeAutomaticCleanupTask,
}"
:tip="t('terminal.Clear successful task tip')"
/>
</el-form>
<div class="config-buttons">
<el-button @click="terminal.toggleConfigDialog(false)">{{ t('terminal.Back to terminal') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { TimelineItemProps } from 'element-plus'
import { ElMessageBox, ElScrollbar } from 'element-plus'
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { postChangeTerminalConfig } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { changeListenDirtyFileSwitch } from '/@/utils/vite'
type SourceType = 'npm' | 'composer'
const { t } = useI18n()
const terminal = useTerminal()
const terminalScrollbarRef = useTemplateRef('terminalScrollbarRef')
const state = reactive({
registryLoading: false,
registryLoadingType: 'npm',
packageManagerLoading: false,
})
const getTaskStatus = (status: number) => {
let statusText = t('terminal.unknown')
let statusType: TimelineItemProps['type'] = 'info'
switch (status) {
case taskStatus.Waiting:
statusText = t('terminal.Waiting for execution')
statusType = 'info'
break
case taskStatus.Connecting:
statusText = t('terminal.Connecting')
statusType = 'warning'
break
case taskStatus.Executing:
statusText = t('terminal.Executing')
statusType = 'warning'
break
case taskStatus.Success:
statusText = t('terminal.Successful execution')
statusType = 'success'
break
case taskStatus.Failed:
statusText = t('terminal.Execution failed')
statusType = 'danger'
break
case taskStatus.Unknown:
statusText = t('terminal.Unknown execution result')
statusType = 'danger'
break
}
return {
statusText: statusText,
statusType: statusType,
}
}
const addTerminalTask = (command: string, pm: boolean, blockOnFailure = true, extend = '', callback: Function = () => {}) => {
if (pm) {
terminal.addTaskPM(command, blockOnFailure, extend, callback)
} else {
terminal.addTask(command, blockOnFailure, extend, callback)
}
// 任务列表滚动条滚动到底部
nextTick(() => {
if (terminalScrollbarRef.value && terminalScrollbarRef.value.wrapRef) {
terminalScrollbarRef.value.setScrollTop(terminalScrollbarRef.value.wrapRef.scrollHeight)
}
})
}
const webBuild = () => {
ElMessageBox.confirm(t('terminal.Are you sure you want to republish?'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
changeListenDirtyFileSwitch(false)
addTerminalTask('web-build', true, true, '', () => {
changeListenDirtyFileSwitch(true)
})
})
}
const changePackageManager = (val: string) => {
state.packageManagerLoading = true
postChangeTerminalConfig({ manager: val })
.then((res) => {
if (res.code == 1) {
terminal.changePackageManager(val)
}
})
.finally(() => {
state.packageManagerLoading = false
})
}
const changeRegistry = (val: string, type: SourceType) => {
const oldVal = type == 'npm' ? terminal.state.npmRegistry : terminal.state.composerRegistry
terminal.changeRegistry(val, type)
state.registryLoading = true
state.registryLoadingType = type
terminal.addTask(`set-${type}-registry.${val}`, false, '', (res: taskStatus) => {
state.registryLoading = false
if (res == taskStatus.Failed || res == taskStatus.Unknown) {
ElMessageBox.confirm(t('terminal.Failed to modify the source command, Please try again manually'), t('Reminder'), {
confirmButtonText: t('Confirm'),
showCancelButton: false,
type: 'warning',
}).then(() => {
terminal.changeRegistry(oldVal, type)
})
}
})
}
const getSourceContent = (type: SourceType) => {
let content: anyObj = {}
if (type == 'npm') {
content = { npm: 'npm', taobao: 'taobao', tencent: 'tencent' }
} else if (type == 'composer') {
content = {
composer: 'composer',
huawei: 'huawei',
aliyun: 'aliyun',
tencent: 'tencent',
kkame: 'kkame',
}
}
// 如果值为 unknown则 unknown 选项
if (terminal.state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] == 'unknown') {
content.unknown = t('Unknown')
}
return content
}
onMounted(() => {
terminal.init()
})
</script>
<style scoped lang="scss">
.terminal-warning-alert {
margin: 0 0 20px 0;
}
.terminal-timeline {
padding: 0 15px;
}
.command {
font-size: var(--el-font-size-large);
font-weight: bold;
margin-left: 10px;
}
.exec-message {
color: var(--ba-bg-color-overlay);
font-size: 12px;
line-height: 16px;
padding: 6px;
background-color: #424251;
margin-top: 10px;
min-height: 30px;
max-height: 200px;
overflow: auto;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: var(--ba-bg-color);
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #909399;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.exec-message {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
.toggle-message-display {
padding-top: 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.task-opt {
display: none;
float: right;
}
.task-item.task-status-0:hover,
.task-item.task-status-3:hover,
.task-item.task-status-4:hover,
.task-item.task-status-5:hover {
.task-opt {
display: inline;
}
}
.block-on-failure-tag {
margin-left: 10px;
}
.terminal-menu-item {
margin-bottom: 12px;
}
.terminal-menu-item + .terminal-menu-item {
margin-left: 12px;
margin-bottom: 12px;
}
.terminal-buttons {
display: block;
width: fit-content;
margin: 0 auto;
padding-top: 12px;
}
.config-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 20px;
padding-right: 20px;
}
:deep(.main-dialog) {
--el-dialog-padding-primary: 16px 16px 0 16px;
.el-dialog__body {
margin-top: 16px;
}
}
:deep(.ba-terminal-dialog) {
--el-dialog-width: 46% !important;
.el-loading-spinner {
--el-loading-spinner-size: 20px;
}
}
@media screen and (max-width: 768px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 540px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 94% !important;
}
}
</style>

14
web/src/lang/autoload.ts Normal file
View File

@@ -0,0 +1,14 @@
import { adminBaseRoutePath } from '/@/router/static/adminBase'
/*
* 语言包按需加载映射表
* 使用固定字符串 ${lang} 指代当前语言
* key 为页面 pathvalue 为语言包文件相对路径,访问时,按需自动加载映射表的语言包,同时加载 path 对应的语言包(若存在)
*/
export default {
'/': ['./frontend/${lang}/index.ts'],
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
}

102
web/src/lang/backend/en.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* backend common language package
*/
export default {
Balance: 'Balance',
Integral: 'Integral',
Connection: 'connection',
'Database connection': 'Database connection',
'Database connection help': 'You can configure multiple database connections in config/database.php and select it here',
layouts: {
'Layout configuration': 'Layout configuration',
'Layout mode': 'Layout mode',
default: 'Default',
classic: 'Classic',
'Single column': 'Single Column',
'overall situation': 'Global',
'Background page switching animation': 'Background page switching animation',
'Please select an animation name': 'Please select an animation name',
sidebar: 'Sidebar',
'Side menu bar background color': 'Background color of the side menu bar',
'Side menu text color': 'Side menu text color',
'Side menu active item background color': 'Background color of the side menu activation item',
'Side menu active item text color': 'Side menu activation item text color',
'Show side menu top bar (logo bar)': 'Display the top bar of the side menu (Logo Bar)',
'Side menu top bar background color': 'Background color of the top bar of the side menu ',
'Side menu width (when expanded)': 'Width of the side menu (Unfolding)',
'Side menu default icon': 'Side menu default icon',
'Side menu horizontal collapse': 'Side menu collapsed horizontally',
'Side menu accordion': 'Side menu accordion',
'Top bar': 'Top bar',
'Top bar background color': 'Top bar background color',
'Top bar text color': 'Top bar text color',
'Background color when hovering over the top bar': 'Top bar hover background color',
'Top bar menu active item background color': 'Background color of the top bar activation item',
'Top bar menu active item text color': 'Top bar menu activation item text color',
'Are you sure you want to restore all configurations to the default values?':
'Are you sure to restore all configurations to the default values?',
'Restore default': 'Restore default',
Profile: 'Profile',
Logout: 'Logout',
'Dark mode': 'Dark mode',
'Exit full screen': 'Exit Full Screen',
'Full screen is not supported': 'Your browser does not support full screen, please change another browser and try again~',
'Member center': 'Member center',
'Member information': 'Member information',
'Login to the buildadmin': 'Login to the buildadmin',
'Please enter buildadmin account name or email': 'Please enter buildadmin account name or email',
'Please enter the buildadmin account password': 'Please enter the buildadmin account password',
Login: 'Login',
Password: 'Password',
Username: 'Username',
Register: 'Register',
},
terminal: {
Source: 'source',
Terminal: 'Terminal',
'Command run log': 'Command Run Log',
'No mission yet': 'There is no task yet',
'Test command': 'Test command',
'Install dependent packages': 'Install dependent packages',
Republish: 'Republish',
'Clean up task list': 'Clean up the task list',
unknown: 'Unknown',
'Waiting for execution': 'Waiting for execution',
Connecting: 'Connecting',
Executing: 'Executing',
'Successful execution': 'Executed successfully',
'Execution failed': 'Failed to execute',
'Unknown execution result': 'Execution result is unknown',
'Are you sure you want to republish?': 'Are you sure to republish?',
'Failure to execute this command will block the execution of the queue': 'Failed to execute this command will block queue execution.',
'NPM package manager': 'NPM package manager',
'NPM package manager tip': 'Select an available package manager for the execution of commands such as npm install in the WEB terminal',
'Clear successful task': 'Clear successful task',
'Clear successful task tip': 'When you start a new task, automatically clear the list of already successful tasks',
'Manual execution': 'Manual execution',
'Do not refresh the browser': 'Do not refresh your browser.',
'Terminal settings': 'Terminal setup',
'Back to terminal': 'Back to terminal',
or: 'or',
'Site domain name': 'Site domain name',
'The current terminal is not running under the installation service, and some commands may not be executed':
'The current terminal is not running under the installation service, and some commands may not be executed.',
'Newly added tasks will never start because they are blocked by failed tasks':
'Newly added tasks will never start because they are blocked by failed tasks!(Web terminal)',
'Failed to modify the source command, Please try again manually': 'Failed to modify the source command. Please try again manually.',
},
vite: {
Later: '稍后',
'Restart hot update': '重启热更新',
'Close type terminal': 'WEB Terminal server',
'Close type crud': 'CRUD server',
'Close type modules': 'module install server',
'Close type config': 'system configuration server',
'Reload hot server title': 'Need to restart Vite hot update service',
'Reload hot server tips 1': 'To ensure that ',
'Reload hot server tips 2':
" is not interrupted, the system has temporarily suspended Vite's hot update function. During this period, changes to front-end files will not be updated in real-time and web pages will not be automatically reloaded. It has been detected that there are file updates during the service suspension period, and the hot update service needs to be restarted.",
'Reload hot server tips 3':
'The pause of hot updates does not affect the already loaded functions. You can continue to operate and click to restart the hot update service after everything is ready.',
},
}

View File

@@ -0,0 +1,13 @@
export default {
username: 'Username',
nickname: 'Nickname',
group: 'Group',
avatar: 'Avatar',
email: 'Email',
mobile: 'Mobile Number',
'Last login': 'Last login',
Password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
'Personal signature': 'Personal Signature',
'Administrator login': 'Administrator Login Name',
}

View File

@@ -0,0 +1,12 @@
export default {
admin_id: 'Manage ID',
username: 'Manage Username',
title: 'Title',
data: 'Request Data',
url: 'URL',
ip: 'IP',
useragent: 'UserAgent',
'Operation administrator': 'Operation administrator',
'Operator IP': 'Operator IP',
'Request data': 'Request Data',
}

View File

@@ -0,0 +1,9 @@
export default {
GroupName: 'Group Name',
'Group name': 'Group Name',
jurisdiction: 'Permissions',
'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself',
'Manage subordinate role groups here':
'In managing a subordinate role group (excluding a peer role group), you have all the rights of a subordinate role group and additional rights',
}

View File

@@ -0,0 +1,51 @@
export default {
title: 'Title',
Icon: 'Icon',
name: 'Name',
type: 'Type',
cache: 'Cache',
'Superior menu rule': 'Superior menu rules',
'Rule type': 'Rule type',
'type menu_dir': 'Menu directory',
'type menu': 'Menu item',
'type button': 'Page button',
'Rule title': 'Rule title',
'Rule name': 'Rule name',
'Routing path': 'Routing path',
'Rule Icon': 'Rule Icon',
'Menu type': 'Menu type',
'Menu type tab': 'Tab',
'Menu type link (offsite)': 'Link (off-site)',
'Link address': 'Link address',
'Component path': 'Component path',
'Extended properties': 'Extended properties',
'Add as route only': 'Add as route only',
'Add as menu only': 'Add as menu only',
'Rule comments': 'Rule comments',
'Rule weight': 'Rule weights',
'Create Page Button': 'Create Page Button',
'Create Page Button index': 'index',
'Create Page Button add': 'add',
'Create Page Button edit': 'edit',
'Create Page Button del': 'del',
'Create Page Button sortable': 'sortable',
'Create Page Button tips': 'When creating the menu, automatically create the menu page buttons (permission nodes)',
'Please select the button for automatically creating the desired page': 'Please select the button for automatically creating the desired page',
'Please enter the weight of menu rule (sort by)': 'Please enter the menu rule weights (sort by)',
'Please enter the correct URL': 'Please enter the correct URL',
'The superior menu rule cannot be the rule itself': 'The superior menu rules cannot be rules itself.',
'It will be registered as the web side routing name and used as the server side API authentication':
'It will be registered as the routing name of the webside and used as a server-side API authentication at the same time.',
'Please enter the URL address of the link or iframe': 'Please enter the link or the URL address of iframe.',
'English name, which does not need to start with `/admin`, such as auth/menu':
'The English name does not need to start with `/admin`, such as: auth/menu.',
'Web side component path, please start with /src, such as: /src/views/backend/dashboard':
'Please start with /src for web side component paths, such as: /src/views/backend/dashboard.vue',
'The web side routing path (path) does not need to start with `/admin`, such as auth/menu':
'The web side routing path (Path) does not need to start with `/admin`, such as: auth/menu.',
'Use in controller `get_ route_ Remark()` function, which can obtain the value of this field for your own use, such as the banner file of the console':
'Use the `get_route_remark()` function in the controller can get the value of this field for your own use, such as the banner file for the console.',
'extend Title':
"For example, if 'auth/menu' is only added as a route, then `auth/menu`, `auth/menu/:a` and `auth/menu/:b/:c` can be added only as menus.",
none: 'None',
}

View File

@@ -0,0 +1,173 @@
export default {
show: 'Show in Table Columns',
width: 'Width',
sortable: 'sortable',
operator: 'Search operator',
comSearchRender: 'Common search render',
comSearchInputAttr: 'Common search render input extend properties',
comSearchInputAttrTip: 'Remote pull-down field, no need to fill in the mandatory attributes of the component.',
render: 'table column render',
timeFormat: 'Format',
step: 'Step',
rows: 'Rows',
'api url': 'api url',
'api url example': 'For example: /admin/user.User/index',
'remote-pk': 'value field',
'remote-field': 'label field',
'remote-url': 'remote URL',
'remote-table': 'remote table',
'remote-controller': 'remote controller',
'remote-model': 'remote model',
'remote-primary-table-alias': 'primary table alias',
'relation-fields': 'relation fields',
'image-multi': 'Multiple upload',
'file-multi': 'Multiple upload',
'select-multi': 'Multiple',
validator: 'validator',
validatorMsg: 'validator error message',
copy: 'Copy',
'CRUD record': 'CRUD record',
'Delete Code': 'Delete Code',
'Start CRUD design with this record?': 'Start CRUD design with this record?',
'Are you sure to delete the generated CRUD code?': 'Are you sure to delete the generated CRUD code?',
start: 'Start',
create: 'Create',
or: ' or ',
'New background CRUD from zero': 'New background CRUD from zero',
'Select Data Table': 'Select data table',
'Select a designed data table from the database': 'Select a designed data table from the database',
'Start with previously generated CRUD code': 'Start with previously generated CRUD code',
'Fast experience': 'Fast experience',
'Please enter SQL': 'Please enter SQL',
'Please select a data table': 'Please select a data table',
'data sheet help': 'The table prefix must be the same as the table prefix configured for the project',
'data sheet': 'data sheet',
'table create SQL': 'table creation SQL',
'Please enter the table creation SQL': 'Please enter the table creation SQL',
'experience 1 1': 'Prepare the ',
'experience 1 2': 'development environment',
'experience 1 3': '(The site port is 1818)',
'experience 2 1': 'On this page, click to',
'experience 2 2': 'Select data table',
'experience 2 3': '(You can select the test_build data table)',
'experience 3 1': 'Click',
'experience 3 2': 'Generate CRUD Code',
'experience 3 3': ', and click ',
'experience 3 4': 'Continue to Generate',
'experience 4 1': 'You are not currently in the development environment, ',
'experience 4 2': 'please set up the development environment',
'experience 4 3': ', or after generating the code, click on the upper right corner of the terminal to',
'experience 4 4': 'Republish',
// design
'Name of the data table': 'Name of the data table',
'Data Table Notes': 'Data Table Notes',
'Generate CRUD code': 'Generate CRUD code',
'give up': 'give up',
'Table Quick Search Fields': 'Table Quick Search Fields',
'Table Default Sort Fields': 'Table Default Sort Fields',
'sort order': 'sort order',
'sort order asc': 'asc',
'sort order desc': 'desc',
'Fields as Table Columns': 'Fields as Table Columns',
'Fields as form items': 'Fields as form items',
'The relative path to the generated code': 'The relative path to the generated code',
'For quick combination code generation location, please fill in the relative path':
'For quick combination code generation location, please fill in the relative path',
'Generated Controller Location': 'Generated Controller Location',
'Generated Data Model Location': 'Generated Data Model Location',
'Generated Validator Location': 'Generated Validator Location',
'Common model': 'Common model',
'WEB end view directory': 'WEB end view directory',
'Check model class': "Check whether protected $connection = '{connection}'; is configured in the above data model class",
'There is no connection attribute in model class': 'If no configuration is available, you can configure it manually',
'Advanced Configuration': 'Advanced Configuration',
'Common Fields': 'Common Fields',
'Base Fields': 'Base Fields',
'Advanced Fields': 'Advanced Fields',
'Field Name': 'Field Name',
'field comment': 'field comment',
'Please select a field from the left first': 'Please select a field from the left first',
Common: 'Common',
'Generate type': 'generate type',
'Field comments (CRUD dictionary)': 'Field comments (CRUD dictionary)',
'Field Properties': 'Field Properties',
'Field Type': 'Field Type',
length: 'length',
'decimal point': 'decimal point',
'Field Defaults': 'Field Defaults',
'Please input the default value': 'Please input the default value',
'Auto increment': 'Auto increment',
Unsigned: 'Unsigned',
'Allow NULL': 'Allow NULL',
'Field Form Properties': 'Field Form Properties',
'Field Table Properties': 'Field Table Properties',
'Remote drop-down association information': 'Remote drop-down association information',
'Associated Data Table': 'Associated Data Table',
'Drop down value field': 'Drop down value field',
'Drop down label field': 'Drop down label field',
'Please select the value field of the select component': 'Please select the value field of the select component',
'Please select the label field of the select component': 'Please select the label field of the select component',
'Fields displayed in the table': 'Fields displayed in the table',
'Please select the fields displayed in the table': 'Please select the fields displayed in the table',
'Controller position': 'Controller position',
'Please select the controller of the data table': 'Please select the controller of the data table',
'Data Model Location': 'Data Model Location',
'Data source configuration type': 'Data source configuration type',
'Fast configuration with generated controllers and models': 'Fast configuration with generated controllers and models',
'Custom configuration': 'Custom configuration',
'If the remote interface query involves associated query of multiple tables, enter the alias of the primary data table here':
'If the remote interface query involves associated query of multiple tables, enter the alias of the primary data table here',
'Please select the data model location of the data table': 'Please select the data model location of the data table',
'Confirm CRUD code generation': 'Confirm CRUD code generation',
'Continue building': 'Continue building',
'Please enter the data table name!': 'Please enter the data table name!',
'Please enter the correct table name!': 'Please enter the correct table name!',
'Use lower case underlined for table names': 'Use lower case underlined for table names',
'Please design the primary key field!': 'Please design the primary key field!',
'It is irreversible to give up the design Are you sure you want to give up?':
'It is irreversible to give up the design. Are you sure you want to give up?',
'There can only be one primary key field': 'There can only be one primary key field.',
'Drag the left element here to start designing CRUD': 'Drag the left element here to start designing CRUD',
'The data table already exists Continuing to generate will automatically delete the original table and create a new one!':
'The data table already exists Continuing to generate will automatically delete the original table and create a new one!',
'The controller already exists Continuing to generate will automatically overwrite the existing code!':
'The controller already exists Continuing to generate will automatically overwrite the existing code!',
'The menu rule with the same name already exists The menu and permission node will not be created in this generation':
'The menu rule with the same name already exists The menu and permission node will not be created in this generation',
'For example: `user table` will be generated into `user management`': 'For example: `user table` will be generated into `user management`',
'The remote pull-down will request the corresponding controller to obtain data, so it is recommended that you create the CRUD of the associated table':
'The remote pull-down will request the corresponding controller to obtain data, so it is recommended that you create the CRUD of the associated table',
'If it is left blank, the model of the associated table will be generated automatically If the table already has a model, it is recommended to select it to avoid repeated generation':
'If it is left blank, the model of the associated table will be generated automatically If the table already has a model, it is recommended to select it to avoid repeated generation',
'The field comment will be used as the CRUD dictionary, and will be identified as the field title before the colon, and as the data dictionary after the colon':
'The field comment will be used as the CRUD dictionary, and will be identified as the field title before the colon, and as the data dictionary after the colon',
'Field name is invalid It starts with a letter or underscore and cannot contain any character other than letters, digits, or underscores':
'Field name {field} is invalid. It starts with a letter or underscore and cannot contain any character other than letters, digits, or underscores',
'The selected table has already generated records You are advised to start with historical records':
'The selected table has already generated records. You are advised to start with historical records',
'Start with the historical record': 'Start with the historical record',
'Add field': 'Add field',
'Modify field properties': 'Modify field properties',
'Modify field name': 'Modify field name',
'Delete field': 'Delete field',
'Modify field order': 'Modify field order',
'First field': 'First field',
After: 'after',
'Table design change': 'Table design change',
'Data table design changes preview': 'Data table design changes preview',
designChangeTips: 'When unchecked, the change will not be synchronized to the data table (the table structure has been manually modified, etc)',
tableReBuild: 'Delete and rebuild',
tableReBuildBlockHelp:
'Deleting existing data tables and rebuilding them without adjusting the table structure ensures that CRUD code/records are consistent with the table structure',
Yes: 'Yes',
No: 'No',
'If the data is abnormal, repeat the previous step': 'If the data is abnormal, repeat the previous step',
'Field name duplication': 'field name {field} is duplicate',
'Design remote select tips':
'The name of the cost field is automatically generated from the table name; Confirm that when the field name user_id is generated, the association method generated by the field name user_id is named user, and the association method generated by the field name developer_done_id is named developerDone. Note that the name prefix of the remote drop-down field is not the same',
'Vite hot warning':
'Vite Hot Update service not found, please generate code in the development environment, or click the WEB terminal in the upper right corner to republish',
'Reset generate type attr':
'The field generation type has been changed. Do you want to reset the field design to the preset scheme for the new type?',
'Design efficiency': 'Determine design validity by yourself',
}

View File

@@ -0,0 +1,53 @@
export default {
id: 'id',
table_name: 'name',
comment: 'comment',
table: 'table',
fields: 'fields',
sync: 'sync',
'sync no': 'no',
'sync yes': 'yes',
status: 'status',
delete: 'delete code',
'status delete': 'status delete',
'status success': 'status success',
'status error': 'status error',
'status start': 'status start',
create_time: 'create_time',
'quick Search Fields': 'id,table_name,comment',
'Upload the selected design records to the cloud for cross-device use': 'Upload the selected design records to the cloud for cross-device use',
'Design records that have been synchronized to the cloud': 'Design records that have been synchronized to the cloud',
'Cloud record': 'Cloud record',
Settings: 'Settings',
'Login for backup design': 'Login for backup design',
'CRUD design record synchronization scheme': 'CRUD design record synchronization scheme',
Manual: 'Manual',
automatic: 'automatic',
'When automatically synchronizing records, share them to the open source community':
'When automatically synchronizing records, share them to the open source community',
'Not to share': 'Not to share',
Share: 'Share',
'Enabling sharing can automatically earn community points during development':
'Enabling sharing can automatically earn community points during development',
'The synchronized CRUD records are automatically resynchronized when they are updated':
'The synchronized CRUD records are automatically resynchronized when they are updated',
'Do not resynchronize': 'Do not resynchronize',
'Automatic resynchronization': 'Automatic resynchronization',
'No effective design': 'No effective design',
'Number of fields': 'Number of fields',
'Upload type': 'Upload type',
Update: 'Update',
'New added': 'New added',
'Share to earn points': 'Share to earn points',
'Share to the open source community': 'Share to the open source community',
'No design record': 'No design record',
Field: 'Field',
'Field information': 'Field information',
'No field': 'No field',
'Field name': 'Field name',
Note: 'Note',
Type: 'Type',
Load: 'Load',
'Delete cloud records?': 'Delete cloud records?',
'You can use the synchronized design records across devices': 'You can use the synchronized design records across devices',
}

View File

@@ -0,0 +1,21 @@
export default {
remarks: 'remarks',
'Primary key': 'Primary key',
'Primary key (Snowflake ID)': 'Primary key (Snowflake ID)',
'Disable Search': 'Disable Search',
'Weight (drag and drop sorting)': 'Weight (drag and drop sorting)',
'Status:0=Disabled,1=Enabled': 'Status:0=Disabled,1=Enabled',
'Remote Select (association table)': 'Remote Select (association table)',
'Remote Select (Multi)': 'Remote Select (Multi)',
'Radio:opt0=Option1,opt1=Option2': 'Radio:opt0=Option1,opt1=Option2',
'Checkbox:opt0=Option1,opt1=Option2': 'Checkbox:opt0=Option1,opt1=Option2',
Multi: '(Multi)',
'Select:opt0=Option1,opt1=Option2': 'Select:opt0=Option1,opt1=Option2',
'Switch:0=off,1=on': 'Switch:0=off,1=on',
'Time date (timestamp storage)': 'Time date (timestamp storage)',
'If left blank, the verifier title attribute will be filled in automatically':
'If left blank, the verifier title attribute will be filled in automatically',
'Weight (automatically generate drag sort button)': 'Weight (automatically generate drag sort button)',
'If it is not input, it will be automatically analyzed by the controller':
'If it is not input, it will be automatically analyzed by the controller',
}

View File

@@ -0,0 +1,39 @@
export default {
'You have worked today': 'You have worked today: ',
'Continue to work': 'Keep working',
'have a bit of rest': 'Take a break',
'Member registration': 'Member registration',
'Total number of members': 'Total number of members',
'Number of installed plug-ins': 'Number of installed plug-ins',
'Membership growth': 'Membership growth',
'Annex growth': 'Annex Growth',
'New member': 'New Member',
'Joined us': 'Joined us',
'Member source': 'Member source',
'Member last name': 'Member last name',
Loading: 'Loading',
Monday: 'Monday',
Tuesday: 'Tuesday',
Wednesday: 'Wednesday',
Thursday: 'Thursday',
Friday: 'Friday',
Saturday: 'Saturday',
Sunday: 'Sunday',
Visits: 'Visits',
'Registration volume': 'The number of registered users',
picture: 'picture',
file: 'file',
table: 'table',
other: 'other',
'Compressed package': 'Compressed package',
Baidu: 'Baidu',
'Direct access': 'Direct access',
'take a plane': 'Take a plane',
'Take the high-speed railway': 'Take the high-speed rail',
'full name': 'Full name',
hour: 'Hour',
minute: 'Minute',
second: 'Second',
day: 'Day',
'Number of attachments Uploaded': 'Number of attachments upload',
}

View File

@@ -0,0 +1,6 @@
export default {
'Please enter an account': 'Please enter your account',
'Please input a password': 'Please enter your password',
'Hold session': 'Keep the session',
'Sign in': 'Sign in',
}

View File

@@ -0,0 +1,163 @@
export default {
'stateTitle init': 'Module installer initialization...',
'stateTitle download': 'Downloading module...',
'stateTitle install': 'Installing module...',
'stateTitle getInstallableVersion': 'Get installable version...',
'env require': 'Composer',
'env require-dev': 'Composer-dev',
'env dependencies': 'NPM',
'env devDependencies': 'NPM-dev',
'env nuxtDependencies': 'Nuxt NPM',
'env nuxtDevDependencies': 'Nuxt NPM Dev',
// buy
'Module installation warning':
'Free download and update within one year after purchase. Virtual products do not support 7-day refund without reason',
'Order title': 'Order title',
'Order No': 'Order No.:',
'Purchase user': 'Purchase user',
'Order price': 'Order price',
'Purchased, can be installed directly': 'Purchased, can be installed directly',
'Understand and agree': 'Understand and agree',
'Module purchase and use agreement': 'Module purchase and use agreement',
'Point payment': 'Point payment',
'Balance payment': 'Balance payment',
'Wechat payment': 'Wechat payment',
'Alipay payment': 'Alipay payment',
'Install now': 'Install now',
payment: 'payment',
'Confirm order info': 'Confirm order info',
// commonDone
'Congratulations, module installation is complete': 'Congratulations, module installation is complete.',
'Module is disabled': 'Module is disabled.',
'Congratulations, the code of the module is ready': 'Congratulations, the code of the module is ready.',
'Unknown state': 'Unknown state.',
'Do not refresh the page!': 'Do not refresh the page!',
'New adjustment of dependency detected': 'New adjustment of dependency detected',
'This module adds new dependencies': 'This module adds new dependencies',
'The built-in terminal of the system is automatically installing these dependencies, please wait~':
'The built-in terminal of the system is automatically installing these dependencies, please wait~',
'View progress': 'View progress',
'Dependency installation completed~': 'Dependency installation completed~',
'This module does not add new dependencies': 'This module does not add new dependencies.',
'There is no adjustment for system dependency': 'There is no adjustment for system dependency.',
please: 'please',
'After installation 1': 'After installation',
'Manually clean up the system and browser cache': 'Manually clean up the system and browser cache.',
'After installation 2': 'After installation',
'Automatically execute reissue command?': 'Automatically execute reissue command?',
'End of installation': 'End of installation',
'Dependency installation fail 1': 'The dependency installation failed. Please click the retry button in the ',
'Dependency installation fail 2': 'terminal',
'Dependency installation fail 3': 'You can also view the ',
'Dependency installation fail 4': 'unfinished matters manually',
'Dependency installation fail 5': 'Until you are',
'Dependency installation fail 6': 'sure that the dependency is ready',
'Dependency installation fail 7': ', the module will not work!',
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?':
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?',
yes: 'yes',
no: 'no',
// confirmFileConflict
'Update warning':
'The following module files have been detected to be updated. When disabled, they will be automatically overwritten. Please pay attention to backup.',
'File conflict': 'File conflict',
'Conflict file': 'Conflict file',
'Dependency conflict': 'Dependency conflict',
'Confirm to disable the module': 'Confirm to disable the module',
'The module declares the added dependencies': 'The module declares the added dependencies',
Dependencies: 'Dependencies',
retain: 'Retain',
// goodsInfo
'detailed information': 'detailed information',
Price: 'Price',
'Last updated': 'Last updated',
'Published on': 'Published on:',
'amount of downloads': 'amount of downloads',
'Module classification': 'Module classification',
'Module documentation': 'Module documentation',
'Developer Homepage': 'Developer Homepage',
'Click to access': 'Click to access',
'Module status': 'Module status',
'View demo': 'View demo',
'Code scanning Preview': 'Code scanning Preview',
'Buy now': 'Buy now',
'continue installation': 'continue installation',
installed: 'installed',
'to update': 'to update',
uninstall: 'uninstall',
'Contact developer': 'Contact developer',
'Other works of developers': 'Other works of developers',
'There are no more works': 'There are no more works',
'You need to disable this module before updating Do you want to disable it now?':
'You need to disable this module before updating. Do you want to disable it now?',
'Disable and update': 'Disable and update',
'No module purchase order was found': 'No module purchase order was found. Do you want to purchase the current module now?',
// installConflict
'new file': 'new file',
'Existing files': 'Existing files',
'Treatment scheme': 'Treatment scheme',
'Backup and overwrite existing files': 'Backup and overwrite existing files',
'Discard new file': 'Discard new file',
environment: 'environment',
'New dependency': 'New dependency',
'Existing dependencies': 'Existing dependencies',
'Overwrite existing dependencies': 'Overwrite existing dependencies',
'Do not use new dependencies': 'Do not use new dependencies',
// tableHeader
'Upload zip package for installation': 'Upload zip package for installation',
'Upload installation': 'Upload installation',
'Uploaded / installed modules': 'Uploaded / installed modules',
'Local module': 'Local module',
'Publishing module': 'Publishing module',
'Get points': 'Get points',
'Search is actually very simple': 'Search is actually very simple',
// tabs
Loading: 'Loading...',
'No more': 'No more.',
// uploadInstall
'Local upload warning':
'Please make sure that the module package file comes from the official channel or the officially certified module author, otherwise the system may be damaged because:',
'The module can modify and add system files': 'The module can modify and add system files',
'The module can execute sql commands and codes': 'The module can execute sql commands and codes',
'The module can install new front and rear dependencies': 'The module can install new front and rear dependencies',
'Drag the module package file here': 'Drag the module package file here, Or',
'Click me to upload': 'Click me to upload',
'Uploaded, installation is about to start, please wait': 'Uploaded, installation is about to start, please wait',
'Update Log': 'Update Log',
'No detailed update log': 'No detailed update log',
'Use WeChat to scan QR code for payment': 'Use WeChat to scan QR code for payment',
'Use Alipay to scan QR code for payment': 'Use Alipay to scan QR code for payment',
'dependency-installation-fail-tips':
'If the command is successfully executed manually, click `Make sure dependency is ready` above to change the module to the installed state',
'New version': 'New version',
Install: 'Install',
'Installation cancelled because module already exists!': 'Installation cancelled because module already exists!',
'Installation cancelled because the directory required by the module is occupied!':
'Installation cancelled because the directory required by the module is occupied!',
'Installation complete': 'Installation complete',
'A conflict is found Please handle it manually': 'A conflict is found. Please handle it manually',
'Select Version': 'Select install version',
'Wait for dependent installation': 'Wait for dependent installation',
'The operation succeeds Please clear the system cache and refresh the browser ~':
'The operation succeeds. Please clear the system cache and refresh the browser ~',
'Deal with conflict': 'Deal with conflict',
'Wait for installation': 'Wait for installation',
'Conflict pending': 'Conflict pending',
'Dependency to be installed': 'Dependency to be installed',
'Restart Vite hot server': 'Restart Vite hot server',
'Restart Vite hot server tips':
'Before successfully restarting the service, you can find the button to manually restart the service from the button group on the right side of the top bar.',
'Manual restart': 'Manual restart',
'Restart Now': 'Restart Now',
// 选择安装版本
'Available system version': 'Available system version',
Description: 'Description',
Version: 'Version',
'Current installed version': 'Current installed version',
'Insufficient system version': 'Insufficient system version',
'Click to install': 'Click to install',
'Versions released beyond the authorization period': 'Versions released beyond the authorization period',
Renewal: 'Renewal',
'Order expiration time':
'The expiration time of the current order authorization is {expiration_time}, and the release time of this version is {create_time}',
}

View File

@@ -0,0 +1,14 @@
export default {
'Last logged in on': 'Last logged on',
'user name': 'Username',
'User nickname': 'User nickname',
'Please enter a nickname': 'Please enter a nickname',
'e-mail address': 'E-mail address',
'phone number': 'Mobile number',
autograph: 'Signature',
'This guy is lazy and doesn write anything': "This guy is lazy and didn't write anything.",
'New password': 'New password',
'Please leave blank if not modified': 'Please leave blank if you do not modify',
'Save changes': 'Save changes',
'Operation log': 'Operation log',
}

View File

@@ -0,0 +1,25 @@
export default {
'Upload administrator': 'Upload administrator',
'Upload user': 'Upload member',
'Storage mode': 'Storage mode',
'Physical path': 'Physical path',
'image width': 'Picture width',
'Picture height': 'Picture height',
'file size': 'file size',
'mime type': 'mime type ',
'SHA1 code': 'SHA1',
'The file is saved in the directory, and the file will not be automatically transferred if the record is modified':
'The file had saved in the directory, and the modification record will not automatically tansfer the file.',
'File saving path Modifying records will not automatically transfer files':
'The file had saved in the path, and the modification record will not automatically tansfer the file.',
'Width of picture file': 'The width of the image file.',
'Height of picture file': 'The height of the image file.',
'Original file name': 'Original name of the file',
'File size (bytes)': 'File size (Bytes)',
'File MIME type': 'File MIME type',
'Upload (Reference) times of this file': 'Upload (Reference) times of this file',
'When the same file is uploaded multiple times, only one attachment record will be saved and added':
'When the same file is uploaded many times, only one attachment record will be saved and added.',
'SHA1 encoding of file': 'The SHA1 encoding of file',
'Files and records will be deleted at the same time Are you sure?': 'Files and records will be deleted at the same time Are you sure?',
}

View File

@@ -0,0 +1,16 @@
export default {
'Are you sure to delete the configuration item?': 'Are you sure to delete the configuration item?',
'Add configuration item': 'Add configuration item',
'Quick configuration entry': 'Quick configuration entry',
'Variable name': 'Variable name',
'Variable group': 'Variable group',
'Variable title': 'Variable title',
'Variable type': 'Variable type',
number: 'Number',
'Please enter the recipient email address': 'Please enter the recipient email address',
'Test mail sending': 'Test mail sending',
'send out': 'send',
'Please enter the correct email address': 'Please enter the correct email address',
Sending: 'Sending',
'Please enter the correct mail configuration': 'Please enter the correct mail configuration',
}

View File

@@ -0,0 +1,11 @@
export default {
'Rule name': 'Rule name',
controller: 'Controller',
'data sheet': 'Data table',
'Data table primary key': 'Data table primary key',
'Deleting monitoring': 'Delete monitoring',
'The rule name helps to identify deleted data later': 'Rule names help to identify deleted data subsequently later.',
'The data collection mechanism will monitor delete operations under this controller':
'The data recycle mechanism will monitor the delete operations under this controller.',
'Corresponding data sheet': 'Corresponding data sheet',
}

View File

@@ -0,0 +1,17 @@
export default {
restore: 'Restore',
'Are you sure to restore the selected records?': 'Are you sure to restore the selected records?',
'Restore the selected record to the original data table': 'Restore the selected record to the original data table.',
'Operation administrator': 'Operation administrator',
'Recycling rule name': 'Recycling rule name',
'Rule name': 'Rule name',
controller: 'Controller',
'data sheet': 'Data table',
DeletedData: 'Deleted data',
'Arbitrary fragment fuzzy query': 'Arbitrary fragment fuzzy query',
'Click to expand': 'Click to expand',
'Data table primary key': 'Data table primary key',
'Operator IP': 'Operator IP',
'Deleted data': 'Deleted data',
'Delete time': 'Delete time',
}

View File

@@ -0,0 +1,13 @@
export default {
'Rule name': 'Rule name',
controller: 'Controller',
'data sheet': 'Data table',
'Data table primary key': 'Data table primary key',
'Sensitive fields': 'Sensitive fields',
'Modifying monitoring': 'Modify monitoring',
'The rule name helps to identify the modified data later': 'Rule names help to identify modified data subsequently later.',
'The data listening mechanism will monitor the modification operations under this controller':
'The data monitor mechanism will monitor the modified operation under this controller.',
'Corresponding data sheet': 'Corresponding data table',
'Filling in field notes helps you quickly identify fields later': 'Fill in field comments help to identify fields quickly later.',
}

View File

@@ -0,0 +1,18 @@
export default {
'Operation administrator': 'Operation administrator',
'Rule name': 'Rule name',
controller: 'Controller',
'data sheet': 'Data table',
'Modify line': 'Modify row',
Modification: 'Modify item',
'Before modification': 'Before modification',
'After modification': 'After modification',
'Modification time': 'Modify time',
'Are you sure you want to rollback the record?': 'Are you sure to rollback the record?',
'Rollback the selected record to the original data table': 'Rollback the selected record to the original data table.',
'Operator IP': 'Operator IP',
'Data table primary key': 'Data table primary key',
'Modified item': 'Modified item',
'Modification comparison': 'Modify the comparison',
RollBACK: 'Rollback',
}

View File

@@ -0,0 +1,5 @@
export default {
GroupName: 'Group name',
'Group name': 'Group name',
jurisdiction: 'Permissions',
}

View File

@@ -0,0 +1,16 @@
export default {
'User name': 'Username',
'User nickname': 'User nickname',
balance: 'Balance',
'User ID': 'User ID',
'Change balance': 'Change balance',
'Before change': 'Before the change',
'After change': 'After the change',
remarks: 'Remark',
'Current balance': 'Current balance',
'Change amount': 'Change amount',
'Please enter the balance change amount': 'Please enter the balance change amount.',
'Balance after change': 'Balance after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
User: 'User',
}

View File

@@ -0,0 +1,26 @@
export default {
'Normal routing': 'Normal routing',
'Member center menu contents': 'Member center menu directory ',
'Member center menu items': 'Member Center menu items',
'Top bar menu items': 'Top bar menu items',
'Page button': 'Page button',
'Top bar user dropdown': 'Top bar user dropdown',
'Type route tips': 'Automatically register as a front-end route',
'Type menu_dir tips': 'Automatically register routes and serve as menu directory of member center This item cannot jump',
'Type menu tips': 'Automatically register routes and serve as menu items in member centers',
'Type nav tips': 'Routes are automatically registered as menu items in the top bar of the site',
'Type button tips': 'Automatic registration as a permission node, can be quickly verified by v-auth',
'Type nav_user_menu tips': 'Automatically register routes and serve as a dropdown menu for top bar members',
'English name': 'English name',
'Web side routing path': 'Web side routing path',
no_login_valid: 'no login valid',
'no_login_valid 0': 'no',
'no_login_valid 1': 'yes',
'no_login_valid tips': 'Tourists do not have membership groups Use this option to set whether the current rules are valid for tourists (visible)',
'For example, if you add account/overview as a route only':
'Please start with /src for web side component paths, such as: /src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
"For example, if you add 'account/overview' as a route only, then you can additionally add 'account/overview', 'account/overview/:a' and 'account/overview/:b/:C' as menus only.",
'Component path tips':
'This item is mandatory within a WEB project; otherwise, it cannot be accessed. However, when it is used as a menu within a Nuxt project, there is no need to fill in this item',
}

View File

@@ -0,0 +1,8 @@
export default {
integral: 'Integral',
'Change points': 'Change points',
'Current points': 'Current points',
'Please enter the change amount of points': 'Please enter the change amount of points',
'Points after change': 'Points after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
}

View File

@@ -0,0 +1,22 @@
export default {
'User name': 'Username',
nickname: 'Nickname',
group: 'Group',
avatar: 'Avatar',
Gender: 'Gender',
male: 'Male',
female: 'Female',
mobile: 'Mobile Number',
'Last login IP': 'Last login IP',
'Last login': 'Last login',
email: 'Email',
birthday: 'Birthday',
balance: 'Balance',
'Adjustment balance': 'Adjust balance',
integral: 'Integral',
'Adjust integral': 'Adjust integral',
password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify',
'Personal signature': 'Personal signature',
'Login account': 'Login account name',
}

View File

@@ -0,0 +1,101 @@
/**
* 后台公共语言包
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key
*/
export default {
Balance: '余额',
Integral: '积分',
Connection: '连接标识',
'Database connection': '数据库连接配置标识',
'Database connection help': '您可以在 config/database.php 内配置多个数据库连接,然后在此处选择它,留空将使用默认连接配置',
layouts: {
'Layout configuration': '布局配置',
'Layout mode': '布局方式',
default: '默认',
classic: '经典',
'Single column': '单栏',
'Double column': '双栏',
'overall situation': '全局',
'Background page switching animation': '后台页面切换动画',
'Please select an animation name': '请选择动画名称',
sidebar: '侧边栏',
'Side menu bar background color': '侧边菜单栏背景色',
'Side menu text color': '侧边菜单文字颜色',
'Side menu active item background color': '侧边菜单激活项背景色',
'Side menu active item text color': '侧边菜单激活项文字色',
'Show side menu top bar (logo bar)': '显示侧边菜单顶栏(LOGO栏)',
'Side menu top bar background color': '侧边菜单顶栏背景色',
'Side menu width (when expanded)': '侧边菜单宽度(展开时)',
'Side menu default icon': '侧边菜单默认图标',
'Side menu horizontal collapse': '侧边菜单水平折叠',
'Side menu accordion': '侧边菜单手风琴',
'Top bar': '顶栏',
'Top bar background color': '顶栏背景色',
'Top bar text color': '顶栏文字色',
'Background color when hovering over the top bar': '顶栏悬停时背景色',
'Top bar menu active item background color': '顶栏菜单激活项背景色',
'Top bar menu active item text color': '顶栏菜单激活项文字色',
'Are you sure you want to restore all configurations to the default values?': '确定要恢复全部配置到默认值吗?',
'Restore default': '恢复默认',
Profile: '个人资料',
Logout: '注销',
'Dark mode': '暗黑模式',
'Exit full screen': '退出全屏',
'Full screen is not supported': '您的浏览器不支持全屏,请更换浏览器再试~',
'Member center': '会员中心',
'Member information': '会员信息',
'Login to the buildadmin': '登录到 BuildAdmin 开源社区',
'Please enter buildadmin account name or email': '请输入 BuildAdmin 账户名/邮箱/手机号',
'Please enter the buildadmin account password': '请输入 BuildAdmin 账户密码',
Login: '登录',
Password: '密码',
Username: '用户名',
Register: '没有账户?去注册',
},
terminal: {
Source: '源',
Terminal: '终端',
'Command run log': '命令运行日志',
'No mission yet': '还没有任务...',
'Test command': '测试命令',
'Install dependent packages': '安装依赖包',
Republish: '重新发布',
'Clean up task list': '清理任务列表',
unknown: '未知',
'Waiting for execution': '等待执行',
Connecting: '连接中...',
Executing: '执行中...',
'Successful execution': '执行成功',
'Execution failed': '执行失败',
'Unknown execution result': '执行结果未知',
'Are you sure you want to republish?': '确认要重新发布吗?',
'Failure to execute this command will block the execution of the queue': '本命令执行失败会阻断队列执行',
'NPM package manager': 'NPM 包管理器',
'NPM package manager tip': '选择一个可用的包管理器,用于 WEB 终端中 npm install 等命令的执行',
'Clear successful task': '清理成功任务',
'Clear successful task tip': '开始一个新任务时,自动清理列表中已经成功的任务',
'Manual execution': '手动执行',
'Do not refresh the browser': '请勿刷新浏览器',
'Terminal settings': '终端设置',
'Back to terminal': '回到终端',
or: '或',
'Site domain name': '站点域名',
'The current terminal is not running under the installation service, and some commands may not be executed':
'当前终端未运行于安装服务下,部分命令可能无法执行。',
'Newly added tasks will never start because they are blocked by failed tasks': '新添加的任务永远不会开始因为被失败的任务阻塞WEB终端',
'Failed to modify the source command, Please try again manually': '修改源的命令执行失败,请手动重试。',
},
vite: {
Later: '稍后',
'Restart hot update': '重启热更新',
'Close type terminal': 'WEB终端执行命令',
'Close type crud': 'CRUD代码生成服务',
'Close type modules': '模块安装服务',
'Close type config': '修改系统配置',
'Reload hot server title': '需要重启 Vite 热更新服务',
'Reload hot server tips 1': '为确保',
'Reload hot server tips 2':
'不被打断,系统暂停了 Vite 的热更新功能,期间前端文件变动将不会实时更新和自动重载网页,现检测到服务暂停期间存在文件更新,需要重启热更新服务。',
'Reload hot server tips 3': '热更新暂停不影响已经加载好的功能,您可以继续操作,并在一切就绪后再点击重新启动热更新服务。',
},
}

View File

@@ -0,0 +1,13 @@
export default {
username: '用户名',
nickname: '昵称',
group: '角色组',
avatar: '头像',
email: '电子邮箱',
mobile: '手机号',
'Last login': '最后登录',
Password: '密码',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Administrator login': '管理员登录名',
}

View File

@@ -0,0 +1,12 @@
export default {
admin_id: '管理ID',
username: '管理用户名',
title: '标题',
data: '请求数据',
url: 'URL',
ip: 'IP',
useragent: 'UserAgent',
'Operation administrator': '操作管理员',
'Operator IP': '操作人IP',
'Request data': '请求数据',
}

View File

@@ -0,0 +1,8 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
jurisdiction: '权限',
'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身',
'Manage subordinate role groups here': '在此管理下级角色组(您拥有下级角色组的所有权限并且拥有额外的权限,不含同级)',
}

View File

@@ -0,0 +1,49 @@
export default {
title: '标题',
Icon: '图标',
name: '名称',
type: '类型',
cache: '缓存',
'Superior menu rule': '上级菜单规则',
'Rule type': '规则类型',
'type menu_dir': '菜单目录',
'type menu': '菜单项',
'type button': '页面按钮',
'Rule title': '规则标题',
'Rule name': '规则名称',
'Routing path': '路由路径',
'Rule Icon': '规则图标',
'Menu type': '菜单类型',
'Menu type tab': '选项卡',
'Menu type link (offsite)': '链接(站外)',
'Link address': '链接地址',
'Component path': '组件路径',
'Extended properties': '扩展属性',
'Add as route only': '只添加为路由',
'Add as menu only': '只添加为菜单',
'Rule comments': '规则备注',
'Rule weight': '规则权重',
'Create Page Button': '创建页面按钮',
'Create Page Button index': '查看',
'Create Page Button add': '添加',
'Create Page Button edit': '编辑',
'Create Page Button del': '删除',
'Create Page Button sortable': '快速排序',
'Create Page Button tips': '创建菜单的同时,自动创建菜单的页面按钮(权限节点),若需自定义按钮请后续手动添加',
'Please select the button for automatically creating the desired page': '请选择需要自动创建的页面按钮',
'Please enter the weight of menu rule (sort by)': '请输入菜单规则权重(排序依据)',
'Please enter the correct URL': '请输入正确的 URL',
'The superior menu rule cannot be the rule itself': '上级菜单规则不能是规则本身',
'It will be registered as the web side routing name and used as the server side API authentication':
'将注册为 WEB 端路由名称,同时作为服务端方法名验权(有此节点权限才能请求对应控制器或方法)',
'Please enter the URL address of the link or iframe': '请输入链接或 Iframe 的 URL 地址',
'English name, which does not need to start with `/admin`, such as auth/menu': '英文名称,无需以 `/admin` 开头auth/menu',
'Web side component path, please start with /src, such as: /src/views/backend/dashboard':
'WEB 端组件路径,请以 /src 开头,如:/src/views/backend/dashboard.vue',
'The web side routing path (path) does not need to start with `/admin`, such as auth/menu':
'vue-router 的 path无需以 `/admin` 开头auth/menu',
'Use in controller `get_ route_ Remark()` function, which can obtain the value of this field for your own use, such as the banner file of the console':
'在控制器中使用 `get_route_remark()` 函数,可以获得此字段值自用,比如控制台的 Banner 文案',
'extend Title': '比如将 `auth/menu` 只添加为路由,那么可以另外将 `auth/menu`、`auth/menu/:a`、`auth/menu/:b/:c` 只添加为菜单',
none: '无',
}

View File

@@ -0,0 +1,168 @@
export default {
show: '在表格列中显示',
width: '表格列宽度',
sortable: '字段排序',
operator: '公共搜索操作符',
comSearchRender: '公共搜索输入框渲染方案',
comSearchInputAttr: '公共搜索输入框扩展属性',
comSearchInputAttrTip: '格式如size=large一行一个属性远程下拉的公共搜索也渲染为远程下拉时此处免填远程下拉组件的必填属性',
render: '渲染方案',
timeFormat: '格式化方式',
step: '步进值',
rows: '行数',
'api url': '数据接口URL',
'api url example': '比如: /admin/user.User/index',
'remote-pk': '远程下拉 value 字段',
'remote-field': '远程下拉 label 字段',
'remote-url': '远程下拉数据接口 URL',
'remote-controller': '关联表的控制器',
'remote-table': '关联数据表',
'remote-model': '关联表的模型',
'remote-primary-table-alias': '主表别名',
'relation-fields': '关联表显示字段',
'image-multi': '图片多选上传',
'file-multi': '文件多选上传',
'select-multi': '下拉框多选',
validator: '验证规则',
validatorMsg: '验证错误提示',
copy: '复制设计',
'CRUD record': 'CRUD 记录',
'Delete Code': '删除代码',
'Start CRUD design with this record?': '以此记录开始 CRUD 设计?',
'Are you sure to delete the generated CRUD code?': '确认删除生成的 CRUD 代码?',
start: '开始',
create: '新建',
or: '或',
'New background CRUD from zero': '从零新建后台 CRUD',
'Select Data Table': '选择数据表',
'Select a designed data table from the database': '从数据库中选择一个设计好的数据表',
'Start with previously generated CRUD code': '从以往生成的 CRUD 代码开始',
'Fast experience': '快速体验',
'Please enter SQL': '请输入 SQL',
'Please select a data table': '请选择数据表',
'data sheet help': '数据表前缀需要同项目配置的数据表前缀一致',
'data sheet': '数据表',
'table create SQL': '建表 SQL',
'Please enter the table creation SQL': '请输入建表 SQL',
'experience 1 1': '准备好',
'experience 1 2': '开发环境',
'experience 1 3': '站点端口为1818',
'experience 2 1': '在本页点击',
'experience 2 2': '选择数据表',
'experience 2 3': '(可选择 test_build 数据表)',
'experience 3 1': '点击',
'experience 3 2': '生成 CRUD 代码',
'experience 3 3': ',点击',
'experience 3 4': '继续生成',
'experience 4 1': '您当前未在开发环境,请 ',
'experience 4 2': '搭建开发环境',
'experience 4 3': ',或生成好代码之后点击右上角终端内的',
'experience 4 4': '重新发布',
// design
'Name of the data table': '数据表的名称',
'Data Table Notes': '数据表注释',
'Generate CRUD code': '生成 CRUD 代码',
'give up': '放弃',
'Table Quick Search Fields': '表格快速搜索字段',
'Table Default Sort Fields': '表格默认排序字段',
'sort order': '排序方式',
'sort order asc': 'asc-顺序',
'sort order desc': 'desc-倒序',
'Fields as Table Columns': '作为表格列的字段',
'Fields as form items': '作为表单项的字段',
'The relative path to the generated code': '生成代码的相对位置',
'For quick combination code generation location, please fill in the relative path': '快速的组合代码生成位置,请填写相对路径',
'Generated Controller Location': '生成的控制器位置',
'Generated Data Model Location': '生成的数据模型位置',
'Generated Validator Location': '生成的验证器位置',
'WEB end view directory': 'WEB端视图目录',
'Check model class': "请检查以上数据模型类中是否已经配置 protected $connection = '{connection}';",
'There is no connection attribute in model class': '未配置请手动配置。',
'Common model': '公共模型',
'Advanced Configuration': '高级配置',
'Common Fields': '常用字段',
'Base Fields': '基础字段',
'Advanced Fields': '高级字段',
'Field Name': '字段名',
'field comment': '字段注释',
'Please select a field from the left first': '请先从左侧选择一个字段',
Common: '常用',
'Generate type': '生成类型',
'Field comments (CRUD dictionary)': '字段注释CRUD 字典)',
'Field Properties': '字段属性',
'Field Type': '字段类型',
length: '长度',
'decimal point': '小数点',
'Field Defaults': '字段默认值',
'Please input the default value': '请输入默认值',
'Auto increment': '自动递增',
Unsigned: '无符号',
'Allow NULL': '允许 NULL',
'Field Form Properties': '字段表单属性',
'Field Table Properties': '字段表格属性',
'Remote drop-down association information': '远程下拉关联信息',
'Associated Data Table': '关联数据表',
'Drop down value field': '下拉 value 字段',
'Drop down label field': '下拉 label 字段',
'Please select the value field of the select component': '请选择 select 组件的 value 字段',
'Please select the label field of the select component': '请选择 select 组件的 label 字段',
'Fields displayed in the table': '在表格中显示的字段',
'Please select the fields displayed in the table': '请选择在表格中显示的字段',
'Controller position': '控制器位置',
'Please select the controller of the data table': '请选择数据表的控制器',
'Data Model Location': '数据模型位置',
'Please select the data model location of the data table': '请选择数据表的数据模型位置',
'Data source configuration type': '数据源配置类型',
'Fast configuration with generated controllers and models': '通过已生成好的控制器和模型快速配置',
'Custom configuration': '自定义配置',
'If the remote interface query involves associated query of multiple tables, enter the alias of the primary data table here':
'如果远程接口查询数据时涉及多表关联查询,请在此填写主数据表的别名',
'Confirm CRUD code generation': '确认生成 CRUD 代码',
'Continue building': '继续生成',
'Please enter the data table name!': '请输入数据表名!',
'Please enter the correct table name!': '请输入正确的数据表名!',
'Use lower case underlined for table names': '请使用小写加下划线作为表名,小写字母开头,可以带有数字',
'Please design the primary key field!': '请设计主键字段!',
'It is irreversible to give up the design Are you sure you want to give up?': '放弃设计不可逆,确定要放弃吗?',
'There can only be one primary key field': '只可以有一个主键字段。',
'Drag the left element here to start designing CRUD': '拖动左侧元素至此处以开始设计CRUD',
'The data table already exists Continuing to generate will automatically delete the original table and create a new one!':
'数据表已经存在,继续生成将自动删除原表并建立新的数据表(数据表是空的或您已勾选删表重建)!',
'The controller already exists Continuing to generate will automatically overwrite the existing code!':
'控制器已经存在,继续生成将自动覆盖已有代码!',
'The menu rule with the same name already exists The menu and permission node will not be created in this generation':
'同名菜单规则已经存在,本次生成将不会创建菜单和权限节点!',
'For example: `user table` will be generated into `user management`': '如:会员表(将生成为会员管理)',
'The remote pull-down will request the corresponding controller to obtain data, so it is recommended that you create the CRUD of the associated table':
'远程下拉将请求该控制器的 index 方法来获取 value 和 label 字段数据所以请先生成好被关联表的CRUD',
'If it is left blank, the model of the associated table will be generated automatically If the table already has a model, it is recommended to select it to avoid repeated generation':
'留空则自动生成关联表的模型,若该表已有模型,请选择好以免重复生成',
'The field comment will be used as the CRUD dictionary, and will be identified as the field title before the colon, and as the data dictionary after the colon':
'字段注释将作为 CRUD 字典,冒号前将识别为字段标题,冒号后识别为数据字典',
'Field name is invalid It starts with a letter or underscore and cannot contain any character other than letters, digits, or underscores':
'字段名 {field} 不符合规范,请以 字母、_ 开头,不能出现 字母、数字、下划线 以外的字符',
'The selected table has already generated records You are advised to start with historical records':
'选择的表已有成功生成的记录,建议从历史记录开始~',
'Start with the historical record': '从历史记录开始',
'Add field': '添加字段',
'Modify field properties': '修改字段属性',
'Modify field name': '修改字段名称',
'Delete field': '删除字段',
'Modify field order': '修改字段顺序',
'First field': '第一个字段',
After: '之后',
'Table design change': '表设计变更',
'Data table design changes preview': '数据表设计变更预览',
designChangeTips: '取消勾选后,则该项变动不会尝试同步至数据表(通常用于已经手动修改过表结构等情况)',
tableReBuild: '删表重建',
tableReBuildBlockHelp: '不调整表结构直接删除已有数据表并重建此举可以确保CRUD代码/记录与数据表结构一致',
Yes: '是',
No: '否',
'If the data is abnormal, repeat the previous step': '数据异常,请重做上步操作',
'Field name duplication': '字段名称 {field} 重复!',
'Design remote select tips':
'将自动根据表名生成本字段的名称;确认生成时,字段名 user_id 生成的关联方法名为 user字段名 developer_done_id 生成的关联方法名为 developerDone请注意远程下拉字段的名称前缀不要重复',
'Vite hot warning': '未找到 Vite 热更新服务请在开发环境生成代码或点击右上角的WEB终端重新发布',
'Reset generate type attr': '字段生成类型已修改,是否将字段设计重置为新类型预设的方案?',
'Design efficiency': '自行确定设计有效性',
}

View File

@@ -0,0 +1,51 @@
export default {
id: 'ID',
table_name: '数据表名',
comment: '表注释',
table: '数据表数据',
fields: '字段数据',
sync: '是否上传',
'sync no': '否',
'sync yes': '是',
status: '状态',
delete: '删除代码',
'status delete': '代码已删除',
'status success': '成功',
'status error': '失败',
'status start': '生成中',
create_time: '创建时间',
'quick Search Fields': 'ID、表名、注释',
'Upload the selected design records to the cloud for cross-device use': '上传选中的设计记录至云端以跨设备使用',
'Design records that have been synchronized to the cloud': '已同步至云端的设计记录',
'Cloud record': '云记录',
Settings: '设置',
'Login for backup design': '登录以备份设计',
'CRUD design record synchronization scheme': 'CRUD 设计记录同步方案',
Manual: '手动',
automatic: '自动',
'When automatically synchronizing records, share them to the open source community': '自动同步记录时分享至开源社区',
'Not to share': '不分享',
Share: '分享',
'Enabling sharing can automatically earn community points during development': '开启分享可于开发同时自动获取社区积分',
'The synchronized CRUD records are automatically resynchronized when they are updated': '已同步的 CRUD 记录被更新时自动重新同步',
'Do not resynchronize': '不重新同步',
'Automatic resynchronization': '自动重新同步',
'No effective design': '无有效设计',
'Number of fields': '字段数',
'Upload type': '上传类型',
Update: '更新',
'New added': '新增',
'Share to earn points': '分享获得积分',
'Share to the open source community': '分享至开源社区',
'No design record': '无设计记录',
Field: '字段',
'Field information': '字段信息',
'No field': '无字段',
'Field name': '字段名',
Note: '注释',
Type: '类型',
Load: '载入',
'Delete cloud records?': '删除云端记录?',
'You can use the synchronized design records across devices':
'您可以跨设备使用已同步的设计记录;选择手动同步时,系统不会主动收集任何数据,同时系统永远不会同步表内数据',
}

View File

@@ -0,0 +1,19 @@
export default {
remarks: '备注',
'Primary key': '主键',
'Primary key (Snowflake ID)': '主键雪花ID',
'Disable Search': '禁用搜索',
'Weight (drag and drop sorting)': '权重(拖拽排序)',
'Status:0=Disabled,1=Enabled': '状态:0=禁用,1=启用',
'Remote Select (association table)': '远程下拉(关联表)',
'Remote Select (Multi)': '远程下拉(关联多选)',
'Radio:opt0=Option1,opt1=Option2': '单选框:opt0=选项一,opt1=选项二',
'Checkbox:opt0=Option1,opt1=Option2': '复选框:opt0=选项一,opt1=选项二',
Multi: '(多选)',
'Select:opt0=Option1,opt1=Option2': '下拉框:opt0=选项一,opt1=选项二',
'Switch:0=off,1=on': '开关:0=关,1=开',
'Time date (timestamp storage)': '时间日期(时间戳存储)',
'If left blank, the verifier title attribute will be filled in automatically': '留空则自动填写验证器title属性(看不懂请直接填写完整错误消息)',
'Weight (automatically generate drag sort button)': '权重(自动生成拖拽排序按钮)',
'If it is not input, it will be automatically analyzed by the controller': '不输入则以控制器自动解析',
}

View File

@@ -0,0 +1,39 @@
export default {
'You have worked today': '您今天已工作了',
'Continue to work': '继续工作',
'have a bit of rest': '休息片刻',
'Member registration': '会员注册量',
'Total number of members': '会员总数',
'Number of installed plug-ins': '已装插件数',
'Membership growth': '会员增长情况',
'Annex growth': '附件增长情况',
'New member': '刚刚加入的会员',
'Joined us': '加入了我们',
'Member source': '会员来源',
'Member last name': '会员姓氏',
Loading: '加载中...',
Monday: '周一',
Tuesday: '周二',
Wednesday: '周三',
Thursday: '周四',
Friday: '周五',
Saturday: '周六',
Sunday: '周日',
Visits: '访问量',
'Registration volume': '注册量',
picture: '图片',
file: '文档',
table: '表格',
other: '其它',
'Compressed package': '压缩包',
Baidu: '百度',
'Direct access': '直接访问',
'take a plane': '坐飞机',
'Take the high-speed railway': '坐高铁',
'full name': '姓名',
hour: '小时',
minute: '分',
second: '秒',
day: '天',
'Number of attachments Uploaded': '附件上传量',
}

View File

@@ -0,0 +1,6 @@
export default {
'Please enter an account': '请输入账号',
'Please input a password': '请输入密码',
'Hold session': '保持会话',
'Sign in': '登录',
}

View File

@@ -0,0 +1,153 @@
export default {
'stateTitle init': '模块安装器初始化...',
'stateTitle download': '正在下载模块...',
'stateTitle install': '正在安装模块...',
'stateTitle getInstallableVersion': '正在获取模块版本列表...',
'env require': '后端依赖composer',
'env require-dev': '后端开发环境依赖composer',
'env dependencies': '前端依赖NPM',
'env devDependencies': '前端开发环境依赖NPM',
'env nuxtDependencies': '前端依赖Nuxt-NPM',
'env nuxtDevDependencies': '前端开发环境依赖Nuxt-NPM',
// buy
'Module installation warning': '购买后一年内可免费下载和更新虚拟产品不支持7天无理由退款',
'Order title': '订单标题',
'Order No': '订单编号',
'Purchase user': '购买用户',
'Order price': '订单价格',
'Purchased, can be installed directly': '已购买,可直接安装',
'Understand and agree': '理解并同意',
'Module purchase and use agreement': '模块购买和使用协议',
'Point payment': '积分支付',
'Balance payment': '余额支付',
'Wechat payment': '微信支付',
'Alipay payment': '支付宝支付',
'Install now': '立即安装',
payment: '支付',
'Confirm order info': '确认订单信息',
// commonDone
'Congratulations, module installation is complete': '恭喜,模块安装已完成。',
'Module is disabled': '模块已禁用。',
'Congratulations, the code of the module is ready': '恭喜,模块的代码已经准备好了。',
'Unknown state': '未知状态。',
'Do not refresh the page!': '请勿刷新页面!',
'New adjustment of dependency detected': '检测到依赖项有新的调整',
'This module adds new dependencies': '本模块添加了新的依赖项',
'The built-in terminal of the system is automatically installing these dependencies, please wait~': '系统内置终端正在自动安装这些依赖,请稍等~',
'View progress': '查看进度',
'Dependency installation completed~': '依赖已安装完成~',
'This module does not add new dependencies': '本模块没有添加新的依赖项。',
'There is no adjustment for system dependency': '系统依赖无调整。',
please: '请',
'After installation 1': '在安装结束后',
'Manually clean up the system and browser cache': '手动的清理系统和浏览器缓存。',
'After installation 2': '安装结束后',
'Automatically execute reissue command?': '自动执行重新发布命令?',
'End of installation': '安装结束',
'Dependency installation fail 1': '依赖安装失败,请点击',
'Dependency installation fail 2': '终端',
'Dependency installation fail 3': '中的重试按钮,您也可以查看',
'Dependency installation fail 4': '手动完成未尽事宜',
'Dependency installation fail 5': '在您',
'Dependency installation fail 6': '确定依赖已准备好',
'Dependency installation fail 7': '之前,模块还不能正常使用!',
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?':
'WEB终端失败的命令已经手动或以其他方式执行成功',
yes: '是',
no: '否',
// confirmFileConflict
'Update warning': '检测到以下的模块文件有更新,禁用时将自动覆盖,请注意备份。',
'File conflict': '文件冲突',
'Conflict file': '冲突文件',
'Dependency conflict': '依赖冲突',
'Confirm to disable the module': '确认禁用模块',
'The module declares the added dependencies': '模块声明添加的依赖',
Dependencies: '依赖项',
retain: '保留',
// goodsInfo
'detailed information': '详细信息',
Price: '价格',
'Last updated': '最后更新',
'Published on': '发布时间',
'amount of downloads': '下载次数',
'Module classification': '模块分类',
'Module documentation': '模块文档',
'Developer Homepage': '开发者主页',
'Click to access': '点击访问',
'Module status': '模块状态',
'View demo': '查看演示',
'Code scanning Preview': '扫码预览',
'Buy now': '立即购买',
'continue installation': '继续安装',
installed: '已安装',
'to update': '更新',
uninstall: '卸载',
'Contact developer': '联系开发者',
'Other works of developers': 'TA的其他作品',
'There are no more works': '没有更多作品了',
'You need to disable this module before updating Do you want to disable it now?': '更新前需要先禁用该模块,立即禁用?',
'Disable and update': '禁用并更新',
'No module purchase order was found': '没有找到有效的模块购买订单,是否立即购买当前模块?',
// installConflict
'new file': '新文件',
'Existing files': '已有文件',
'Treatment scheme': '处理方案',
'Backup and overwrite existing files': '备份并覆盖已有文件',
'Discard new file': '丢弃新文件',
environment: '环境',
'New dependency': '新依赖',
'Existing dependencies': '已有依赖',
'Overwrite existing dependencies': '覆盖已有依赖',
'Do not use new dependencies': '不使用新依赖',
// tableHeader
'Upload zip package for installation': '上传ZIP包安装',
'Upload installation': '上传安装',
'Uploaded / installed modules': '已上传/安装的模块',
'Local module': '本地模块',
'Publishing module': '发布模块',
'Get points': '获得积分',
'Search is actually very simple': '搜索其实很简单',
// tabs
Loading: '加载中...',
'No more': '没有更多了...',
// uploadInstall
'Local upload warning': '请您务必确认模块包文件来自官方渠道或经由官方认证的模块作者,否则系统可能被破坏,因为:',
'The module can modify and add system files': '模块可以修改和新增系统文件',
'The module can execute sql commands and codes': '模块可以执行sql命令和代码',
'The module can install new front and rear dependencies': '模块可以安装新的前后端依赖',
'Drag the module package file here': '拖拽模块包文件到此处或',
'Click me to upload': '点击我上传',
'Uploaded, installation is about to start, please wait': '已上传,即将开始安装,请稍等',
'Update Log': '更新日志',
'No detailed update log': '无详细更新日志',
'Use WeChat to scan QR code for payment': '使用微信扫描二维码支付',
'Use Alipay to scan QR code for payment': '使用支付宝扫描二维码支付',
'dependency-installation-fail-tips': '若手动执行命令成功,可点击以上的 `确定依赖已准备好` 将模块修改为已安装状态。',
'New version': '有新版本',
Install: '安装',
'Installation cancelled because module already exists!': '安装取消,因为模块已经存在!',
'Installation cancelled because the directory required by the module is occupied!': '安装取消,因为模块所需目录被占用!',
'Installation complete': '安装完成',
'A conflict is found Please handle it manually': '发现冲突,请手动处理',
'Select Version': '选择安装版本',
'Wait for dependent installation': '等待依赖安装',
'The operation succeeds Please clear the system cache and refresh the browser ~': '操作成功,请清理系统缓存并刷新浏览器~',
'Deal with conflict': '处理冲突',
'Wait for installation': '等待安装',
'Conflict pending': '冲突待处理',
'Dependency to be installed': '依赖待安装',
'Restart Vite hot server': '重启热更新服务',
'Restart Vite hot server tips': '在完成服务重启之前,您还可以随时从顶栏右侧的按钮组中找到手动重启服务的按钮。',
'Manual restart': '手动重启',
'Restart Now': '立即重启',
// 选择安装版本
'Available system version': '可用系统版本',
Description: '描述',
Version: '版本',
'Current installed version': '当前安装版本',
'Insufficient system version': '系统版本不足',
'Click to install': '点击安装',
'Versions released beyond the authorization period': '授权期限以外发布的版本',
Renewal: '续费',
'Order expiration time': '当前订单授权过期时间为 {expiration_time},此版本发布时间为 {create_time}',
}

View File

@@ -0,0 +1,14 @@
export default {
'Last logged in on': '上次登录于',
'user name': '用户名',
'User nickname': '用户昵称',
'Please enter a nickname': '请输入昵称',
'e-mail address': '邮箱地址',
'phone number': '手机号码',
autograph: '签名',
'This guy is lazy and doesn write anything': '这家伙很懒,什么也没写',
'New password': '新密码',
'Please leave blank if not modified': '不修改请留空',
'Save changes': '保存修改',
'Operation log': '操作日志',
}

View File

@@ -0,0 +1,24 @@
export default {
'Upload administrator': '上传管理员',
'Upload user': '上传会员',
'Storage mode': '存储方式',
'Physical path': '物理路径',
'image width': '图片宽度',
'Picture height': '图片高度',
'file size': '文件大小',
'mime type': 'mime类型',
'SHA1 code': 'sha1',
'The file is saved in the directory, and the file will not be automatically transferred if the record is modified':
'文件保存目录,修改记录不会自动转移文件',
'File saving path Modifying records will not automatically transfer files': '文件保存路径,修改记录不会自动转移文件',
'Width of picture file': '图片文件的宽度',
'Height of picture file': '图片文件的高度',
'Original file name': '文件原始名称',
'File size (bytes)': '文件大小(bytes)',
'File MIME type': '文件mime类型',
'Upload (Reference) times of this file': '此文件的上传(引用)次数',
'When the same file is uploaded multiple times, only one attachment record will be saved and added':
'同一文件被多次上传时,只会保存一份和增加一条附件记录',
'SHA1 encoding of file': '文件的sha1编码',
'Files and records will be deleted at the same time Are you sure?': '将同时删除文件和记录,确认吗?',
}

View File

@@ -0,0 +1,16 @@
export default {
'Are you sure to delete the configuration item?': '确定删除配置项吗?',
'Add configuration item': '添加配置项',
'Quick configuration entry': '快捷配置入口',
'Variable name': '变量名',
'Variable group': '变量分组',
'Variable title': '变量标题',
'Variable type': '变量类型',
number: '数字',
'Please enter the recipient email address': '请输入接收者邮箱地址',
'Test mail sending': '测试邮件发送',
'send out': '发送',
'Please enter the correct email address': '请输入正确的电子邮箱地址',
Sending: '发送中...',
'Please enter the correct mail configuration': '请输入正确的邮件配置',
}

View File

@@ -0,0 +1,10 @@
export default {
'Rule name': '规则名称',
controller: '控制器',
'data sheet': '数据表',
'Data table primary key': '数据表主键',
'Deleting monitoring': '删除监控中',
'The rule name helps to identify deleted data later': '规则名称有助于后续识别被删数据',
'The data collection mechanism will monitor delete operations under this controller': '数据回收机制将监控此控制器下的删除操作',
'Corresponding data sheet': '对应数据表',
}

View File

@@ -0,0 +1,17 @@
export default {
restore: '还原',
'Are you sure to restore the selected records?': '确定还原选中记录?',
'Restore the selected record to the original data table': '还原选中记录到原数据表',
'Operation administrator': '操作管理员',
'Recycling rule name': '回收规则名称',
'Rule name': '规则名称',
controller: '控制器',
'data sheet': '数据表',
DeletedData: '被删数据',
'Arbitrary fragment fuzzy query': '任意片段模糊查询',
'Click to expand': '点击展开',
'Data table primary key': '数据表主键',
'Operator IP': '操作者IP',
'Deleted data': '被删除的数据',
'Delete time': '删除时间',
}

View File

@@ -0,0 +1,12 @@
export default {
'Rule name': '规则名称',
controller: '控制器',
'data sheet': '数据表',
'Data table primary key': '数据表主键',
'Sensitive fields': '敏感字段',
'Modifying monitoring': '修改监控中',
'The rule name helps to identify the modified data later': '规则名称有助于后续识别被修改数据',
'The data listening mechanism will monitor the modification operations under this controller': '数据监听机制将监控此控制器下的修改操作',
'Corresponding data sheet': '对应数据表',
'Filling in field notes helps you quickly identify fields later': '填写字段注释有助于后续快速识别字段',
}

View File

@@ -0,0 +1,18 @@
export default {
'Operation administrator': '操作管理员',
'Rule name': '规则名称',
controller: '控制器',
'data sheet': '数据表',
'Modify line': '修改行',
Modification: '修改项',
'Before modification': '修改前',
'After modification': '修改后',
'Modification time': '修改时间',
'Are you sure you want to rollback the record?': '确认要回滚记录吗?',
'Rollback the selected record to the original data table': '回滚选中记录到原数据表',
'Operator IP': '操作者IP',
'Data table primary key': '数据表主键',
'Modified item': '被修改项',
'Modification comparison': '修改对比',
RollBACK: '回滚',
}

View File

@@ -0,0 +1,5 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
jurisdiction: '权限',
}

View File

@@ -0,0 +1,16 @@
export default {
'User name': '用户名',
'User nickname': '用户昵称',
balance: '余额',
'User ID': '用户ID',
'Change balance': '变更余额',
'Before change': '变更前',
'After change': '变更后',
remarks: '备注',
'Current balance': '当前余额',
'Change amount': '变动数额',
'Please enter the balance change amount': '请输入余额变更数额',
'Balance after change': '变更后余额',
'Please enter change remarks / description': '请输入变更备注/说明',
User: '用户',
}

View File

@@ -0,0 +1,24 @@
export default {
'Normal routing': '普通路由',
'Member center menu contents': '会员中心菜单目录',
'Member center menu items': '会员中心菜单项',
'Top bar menu items': '顶栏菜单项',
'Page button': '页面按钮',
'Top bar user dropdown': '顶栏会员菜单下拉项',
'Type route tips': '自动注册为前端路由',
'Type menu_dir tips': '自动注册路由,并作为会员中心的菜单目录,此项本身不可跳转',
'Type menu tips': '自动注册路由,并作为会员中心的菜单项目',
'Type nav tips': '自动注册路由,并作为站点顶栏的菜单项目',
'Type button tips': '自动注册为权限节点,可通过 v-auth 快速验权',
'Type nav_user_menu tips': '自动注册路由,并作为顶栏会员菜单下拉项',
'English name': '英文名称',
'Web side routing path': 'WEB 端路由路径vue-router 的 path',
no_login_valid: '未登录有效',
'no_login_valid 0': '游客无效',
'no_login_valid 1': '游客有效',
'no_login_valid tips': '游客没有会员分组,通过本选项设置当前规则是否对游客有效(可见)',
'For example, if you add account/overview as a route only': 'WEB 端组件路径,请以 /src 开头,如:/src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
'比如将 `account/overview` 只添加为路由,那么可以另外将 `account/overview`、`account/overview/:a`、`account/overview/:b/:c` 只添加为菜单',
'Component path tips': '组件路径在 WEB 工程内是必填的,否则无法访问,但作为 Nuxt 工程内的菜单时,无需填写此项,请根据菜单使用场景填写',
}

View File

@@ -0,0 +1,8 @@
export default {
integral: '积分',
'Change points': '变更积分',
'Current points': '当前积分',
'Please enter the change amount of points': '请输入积分变更数额',
'Points after change': '变更后积分',
'Please enter change remarks / description': '请输入变更备注/说明',
}

View File

@@ -0,0 +1,22 @@
export default {
'User name': '用户名',
nickname: '昵称',
group: '分组',
avatar: '头像',
Gender: '性别',
male: '男',
female: '女',
mobile: '手机号',
'Last login IP': '最后登录IP',
'Last login': '最后登录',
email: '电子邮箱',
birthday: '生日',
balance: '余额',
'Adjustment balance': '调整余额',
integral: '积分',
'Adjust integral': '调整积分',
password: '密码',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Login account': '登录账户名',
}

View File

@@ -0,0 +1,4 @@
export default {
noPowerTip:
"It's not what you want, but we're serious. I want to tell you in a special way that you don't have permission to access this page or the file is invalid. You can contact the website administrator to solve the problem faster or go back home page to view another page.",
}

View File

@@ -0,0 +1,7 @@
export default {
'problems tip':
'Your website has encountered some problems. The system is optimizing and reporting fault information. We will improve and reduce this situation in the future.',
'We will automatically return to the previous page when we are finished': 'Auto return to previous page when finished.',
'Return to home page': 'Back to Home',
'Back to previous page': 'Back to previous page',
}

View File

@@ -0,0 +1,20 @@
export default {
'Operation successful': 'Operate successful',
'Automatic cancellation due to duplicate request:': 'Automatic cancellation due to duplicate requests:',
'Interface redirected!': 'Interface redirected!',
'Incorrect parameter!': 'Incorrect parameter!',
'You do not have permission to operate!': 'You have no permission to operate!',
'Error requesting address:': 'Error requesting address:',
'Request timed out!': 'Request timeout!',
'The same data already exists in the system!': 'The same data already exists on the system!',
'Server internal error!': 'Internal server error!',
'Service not implemented!': 'Service unrealized!',
'Gateway error!': 'Gateway error!',
'Service unavailable!': 'Service unavailable!',
'The service is temporarily unavailable Please try again later!': 'The service is temporarily unavailable, please try again later!',
'HTTP version is not supported!': 'HTTP version is not Unsupported!',
'Abnormal problem, please contact the website administrator!': 'Abnormal problems, please contact the website administrator!',
'Network request timeout!': 'Network request timeout!',
'Server exception!': 'Server-side exceptions!',
'You are disconnected!': 'You are disconnected!',
}

View File

@@ -0,0 +1,11 @@
export default {
home: 'Home',
admin: 'Admin',
adminLogin: 'Login',
notFound: 'Page not found',
noPower: 'No access permission',
noTitle: 'No title',
loading: 'Loading...',
user: 'Member Center',
userLogin: 'Menber Login',
}

View File

@@ -0,0 +1,88 @@
export default {
'The moving position is beyond the movable range!': 'The movement position is beyond the removable range!',
'Navigation failed, the menu type is unrecognized!': 'Navigation failed, menu type not recognized!',
'Navigation failed, navigation guard intercepted!': 'Navigation failed, Navigation Guard interception!',
'Navigation failed, it is at the navigation target position!': 'Navigation failed, it is already at the navigation the position!',
'Navigation failed, invalid route!': 'Navigation failed, invalid route!',
'No child menu to jump to!': 'No child menu to jump to!',
Loading: 'Loading...',
Reload: 'Reload',
comma: ',',
'welcome back': 'Welcome back!',
'Late at night, pay attention to your body!': 'It is late at night. Please tack care of your body!',
'good morning!': 'Good morning!',
'Good morning!': 'Good morning!',
'Good noon!': 'Good noon!',
'good afternoon': 'Good afternoon.',
'Good evening': 'Good evening',
'Hello!': 'Hello!',
open: 'Open',
close: 'Close',
'Clean up system cache': 'Clean up the system cache',
'Clean up browser cache': 'Clean up browser cache',
'Clean up all cache': 'Clean up all cache',
'The data of the uploaded file is incomplete!': 'The data of the uploaded file is incomplete!',
'The type of uploaded file is not allowed!': 'The type of uploaded file is not allowed!',
'The size of the uploaded file exceeds the allowed range!': 'The size of the uploaded file exceeds the allowed range!',
'Please install editor': 'Please install editor',
// 输入框类型
mobile: 'Mobile Number',
'Id number': 'Id Number',
account: 'Account name',
password: 'password',
'variable name': 'Variable Name',
email: 'Email address',
date: 'Date',
number: 'Number',
float: 'Float',
integer: 'Integer',
time: 'Time',
file: 'File',
array: 'Array',
switch: 'Switch',
year: 'Year',
image: 'Image',
select: 'Select',
string: 'String',
radio: 'Radio',
checkbox: 'checkbox',
'rich Text': 'Rich Text',
'multi image': 'Multi image',
textarea: 'Textarea',
'time date': 'Time Date',
'remote select': 'Remote Select',
'city select': 'City select',
'icon select': 'Icon select',
'color picker': 'color picker',
color: 'color',
choice: ' Choice',
Icon: 'Icon',
'Local icon title': 'Local icon:/src/assets/icons Inside.svg',
'Please select an icon': 'Please select an icon',
'Ali iconcont Icon': 'Ali Iconfont Icon',
'Select File': 'Select File',
'Original name': 'Original name',
'You can also select': 'You can also select',
items: 'items',
Breakdown: 'Detailed catalogue',
size: 'Size',
type: 'Type',
preview: 'Preview',
'Upload (Reference) times': 'Upload (Reference) times',
'Last upload time': 'Last upload time',
'One attribute per line without quotation marks(formitem)':
'Extensions to FormItem, One attribute per line, no quotation marks required, such as: class=config-item',
'Extended properties of Input, one line without quotation marks, such as: size=large':
'Extended properties of Input, one line without quotation marks, such as: size=large',
'One line at a time, without quotation marks, for example: key1=value1': 'One per line, no quotation marks required, such as: key1=value1',
Var: 'Var ',
Name: 'Name',
Title: 'Title',
Tip: 'Tip',
Rule: 'Rule',
Extend: 'Extend',
Dict: 'Dict',
ArrayKey: 'Key',
ArrayValue: 'Value',
'No data': 'No data',
}

View File

@@ -0,0 +1,18 @@
export default {
'Captcha loading failed, please click refresh button': 'Captcha loading failed, please click refresh button',
'The correct area is not clicked, please try again!': 'The correct area is not clicked, please try again!',
'Verification is successful!': 'Verification is successful!',
'Please click': 'Please click',
'Please enter the correct mobile number': 'Please enter the correct mobile number',
'Please enter the correct account': 'The account requires 3 to 15 characters and contains a-z A-Z 0-9 _',
'Please enter the correct password': 'The password requires 6 to 32 characters and cannot contains & < > " \'',
'Please enter the correct name': 'Please enter the correct name',
'Content cannot be empty': 'The content cannot be blank',
'Floating point number': ' Floating number',
required: 'Required',
'editor required': 'editor Required',
'Please enter the correct ID number': 'Please enter the correct ID number',
number: 'Number (including float and integer)',
integer: 'Integer (excluding float)',
float: 'Float (excluding integer)',
}

View File

@@ -0,0 +1,4 @@
export default {
noPowerTip:
'这不是你想要的,但我们是认真的。我只是想用一种特殊的方式告诉你,你无权访问此页面,或者该文件无效。您可以联系网站管理员以更快地解决问题,或返回网站首页浏览其他页面。',
}

View File

@@ -0,0 +1,6 @@
export default {
'problems tip': '你的网页遇到了一些问题,系统正在优化和上报故障信息,我们在未来将改善和减少这种情况的发生.',
'We will automatically return to the previous page when we are finished': '我们将在完成后自动返回到上一页。',
'Return to home page': '返回首页',
'Back to previous page': '返回上一页',
}

View File

@@ -0,0 +1,20 @@
export default {
'Operation successful': '操作成功',
'Automatic cancellation due to duplicate request:': '因为请求重复被自动取消:',
'Interface redirected!': '接口重定向了!',
'Incorrect parameter!': '参数不正确!',
'You do not have permission to operate!': '您没有权限操作!',
'Error requesting address:': '请求地址出错:',
'Request timed out!': '请求超时!',
'The same data already exists in the system!': '系统已存在相同数据!',
'Server internal error!': '服务器内部错误!',
'Service not implemented!': '服务未实现!',
'Gateway error!': '网关错误!',
'Service unavailable!': '服务不可用!',
'The service is temporarily unavailable Please try again later!': '服务暂时无法访问,请稍后再试!',
'HTTP version is not supported!': 'HTTP版本不受支持',
'Abnormal problem, please contact the website administrator!': '异常问题,请联系网站管理员!',
'Network request timeout!': '网络请求超时!',
'Server exception!': '服务端异常!',
'You are disconnected!': '您断网了!',
}

View File

@@ -0,0 +1,11 @@
export default {
home: '首页',
admin: '后台',
adminLogin: '登录',
notFound: '页面找不到了',
noPower: '无访问权限',
noTitle: '无标题',
loading: 'Loading...',
user: '会员中心',
userLogin: '会员登录',
}

View File

@@ -0,0 +1,86 @@
export default {
'The moving position is beyond the movable range!': '移动位置超出了可移动范围!',
'Navigation failed, the menu type is unrecognized!': '导航失败,菜单类型无法识别!',
'Navigation failed, navigation guard intercepted!': '导航失败,导航守卫拦截!',
'Navigation failed, it is at the navigation target position!': '导航失败,已在导航目标位置!',
'Navigation failed, invalid route!': '导航失败,路由无效!',
'No child menu to jump to!': '没有找到可以跳转的子级菜单!',
Loading: '加载中...',
Reload: '重新加载',
comma: '',
'welcome back': '欢迎回来!',
'Late at night, pay attention to your body!': '夜深了,注意身体哦!',
'good morning!': '早上好!',
'Good morning!': '上午好!',
'Good noon!': '中午好!',
'good afternoon': '下午好!',
'Good evening': '晚上好!',
'Hello!': '您好!',
open: '开启',
close: '关闭',
'Clean up system cache': '清理系统缓存',
'Clean up browser cache': '清理浏览器缓存',
'Clean up all cache': '一键清理所有',
'The data of the uploaded file is incomplete!': '上传文件的资料不完整!',
'The type of uploaded file is not allowed!': '上传文件的类型不被允许!',
'The size of the uploaded file exceeds the allowed range!': '上传文件的大小超出允许范围!',
'Please install editor': '请先于模块市场安装富文本编辑器。',
// 输入框类型
mobile: '手机号',
'Id number': '身份证号',
account: '账户名',
password: '密码',
'variable name': '变量名',
email: '邮箱地址',
date: '日期',
number: '数字',
float: '浮点数',
integer: '整数',
time: '时间',
file: '文件',
array: '数组',
switch: '开关',
year: '年份',
image: '图片',
select: '下拉框',
string: '字符串',
radio: '单选框',
checkbox: '复选框',
'rich Text': '富文本',
'multi image': '多图',
textarea: '多行文本框',
'time date': '时间日期',
'remote select': '远程下拉',
'city select': '城市选择',
'icon select': '图标选择',
'color picker': '颜色选择器',
color: '颜色',
choice: '选择',
Icon: '图标',
'Local icon title': '本地图标:/src/assets/icons中的.svg',
'Please select an icon': '请选择图标',
'Ali iconcont Icon': '阿里 Iconfont 图标',
'Select File': '选择文件',
'Original name': '原始名称',
'You can also select': '还可以选择',
items: '项',
Breakdown: '细目',
size: '大小',
type: '类型',
preview: '预览',
'Upload (Reference) times': '上传(引用)次数',
'Last upload time': '最后上传时间',
'One attribute per line without quotation marks(formitem)': 'FormItem 的扩展属性一行一个无需引号比如class=config-item',
'Extended properties of Input, one line without quotation marks, such as: size=large': 'Input 的扩展属性一行一个无需引号比如size=large',
'One line at a time, without quotation marks, for example: key1=value1': '一行一个无需引号比如key1=value1',
Var: '变量',
Name: '名',
Title: '标题',
Tip: '提示信息',
Rule: '验证规则',
Extend: '扩展属性',
Dict: '字典数据',
ArrayKey: '键名',
ArrayValue: '键值',
'No data': '无数据',
}

View File

@@ -0,0 +1,18 @@
export default {
'Captcha loading failed, please click refresh button': '验证码加载失败,请点击刷新按钮',
'The correct area is not clicked, please try again!': '未点中正确区域,请重试!',
'Verification is successful!': '验证成功!',
'Please click': '请依次点击',
'Please enter the correct mobile number': '请输入正确的手机号',
'Please enter the correct account': '要求3到15位字母开头且只含字母、数字、下划线',
'Please enter the correct password': '密码要求6到32位不能包含 & < > " \'',
'Please enter the correct name': '请输入正确的名称',
'Content cannot be empty': '内容不能为空',
'Floating point number': '浮点数',
required: '必填',
'editor required': '富文本必填',
'Please enter the correct ID number': '请输入正确的身份证号码',
number: '数字(包括浮点数和整数)',
integer: '整数(不包括浮点数)',
float: '浮点数(不包括整数)',
}

View File

@@ -0,0 +1,12 @@
/**
* frontend common language package
*/
export default {
Integral: 'Integral',
Balance: 'Balance',
Language: 'Language',
Copyright: 'Copyright',
'Member Center': 'Member Center',
'Logout login': 'Logout',
'Member center disabled': 'The member center has been disabled. Please contact the webmaster to turn it on.',
}

View File

@@ -0,0 +1,3 @@
export default {
'Steve Jobs': "Great art don't have to follow the trend, it alone can lead.-- Steve Jobs",
}

View File

@@ -0,0 +1,6 @@
export default {
'Change time': 'Change time',
'Current balance': 'Current balance',
'Balance after change': 'Balance after change',
'Balance change record': 'Balance change record',
}

View File

@@ -0,0 +1,8 @@
export default {
'Change Password': 'Change Password',
'Old password': 'Old password',
'New password': 'New password',
'Confirm new password': 'Confirm new password',
'Please enter your current password': 'Please enter your current password',
'The duplicate password does not match the new password': 'The duplicate password does not match the new password',
}

View File

@@ -0,0 +1,6 @@
export default {
'Change time': 'Change time',
'Current points': 'Current points',
'Points after change': 'Points after change',
'Score change record': 'Score change record',
}

View File

@@ -0,0 +1,11 @@
export default {
'Account information': 'Account information',
profile: 'Profile',
'Filled in': 'Filled in',
'Not filled in': 'Not filled in',
mobile: 'mobile',
email: 'email',
'Last login IP': 'Last login IP',
'Last login': 'Last login',
'Growth statistics': 'Growth statistics',
}

View File

@@ -0,0 +1,32 @@
export default {
profile: 'Profile',
'Change Password': 'Change Password',
avatar: 'Avatar',
'User name': 'User name',
'User nickname': 'User nickname',
mail: 'mail',
email: 'email',
'Operation via right button': 'Operation via right button',
'Click Modify': 'Click Modify',
bind: 'bind',
mobile: 'mobile',
Gender: 'Gender',
secrecy: 'secrecy',
male: 'male',
female: 'female',
birthday: 'birthday',
'Personal signature': 'Personal signature',
'Account verification': 'Account verification',
'Account password verification': 'Account password verification',
'Mail verification': 'Mail verification',
'SMS verification': 'SMS verification',
password: 'password',
accept: 'accept',
'next step': 'next step',
'New email': 'New email',
'New mobile': 'New mobile',
'Verification Code': 'Captcha',
send: 'send',
seconds: 'seconds',
nickname: 'nickname',
}

View File

@@ -0,0 +1,24 @@
export default {
reach: ' Reach ',
login: 'Login',
register: 'Register',
'Via email': 'By email',
'Via mobile number': 'By mobile number',
'User name': 'User name',
account: 'Username/Email/Mobile',
password: 'Password',
'Verification Code': 'Captcha',
mobile: 'mobile',
email: 'email',
send: 'send',
seconds: 'seconds',
'Remember me': 'Remember me',
'Forgot your password?': 'Forgot your password?',
'Back to login': 'Back to login',
'No account yet? Click Register': 'No account yet? Click Register',
'Retrieve password': 'Retrieve password',
'Retrieval method': 'Retrieval method',
'New password': 'New password',
second: 'second',
'Account name': 'Account name',
}

View File

@@ -0,0 +1,13 @@
/**
* 前台公共语言包
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key
*/
export default {
Integral: '积分',
Balance: '余额',
Language: '语言',
Copyright: '版权所有',
'Member Center': '会员中心',
'Logout login': '注销登录',
'Member center disabled': '会员中心已禁用,请联系网站管理员开启。',
}

View File

@@ -0,0 +1,3 @@
export default {
'Steve Jobs': '伟大的艺术品不必追随潮流,他本身就能引领潮流。 -- 乔布斯',
}

View File

@@ -0,0 +1,6 @@
export default {
'Change time': '变更时间',
'Current balance': '当前余额',
'Balance after change': '变更后余额',
'Balance change record': '余额变更记录',
}

View File

@@ -0,0 +1,8 @@
export default {
'Change Password': '修改密码',
'Old password': '旧密码',
'New password': '新密码',
'Confirm new password': '确认新密码',
'Please enter your current password': '请输入现在的密码',
'The duplicate password does not match the new password': '重复密码与新密码不相符',
}

View File

@@ -0,0 +1,6 @@
export default {
'Change time': '变更时间',
'Current points': '当前积分',
'Points after change': '变更后积分',
'Score change record': '积分变更记录',
}

View File

@@ -0,0 +1,11 @@
export default {
'Account information': '账户信息',
profile: '个人资料',
'Filled in': '已填写',
'Not filled in': '未填写',
mobile: '手机号',
email: '电子邮箱',
'Last login IP': '最后登录IP',
'Last login': '最后登录',
'Growth statistics': '增长统计',
}

View File

@@ -0,0 +1,32 @@
export default {
profile: '个人资料',
'Change Password': '修改密码',
avatar: '头像',
'User name': '用户名',
'User nickname': '用户昵称',
mail: '邮箱',
email: '电子邮箱',
'Operation via right button': '通过右侧按钮操作',
'Click Modify': '点击修改',
bind: '绑定',
mobile: '手机号',
Gender: '性别',
secrecy: '保密',
male: '男',
female: '女',
birthday: '生日',
'Personal signature': '个性签名',
'Account verification': '账户验证',
'Account password verification': '账户密码验证',
'Mail verification': '邮件验证',
'SMS verification': '短信验证',
password: '密码',
accept: '接受',
'next step': '下一步',
'New email': '新邮箱',
'New mobile': '新手机号',
'Verification Code': '验证码',
send: '发送',
seconds: '秒',
nickname: '昵称',
}

View File

@@ -0,0 +1,24 @@
export default {
reach: '到',
login: '登录',
register: '注册',
'Via email': '通过邮箱',
'Via mobile number': '通过手机号',
'User name': '用户名',
account: '用户名/邮箱/手机号',
password: '密码',
'Verification Code': '验证码',
mobile: '手机号',
email: '电子邮箱',
send: '发送',
seconds: '秒',
'Remember me': '记住我',
'Forgot your password?': '忘记密码?',
'Back to login': '回到登录',
'No account yet? Click Register': '还没有账户?点击注册',
'Retrieve password': '找回密码',
'Retrieval method': '找回方式',
'New password': '新密码',
second: '确定',
'Account name': '账户',
}

50
web/src/lang/globs-en.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Global common language package
*/
export default {
Id: 'ID',
State: 'State',
Home: 'Home',
Complete: 'Completed',
Edit: 'Edit',
Add: 'Add',
Info: 'Details',
Delete: 'Delete',
Refresh: 'Refresh',
Operate: 'Operate',
Confirm: 'Confirm',
Cancel: 'Cancel',
Save: 'Save',
Upload: 'Upload',
Retry: 'Retry',
Reminder: 'Reminder',
Disable: 'Disable',
Enable: 'Enable',
Shrink: 'Shrink',
Open: 'Open',
Search: 'Search',
Reset: 'Reset',
To: 'To',
None: 'None',
Unknown: 'Unknown',
Weigh: 'weigh',
'Drag sort': 'Drag sort',
'Save and edit next item': 'save and edit next item',
'Quick search placeholder': 'Fuzzy search by {fields}',
'Please select field': 'Please select {field}',
'Please input field': 'Please input {field}',
'Please enter the correct field': 'Please enter the correct {field}',
'Update time': 'Update time',
'Create time': 'Create time',
'Fuzzy query': 'Fuzzy query',
'Click select': 'Click select',
'Edit selected row': 'Edit selected row',
'Delete selected row': 'Delete selected row',
'Are you sure to delete the selected record?': 'Are you sure to delete the selected record?',
'All submenus': 'All submenus',
'Shrink all': 'Shrinkage all',
'Expand all': 'Expand all',
'Expand generic search': 'Expand Universal Search',
'Link address': 'Link address',
'No route found to jump~': 'Failed to find a jump route.',
}

View File

@@ -0,0 +1,51 @@
/**
* 全局公共语言包
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key、请使用大写开头避免覆盖
*/
export default {
Id: 'ID',
State: '状态',
Home: '首页',
Complete: '完成',
Edit: '编辑',
Add: '添加',
Info: '查看详情',
Delete: '删除',
Refresh: '刷新',
Operate: '操作',
Confirm: '确认',
Cancel: '取消',
Save: '保存',
Upload: '上传',
Retry: '重试',
Reminder: '温馨提示',
Disable: '禁用',
Enable: '启用',
Shrink: '收缩',
Open: '展开',
Search: '搜索',
Reset: '重置',
To: '至',
None: '无',
Unknown: '未知',
Weigh: '权重',
'Drag sort': '拖动以排序',
'Save and edit next item': '保存并编辑下一项',
'Quick search placeholder': '通过{fields}模糊搜索',
'Please select field': '请选择{field}',
'Please input field': '请输入{field}',
'Please enter the correct field': '请输入正确的{field}',
'Update time': '修改时间',
'Create time': '创建时间',
'Fuzzy query': '模糊查询',
'Click select': '点击选择',
'Edit selected row': '编辑选中行',
'Delete selected row': '删除选中行',
'Are you sure to delete the selected record?': '确定删除选中记录?',
'All submenus': '所有子菜单',
'Shrink all': '收缩所有',
'Expand all': '展开所有',
'Expand generic search': '展开公共搜索',
'Link address': '链接地址',
'No route found to jump~': '没有找到可以跳转的路由~',
}

148
web/src/lang/index.ts Normal file
View File

@@ -0,0 +1,148 @@
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import type { I18n, Composer } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
import { isEmpty } from 'lodash-es'
/*
* 默认只引入 element-plus 的中英文语言包
* 其他语言包请自行在此 import,并添加到 assignLocale 内
* 动态 import 只支持相对路径,所以无法按需 import element-plus 的语言包
* 但i18n的 messages 内是按需载入的
*/
import elementZhcnLocale from 'element-plus/es/locale/lang/zh-cn'
import elementEnLocale from 'element-plus/es/locale/lang/en'
export let i18n: {
global: Composer
}
// 准备要合并的语言包
const assignLocale: anyObj = {
'zh-cn': [elementZhcnLocale],
en: [elementEnLocale],
}
export async function loadLang(app: App) {
const config = useConfig()
const locale = config.lang.defaultLang
// 加载框架全局语言包
const lang = await import(`./globs-${locale}.ts`)
const message = lang.default ?? {}
// 按需加载语言包文件的句柄
if (locale == 'zh-cn') {
window.loadLangHandle = {
...import.meta.glob('./backend/zh-cn/**/*.ts'),
...import.meta.glob('./frontend/zh-cn/**/*.ts'),
...import.meta.glob('./backend/zh-cn.ts'),
...import.meta.glob('./frontend/zh-cn.ts'),
}
} else {
window.loadLangHandle = {
...import.meta.glob('./backend/en/**/*.ts'),
...import.meta.glob('./frontend/en/**/*.ts'),
...import.meta.glob('./backend/en.ts'),
...import.meta.glob('./frontend/en.ts'),
}
}
/*
* 加载页面语言包 import.meta.glob 的路径不能使用变量 import() 在 Vite 中目录名不能使用变量(编译后,文件名可以)
*/
if (locale == 'zh-cn') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/zh-cn/**/*.ts', { eager: true }), locale))
} else if (locale == 'en') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/en/**/*.ts', { eager: true }), locale))
}
const messages = {
[locale]: {
...message,
},
}
// 合并语言包(含element-puls、页面语言包)
Object.assign(messages[locale], ...assignLocale[locale])
i18n = createI18n({
locale: locale,
legacy: false, // 组合式api
globalInjection: true, // 挂载$t,$d等到全局
fallbackLocale: config.lang.fallbackLang,
messages,
})
app.use(i18n as I18n)
return i18n
}
function getLangFileMessage(mList: any, locale: string) {
let msg: anyObj = {}
locale = '/' + locale
for (const path in mList) {
if (mList[path].default) {
// 获取文件名
const pathName = path.slice(path.lastIndexOf(locale) + (locale.length + 1), path.lastIndexOf('.'))
if (pathName.indexOf('/') > 0) {
msg = handleMsglist(msg, mList[path].default, pathName)
} else {
msg[pathName] = mList[path].default
}
}
}
return msg
}
export function mergeMessage(message: anyObj, pathName = '') {
if (isEmpty(message)) return
if (!pathName) {
return i18n.global.mergeLocaleMessage(i18n.global.locale.value, message)
}
let msg: anyObj = {}
if (pathName.indexOf('/') > 0) {
msg = handleMsglist(msg, message, pathName)
} else {
msg[pathName] = message
}
i18n.global.mergeLocaleMessage(i18n.global.locale.value, msg)
}
export function handleMsglist(msg: anyObj, mList: anyObj, pathName: string) {
const pathNameTmp = pathName.split('/')
let obj: anyObj = {}
for (let i = pathNameTmp.length - 1; i >= 0; i--) {
if (i == pathNameTmp.length - 1) {
obj = {
[pathNameTmp[i]]: mList,
}
} else {
obj = {
[pathNameTmp[i]]: obj,
}
}
}
return mergeMsg(msg, obj)
}
export function mergeMsg(msg: anyObj, obj: anyObj) {
for (const key in obj) {
if (typeof msg[key] == 'undefined') {
msg[key] = obj[key]
} else if (typeof msg[key] == 'object') {
msg[key] = mergeMsg(msg[key], obj[key])
}
}
return msg
}
export function editDefaultLang(lang: string): void {
const config = useConfig()
config.setLang(lang)
/*
* 语言包是按需加载的,比如默认语言为中文,则只在app实例内加载了中文语言包,所以切换语言需要进行 reload
*/
location.reload()
}

View File

@@ -0,0 +1,55 @@
<template>
<el-aside v-if="!navTabs.state.tabFullScreen" :class="['layout-aside-' + config.layout.layoutMode, config.layout.shrink ? 'shrink' : '']">
<Logo v-if="config.layout.menuShowTopBar" />
<MenuVerticalChildren v-if="config.layout.layoutMode == 'Double'" />
<MenuVertical v-else />
</el-aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Logo from '/@/layouts/backend/components/logo.vue'
import MenuVertical from '/@/layouts/backend/components/menus/menuVertical.vue'
import MenuVerticalChildren from '/@/layouts/backend/components/menus/menuVerticalChildren.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
defineOptions({
name: 'layout/aside',
})
const config = useConfig()
const navTabs = useNavTabs()
const menuWidth = computed(() => config.menuWidth())
</script>
<style scoped lang="scss">
.layout-aside-Default:not(.shrink) {
background: var(--ba-bg-color-overlay);
margin: 16px 0 16px 16px;
height: calc(100% - 32px);
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
overflow: hidden;
transition: width 0.3s ease;
width: v-bind(menuWidth);
}
.layout-aside-Default.shrink,
.layout-aside-Classic,
.layout-aside-Double {
background: var(--ba-bg-color-overlay);
margin: 0;
height: 100%;
overflow: hidden;
transition: width 0.3s ease;
width: v-bind(menuWidth);
}
.shrink {
position: fixed;
top: 0;
left: 0;
z-index: v-bind('SYSTEM_ZINDEX');
}
</style>

View File

@@ -0,0 +1,259 @@
<!-- 模块市场 CRUD 记录页面等地方的 BuildAdmin 官方账户登录弹窗 -->
<template>
<div>
<el-dialog v-model="model" class="ba-account-dialog" width="25%" :title="t('layouts.Member information')">
<template v-if="baAccount.token">
<div v-loading="state.loading" class="userinfo">
<div class="user-avatar-box">
<img class="user-avatar" :src="baAccount.avatar" alt="" />
<Icon
class="user-avatar-gender"
:name="baAccount.getGenderIcon()['name']"
size="14"
:color="baAccount.getGenderIcon()['color']"
/>
</div>
<p class="username">{{ baAccount.nickname }}</p>
<p class="user-info">
<span>{{ $t('Integral') + ' ' + baAccount.score }}</span>
<span>{{ $t('Balance') + ' ' + baAccount.money }}</span>
</p>
<div class="userinfo-buttons">
<a href="https://uni.buildadmin.com/user" target="_blank" rel="noopener noreferrer">
<el-button v-blur size="default" type="primary">
{{ $t('layouts.Member center') }}
</el-button>
</a>
<el-button @click="baAccount.logout()" v-blur size="default" type="warning">{{ $t('layouts.Logout') }}</el-button>
</div>
</div>
</template>
<template v-else>
<div class="ba-login">
<h3 class="ba-title">{{ t('layouts.Login to the buildadmin') }}</h3>
<el-form
@keyup.enter="onBaAccountSubmitPre()"
ref="baAccountFormRef"
:rules="baAccountFormRules"
class="ba-account-login-form"
:model="state.user"
>
<FormItem
v-model="state.user.username"
type="string"
prop="username"
:placeholder="t('layouts.Please enter buildadmin account name or email')"
:input-attr="{
size: 'large',
}"
/>
<FormItem
v-model="state.user.password"
type="password"
prop="password"
:placeholder="t('layouts.Please enter the buildadmin account password')"
:input-attr="{
size: 'large',
}"
/>
<el-form-item class="form-buttons">
<el-button @click="onBaAccountSubmitPre()" :loading="state.submitLoading" round type="primary" size="large">
{{ t('layouts.Login') }}
</el-button>
<a
target="_blank"
class="ba-account-register"
href="https://uni.buildadmin.com/user/login?type=register"
rel="noopener noreferrer"
>
<el-button round plain type="info" size="large"> {{ t('layouts.Register') }} </el-button>
</a>
</el-form-item>
</el-form>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { baAccountCheckIn, baAccountGetUserInfo } from '/@/api/backend/index'
import clickCaptcha from '/@/components/clickCaptcha'
import FormItem from '/@/components/formItem/index.vue'
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import { uuid } from '/@/utils/random'
import { buildValidatorData } from '/@/utils/validate'
const { t } = useI18n()
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
const model = defineModel<boolean>()
const baAccountFormRef = useTemplateRef('baAccountFormRef')
interface Props {
loginCallback?: (res: ApiResponse) => void
}
const props = withDefaults(defineProps<Props>(), {
loginCallback: () => {},
})
const state = reactive({
loading: true,
submitLoading: false,
user: {
tab: 'login',
username: '',
password: '',
captchaId: uuid(),
captchaInfo: '',
keep: false,
},
})
const onBaAccountSubmitPre = () => {
baAccountFormRef.value?.validate((valid) => {
if (valid) {
clickCaptcha(state.user.captchaId, (captchaInfo: string) => onBaAccountSubmit(captchaInfo), { apiBaseURL: siteConfig.apiUrl })
}
})
}
const onBaAccountSubmit = (captchaInfo = '') => {
state.submitLoading = true
state.user.captchaInfo = captchaInfo
baAccountCheckIn(state.user)
.then((res) => {
baAccount.dataFill(res.data.userInfo, false)
props.loginCallback(res)
})
.finally(() => {
state.submitLoading = false
})
}
const baAccountFormRules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('layouts.Username') })],
password: [buildValidatorData({ name: 'required', title: t('layouts.Password') }), buildValidatorData({ name: 'password' })],
})
watch(
() => model.value,
(newVal) => {
if (newVal && baAccount.token) {
baAccountGetUserInfo()
.then((res) => {
baAccount.dataFill(res.data.userInfo)
})
.catch(() => {
baAccount.removeToken()
})
.finally(() => {
state.loading = false
})
}
}
)
</script>
<style scoped lang="scss">
.userinfo {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 20px 0;
.username {
display: block;
text-align: center;
width: 100%;
padding-top: 10px;
font-size: var(--el-font-size-large);
font-weight: bold;
}
.user-info {
display: block;
text-align: center;
width: 100%;
padding: 10px 0;
font-size: var(--el-font-size-base);
span {
padding: 0 4px;
}
}
.user-avatar-box {
position: relative;
cursor: pointer;
}
.user-avatar {
display: block;
width: 100px;
border-radius: 50%;
border: 1px solid var(--el-border-color-extra-light);
}
.user-avatar-gender {
position: absolute;
bottom: 0px;
right: 10px;
height: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
box-shadow: var(--el-box-shadow);
}
.userinfo-buttons {
margin-top: 10px;
a {
margin-right: 15px;
}
}
}
.ba-login {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 20px;
.ba-title {
width: 100%;
text-align: center;
}
.form-buttons {
.el-button {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 20px;
margin-left: 0;
}
}
.ba-account-register {
width: 100%;
text-decoration: none;
}
.ba-account-login-form {
width: 350px;
padding-top: 20px;
}
}
/* 会员信息弹窗-s */
@media screen and (max-width: 1440px) {
:deep(.ba-account-dialog) {
--el-dialog-width: 40% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.ba-account-dialog) {
--el-dialog-width: 70% !important;
}
}
/* 会员信息弹窗-e */
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div :title="$t('layouts.Exit full screen')" @mouseover.stop="onMouseover" @mouseout.stop="onMouseout">
<div @click.stop="onCloseFullScreen" class="close-full-screen" :style="{ top: state.closeBoxTop + 'px' }">
<Icon name="el-icon-Close" />
</div>
<div class="close-full-screen-on"></div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useNavTabs } from '/@/stores/navTabs'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const navTabs = useNavTabs()
const state = reactive({
closeBoxTop: 20,
})
onMounted(() => {
setTimeout(() => {
state.closeBoxTop = -30
}, 300)
})
/*
* 鼠标滑到顶部显示关闭全屏按钮
* 要检查 hover 的元素在外部直接使用事件而不是css
*/
const onMouseover = () => {
state.closeBoxTop = 20
}
const onMouseout = () => {
state.closeBoxTop = -30
}
const onCloseFullScreen = () => {
navTabs.setFullScreen(false)
}
</script>
<style scoped lang="scss">
.close-full-screen {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
right: calc(50% - 20px);
z-index: v-bind('SYSTEM_ZINDEX');
height: 40px;
width: 40px;
background-color: rgba($color: #000000, $alpha: 0.1);
border-radius: 50%;
box-shadow: var(--el-box-shadow-light);
transition: all 0.3s ease;
.icon {
color: rgba($color: #000000, $alpha: 0.6) !important;
}
&:hover {
background-color: rgba($color: #000000, $alpha: 0.3);
.icon {
color: rgba($color: #ffffff, $alpha: 0.6) !important;
}
}
}
.close-full-screen-on {
position: fixed;
top: 0;
z-index: v-bind('SYSTEM_ZINDEX - 1');
height: 60px;
width: 100px;
left: calc(50% - 50px);
}
</style>

View File

@@ -0,0 +1,420 @@
<template>
<div class="layout-config-drawer">
<el-drawer :model-value="configStore.layout.showDrawer" :title="t('layouts.Layout configuration')" size="310px" @close="onCloseDrawer">
<el-scrollbar class="layout-mode-style-scrollbar">
<el-form :model="configStore.layout">
<div class="layout-mode-styles-box">
<el-divider border-style="dashed">{{ t('layouts.Layout mode') }}</el-divider>
<div class="layout-mode-box-style">
<el-row class="layout-mode-box-style-row" :gutter="10">
<el-col :span="12">
<div
@click="setLayoutMode('Default')"
class="layout-mode-style default"
:class="configStore.layout.layoutMode == 'Default' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.default') }}</div>
</div>
</el-col>
<el-col :span="12">
<div
@click="setLayoutMode('Classic')"
class="layout-mode-style classic"
:class="configStore.layout.layoutMode == 'Classic' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.classic') }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="12">
<div
@click="setLayoutMode('Streamline')"
class="layout-mode-style streamline"
:class="configStore.layout.layoutMode == 'Streamline' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.Single column') }}</div>
</div>
</el-col>
<el-col :span="12">
<div
@click="setLayoutMode('Double')"
class="layout-mode-style double"
:class="configStore.layout.layoutMode == 'Double' ? 'active' : ''"
>
<div class="layout-mode-style-box">
<div class="layout-mode-style-aside"></div>
<div class="layout-mode-style-container-box">
<div class="layout-mode-style-header"></div>
<div class="layout-mode-style-container"></div>
</div>
</div>
<div class="layout-mode-style-name">{{ t('layouts.Double column') }}</div>
</div>
</el-col>
</el-row>
</div>
<el-divider border-style="dashed">{{ t('layouts.overall situation') }}</el-divider>
<div class="layout-config-global">
<el-form-item size="large" :label="t('layouts.Dark mode')">
<DarkSwitch @click="toggleDark()" />
</el-form-item>
<el-form-item :label="t('layouts.Background page switching animation')">
<el-select
@change="onCommitState($event, 'mainAnimation')"
:model-value="configStore.layout.mainAnimation"
:placeholder="t('layouts.Please select an animation name')"
>
<el-option label="slide-right" value="slide-right"></el-option>
<el-option label="slide-left" value="slide-left"></el-option>
<el-option label="el-fade-in-linear" value="el-fade-in-linear"></el-option>
<el-option label="el-fade-in" value="el-fade-in"></el-option>
<el-option label="el-zoom-in-center" value="el-zoom-in-center"></el-option>
<el-option label="el-zoom-in-top" value="el-zoom-in-top"></el-option>
<el-option label="el-zoom-in-bottom" value="el-zoom-in-bottom"></el-option>
</el-select>
</el-form-item>
</div>
<el-divider border-style="dashed">{{ t('layouts.sidebar') }}</el-divider>
<div class="layout-config-aside">
<el-form-item :label="t('layouts.Side menu bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuBackground')"
:model-value="configStore.getColorVal('menuBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu text color')">
<el-color-picker
@change="onCommitColorState($event, 'menuColor')"
:model-value="configStore.getColorVal('menuColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu active item background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuActiveBackground')"
:model-value="configStore.getColorVal('menuActiveBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu active item text color')">
<el-color-picker
@change="onCommitColorState($event, 'menuActiveColor')"
:model-value="configStore.getColorVal('menuActiveColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Show side menu top bar (logo bar)')">
<el-switch
@change="onCommitState($event, 'menuShowTopBar')"
:model-value="configStore.layout.menuShowTopBar"
></el-switch>
</el-form-item>
<el-form-item :label="t('layouts.Side menu top bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'menuTopBarBackground')"
:model-value="configStore.getColorVal('menuTopBarBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu width (when expanded)')">
<el-input
@input="onCommitState($event, 'menuWidth')"
type="number"
:step="10"
:model-value="configStore.layout.menuWidth"
>
<template #append>px</template>
</el-input>
</el-form-item>
<el-form-item :label="t('layouts.Side menu default icon')">
<IconSelector
@change="onCommitMenuDefaultIcon($event, 'menuDefaultIcon')"
:model-value="configStore.layout.menuDefaultIcon"
/>
</el-form-item>
<el-form-item :label="t('layouts.Side menu horizontal collapse')">
<el-switch @change="onCommitState($event, 'menuCollapse')" :model-value="configStore.layout.menuCollapse"></el-switch>
</el-form-item>
<el-form-item :label="t('layouts.Side menu accordion')">
<el-switch
@change="onCommitState($event, 'menuUniqueOpened')"
:model-value="configStore.layout.menuUniqueOpened"
></el-switch>
</el-form-item>
</div>
<el-divider border-style="dashed">{{ t('layouts.Top bar') }}</el-divider>
<div class="layout-config-aside">
<el-form-item :label="t('layouts.Top bar background color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarBackground')"
:model-value="configStore.getColorVal('headerBarBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar text color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabColor')"
:model-value="configStore.getColorVal('headerBarTabColor')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Background color when hovering over the top bar')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarHoverBackground')"
:model-value="configStore.getColorVal('headerBarHoverBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar menu active item background color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabActiveBackground')"
:model-value="configStore.getColorVal('headerBarTabActiveBackground')"
/>
</el-form-item>
<el-form-item :label="t('layouts.Top bar menu active item text color')">
<el-color-picker
@change="onCommitColorState($event, 'headerBarTabActiveColor')"
:model-value="configStore.getColorVal('headerBarTabActiveColor')"
/>
</el-form-item>
</div>
<el-popconfirm
@confirm="restoreDefault"
:title="t('layouts.Are you sure you want to restore all configurations to the default values?')"
>
<template #reference>
<div class="ba-center">
<el-button class="w80" type="info">{{ t('layouts.Restore default') }}</el-button>
</div>
</template>
</el-popconfirm>
</div>
</el-form>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconSelector from '/@/components/baInput/components/iconSelector.vue'
import DarkSwitch from '/@/layouts/common/components/darkSwitch.vue'
import { useConfig } from '/@/stores/config'
import { BEFORE_RESIZE_LAYOUT, STORE_CONFIG } from '/@/stores/constant/cacheKey'
import type { Layout } from '/@/stores/interface'
import { useNavTabs } from '/@/stores/navTabs'
import { Local, Session } from '/@/utils/storage'
import toggleDark from '/@/utils/useDark'
const { t } = useI18n()
const configStore = useConfig()
const navTabs = useNavTabs()
const router = useRouter()
const onCommitState = (value: any, name: any) => {
configStore.setLayout(name, value)
}
const onCommitColorState = (value: string | null, name: keyof Layout) => {
if (value === null) return
const colors = configStore.layout[name] as string[]
if (configStore.layout.isDark) {
colors[1] = value
} else {
colors[0] = value
}
configStore.setLayout(name, colors)
}
const setLayoutMode = (mode: string) => {
Session.set(BEFORE_RESIZE_LAYOUT, {
layoutMode: mode,
menuCollapse: configStore.layout.menuCollapse,
})
configStore.setLayoutMode(mode)
}
// 修改默认菜单图标
const onCommitMenuDefaultIcon = (value: any, name: any) => {
configStore.setLayout(name, value)
const menus = navTabs.state.tabsViewRoutes
navTabs.setTabsViewRoutes([])
nextTick(() => {
navTabs.setTabsViewRoutes(menus)
})
}
const onCloseDrawer = () => {
configStore.setLayout('showDrawer', false)
}
const restoreDefault = () => {
Local.remove(STORE_CONFIG)
Session.remove(BEFORE_RESIZE_LAYOUT)
router.go(0)
}
</script>
<style scoped lang="scss">
.layout-config-drawer :deep(.el-input__inner) {
padding: 0 0 0 6px;
}
.layout-config-drawer :deep(.el-input-group__append) {
padding: 0 10px;
}
.layout-config-drawer :deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-config-drawer :deep(.el-drawer__body) {
padding: 0;
}
.layout-mode-styles-box {
padding: 20px;
}
.layout-mode-box-style-row {
margin-bottom: 15px;
}
.layout-mode-style {
position: relative;
height: 100px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-small);
&:hover,
&.active {
border: 1px solid var(--el-color-primary);
}
.layout-mode-style-name {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary-light-5);
border-radius: 50%;
height: 50px;
width: 50px;
border: 1px solid var(--el-color-primary-light-3);
}
.layout-mode-style-box {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&.default {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 90%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container-box {
width: 68%;
height: 90%;
margin-left: 4%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container {
width: 100%;
height: 85%;
background-color: var(--el-border-color-extra-light);
margin-top: 5%;
}
}
}
&.classic {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 100%;
background-color: var(--el-border-color-lighter);
}
.layout-mode-style-container-box {
width: 82%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
&.streamline {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-container-box {
width: 100%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
&.double {
display: flex;
align-items: center;
justify-content: center;
.layout-mode-style-aside {
width: 18%;
height: 100%;
background-color: var(--el-border-color);
}
.layout-mode-style-container-box {
width: 82%;
height: 100%;
.layout-mode-style-header {
width: 100%;
height: 10%;
background-color: var(--el-border-color);
}
.layout-mode-style-container {
width: 100%;
height: 90%;
background-color: var(--el-border-color-extra-light);
}
}
}
}
.w80 {
width: 90%;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<el-header v-if="!navTabs.state.tabFullScreen" class="layout-header">
<component :is="config.layout.layoutMode + 'NavBar'"></component>
</el-header>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import DefaultNavBar from '/@/layouts/backend/components/navBar/default.vue'
import ClassicNavBar from '/@/layouts/backend/components/navBar/classic.vue'
import StreamlineNavBar from '/@/layouts/backend/components/menus/menuHorizontal.vue'
import DoubleNavBar from '/@/layouts/backend/components/navBar/double.vue'
defineOptions({
name: 'layout/header',
components: { DefaultNavBar, ClassicNavBar, StreamlineNavBar, DoubleNavBar },
})
const config = useConfig()
const navTabs = useNavTabs()
</script>
<style scoped lang="scss">
.layout-header {
height: auto;
padding: 0;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="layout-logo">
<img v-if="!config.layout.menuCollapse" class="logo-img" src="~assets/logo.png" alt="logo" />
<div v-if="!config.layout.menuCollapse" :style="{ color: config.getColorVal('menuActiveColor') }" class="website-name">
{{ siteConfig.siteName }}
</div>
<Icon
v-if="config.layout.layoutMode != 'Streamline'"
@click="onMenuCollapse"
:name="config.layout.menuCollapse ? 'fa fa-indent' : 'fa fa-dedent'"
:class="config.layout.menuCollapse ? 'unfold' : ''"
:color="config.getColorVal('menuActiveColor')"
size="18"
class="fold"
/>
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import { useSiteConfig } from '/@/stores/siteConfig'
import { closeShade } from '/@/utils/pageShade'
import { Session } from '/@/utils/storage'
import { BEFORE_RESIZE_LAYOUT } from '/@/stores/constant/cacheKey'
import { setNavTabsWidth } from '/@/utils/layout'
const config = useConfig()
const siteConfig = useSiteConfig()
const onMenuCollapse = function () {
if (config.layout.shrink && !config.layout.menuCollapse) {
closeShade()
}
config.setLayout('menuCollapse', !config.layout.menuCollapse)
Session.set(BEFORE_RESIZE_LAYOUT, {
layoutMode: config.layout.layoutMode,
menuCollapse: config.layout.menuCollapse,
})
// 等待侧边栏动画结束后重新计算导航栏宽度
setTimeout(() => {
setNavTabsWidth()
}, 350)
}
</script>
<style scoped lang="scss">
.layout-logo {
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 10px;
background: v-bind('config.layout.layoutMode != "Streamline" ? config.getColorVal("menuTopBarBackground"):"transparent"');
}
.logo-img {
width: 28px;
}
.website-name {
display: block;
width: 180px;
padding-left: 4px;
font-size: var(--el-font-size-extra-large);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fold {
margin-left: auto;
}
.unfold {
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="layouts-menu-horizontal">
<div class="menu-horizontal-logo" v-if="config.layout.menuShowTopBar">
<Logo />
</div>
<el-scrollbar ref="layoutMenuScrollbarRef" class="horizontal-menus-scrollbar">
<el-menu ref="layoutMenuRef" class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive">
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import Logo from '/@/layouts/backend/components/logo.vue'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import NavMenus from '/@/layouts/backend/components/navMenus.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
/**
* 激活当前路由对应的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
/**
* 滚动条横向滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollLeft(activeMenu.offsetLeft)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
new horizontalScroll(layoutMenuScrollbarRef.value!.wrapRef!)
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style scoped lang="scss">
.layouts-menu-horizontal {
display: flex;
align-items: center;
width: 100vw;
height: var(--el-header-height);
background-color: var(--ba-bg-color-overlay);
border-bottom: 1px solid var(--el-color-info-light-8);
}
.menu-horizontal-logo {
width: 180px;
&:hover {
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
}
}
.horizontal-menus-scrollbar {
flex: 1;
height: var(--el-header-height);
}
.menu-horizontal {
border: none;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<template v-for="menu in props.menus">
<template v-if="menu.children && menu.children.length > 0">
<el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<template #title>
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
</template>
<MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" />
</el-sub-menu>
</template>
<template v-else>
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
</el-menu-item>
</template>
</template>
</template>
<script setup lang="ts">
import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { RouteRecordRaw } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
const { t } = useI18n()
const config = useConfig()
interface Props {
menus: RouteRecordRaw[]
extends?: {
level: number
[key: string]: any
}
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
extends: () => {
return {
level: 1,
}
},
})
/**
* sub-menu-item 被点击 - 用于单栏布局和双栏布局
* 顶栏菜单:点击时打开第一个菜单
* 侧边菜单(若有):点击只展开收缩
*
* sub-menu-item 被点击时,也会触发到 menu-item 的点击事件,由 el-menu 内部触发,无法很好的排除,在此检查 level 值
*/
const onClickSubMenu = (menu: RouteRecordRaw) => {
if (props.extends?.position == 'horizontal' && props.extends.level <= 1 && menu.children?.length) {
const firstRoute = getFirstRoute(menu.children)
if (firstRoute) {
onClickMenu(firstRoute)
} else {
ElNotification({
type: 'error',
message: t('utils.No child menu to jump to!'),
})
}
}
}
</script>
<style scoped lang="scss">
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<el-scrollbar ref="layoutMenuScrollbarRef" class="vertical-menus-scrollbar">
<el-menu
class="layouts-menu-vertical"
:collapse-transition="false"
:unique-opened="config.layout.menuUniqueOpened"
:default-active="state.defaultActive"
:collapse="config.layout.menuCollapse"
ref="layoutMenuRef"
>
<MenuTree :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
const verticalMenusScrollbarHeight = computed(() => {
const menuTopBarHeight = config.layout.menuShowTopBar ? 50 : 0
return 'calc(100% - ' + menuTopBarHeight + 'px)'
})
/**
* 激活当前路由对应的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
/**
* 滚动条滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollTop(activeMenu.offsetTop)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style>
.vertical-menus-scrollbar {
height: v-bind(verticalMenusScrollbarHeight);
background-color: v-bind('config.getColorVal("menuBackground")');
}
.layouts-menu-vertical {
border: 0;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<el-scrollbar ref="layoutMenuScrollbarRef" class="children-vertical-menus-scrollbar">
<el-menu
class="layouts-menu-vertical-children"
:collapse-transition="false"
:unique-opened="config.layout.menuUniqueOpened"
:default-active="state.defaultActive"
:collapse="config.layout.menuCollapse"
ref="layoutMenuRef"
>
<MenuTree v-if="state.routeChildren.length > 0" :menus="state.routeChildren" />
</el-menu>
</el-scrollbar>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, useTemplateRef } from 'vue'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef } from '/@/stores/refs'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const layoutMenuScrollbarRef = useTemplateRef('layoutMenuScrollbarRef')
const state: {
defaultActive: string
routeChildren: RouteRecordRaw[]
} = reactive({
defaultActive: '',
routeChildren: [],
})
const verticalMenusScrollbarHeight = computed(() => {
const menuTopBarHeight = config.layout.menuShowTopBar ? 50 : 0
return 'calc(100% - ' + menuTopBarHeight + 'px)'
})
/**
* 激活当前路由的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
let routeChildren = navTabs.getTabsViewDataByRoute(currentRoute, 'above')
if (routeChildren) {
if (routeChildren.children && routeChildren.children.length > 0) {
state.routeChildren = routeChildren.children
} else {
state.routeChildren = [routeChildren]
}
} else if (!state.routeChildren) {
state.routeChildren = navTabs.state.tabsViewRoutes
}
}
/**
* 侧栏菜单滚动条滚动到激活菜单所在位置
*/
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical-children li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollTop(activeMenu.offsetTop)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style>
.children-vertical-menus-scrollbar {
height: v-bind(verticalMenusScrollbarHeight);
background-color: v-bind('config.getColorVal("menuBackground")');
}
.layouts-menu-vertical-children {
border: 0;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="nav-bar">
<div v-if="config.layout.shrink && config.layout.menuCollapse" class="unfold">
<Icon @click="onMenuCollapse" name="fa fa-indent" :color="config.getColorVal('menuActiveColor')" size="18" />
</div>
<NavTabs v-if="!config.layout.shrink" ref="layoutNavTabsRef" />
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import NavTabs from '/@/layouts/backend/components/navBar/tabs.vue'
import { layoutNavTabsRef } from '/@/stores/refs'
import NavMenus from '../navMenus.vue'
import { showShade } from '/@/utils/pageShade'
const config = useConfig()
const onMenuCollapse = () => {
showShade('ba-aside-menu-shade', () => {
config.setLayout('menuCollapse', true)
})
config.setLayout('menuCollapse', false)
}
</script>
<style scoped lang="scss">
.nav-bar {
display: flex;
height: 50px;
width: 100%;
background-color: v-bind('config.getColorVal("headerBarBackground")');
:deep(.nav-tabs) {
display: flex;
height: 100%;
position: relative;
.ba-nav-tab {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
cursor: pointer;
z-index: 1;
height: 100%;
user-select: none;
color: v-bind('config.getColorVal("headerBarTabColor")');
transition: all 0.2s;
-webkit-transition: all 0.2s;
.close-icon {
padding: 2px;
margin: 2px 0 0 4px;
}
.close-icon:hover {
background: var(--ba-color-primary-light);
color: var(--el-border-color) !important;
border-radius: 50%;
}
&.active {
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
}
&:hover {
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
}
}
.nav-tabs-active-box {
position: absolute;
height: 50px;
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
}
}
.unfold {
align-self: center;
padding-left: var(--ba-main-space);
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="nav-bar" :class="config.layout.shrink ? 'shrink' : ''">
<!-- 小屏设备下的展开菜单按钮 -->
<div v-if="config.layout.shrink && config.layout.menuCollapse" class="unfold">
<Icon @click="onMenuCollapse" name="fa fa-indent" :color="config.getColorVal('menuActiveColor')" size="18" />
</div>
<NavTabs v-if="!config.layout.shrink" ref="layoutNavTabsRef" />
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
import NavTabs from '/@/layouts/backend/components/navBar/tabs.vue'
import NavMenus from '../navMenus.vue'
import { layoutNavTabsRef } from '/@/stores/refs'
import { showShade } from '/@/utils/pageShade'
const config = useConfig()
const onMenuCollapse = () => {
showShade('ba-aside-menu-shade', () => {
config.setLayout('menuCollapse', true)
})
config.setLayout('menuCollapse', false)
}
</script>
<style lang="scss" scoped>
.nav-bar {
display: flex;
height: 50px;
margin: 20px var(--ba-main-space) 0 var(--ba-main-space);
:deep(.nav-tabs) {
display: flex;
height: 100%;
position: relative;
.ba-nav-tab {
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
cursor: pointer;
z-index: 1;
user-select: none;
opacity: 0.7;
color: v-bind('config.getColorVal("headerBarTabColor")');
.close-icon {
padding: 2px;
margin: 2px 0 0 4px;
}
.close-icon:hover {
background: var(--ba-color-primary-light);
color: var(--el-border-color) !important;
border-radius: 50%;
}
&.active {
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
}
&:hover {
opacity: 1;
}
}
.nav-tabs-active-box {
position: absolute;
height: 40px;
border-radius: var(--el-border-radius-base);
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
box-shadow: var(--el-box-shadow-light);
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
}
}
.nav-bar.shrink {
width: 100%;
background-color: v-bind('config.getColorVal("headerBarBackground")');
margin: 0;
.unfold {
align-self: center;
padding-left: var(--ba-main-space);
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="layouts-menu-horizontal-double">
<el-scrollbar ref="layoutMenuScrollbarRef" class="double-menus-scrollbar">
<el-menu ref="layoutMenuRef" class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive">
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="navTabs.state.tabsViewRoutes" />
</el-menu>
</el-scrollbar>
<NavMenus />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { onBeforeRouteUpdate, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import MenuTree from '/@/layouts/backend/components/menus/menuTree.vue'
import NavMenus from '/@/layouts/backend/components/navMenus.vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMenuRef, layoutMenuScrollbarRef } from '/@/stores/refs'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getMenuKey } from '/@/utils/router'
const config = useConfig()
const navTabs = useNavTabs()
const route = useRoute()
const state = reactive({
defaultActive: '',
})
/**
* 激活当前路由的菜单
*/
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
// 以路由 fullPath 匹配的菜单优先,且 fullPath 无匹配时,回退到 path 的匹配菜单
const tabView = navTabs.getTabsViewDataByRoute(currentRoute)
if (tabView) {
state.defaultActive = getMenuKey(tabView, tabView.meta!.matched as string)
}
}
// 滚动条滚动到激活菜单所在位置
const verticalMenusScroll = () => {
setTimeout(() => {
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
if (activeMenu) {
layoutMenuScrollbarRef.value?.setScrollLeft(activeMenu.offsetLeft)
}
}, 500)
}
onMounted(() => {
currentRouteActive(route)
verticalMenusScroll()
new horizontalScroll(layoutMenuScrollbarRef.value!.wrapRef!)
})
onBeforeRouteUpdate((to) => {
currentRouteActive(to)
})
</script>
<style scoped lang="scss">
.layouts-menu-horizontal-double {
display: flex;
align-items: center;
height: var(--el-header-height);
background-color: var(--ba-bg-color-overlay);
border-bottom: 1px solid var(--el-color-info-light-8);
}
.double-menus-scrollbar {
width: 70vw;
height: var(--el-header-height);
}
.menu-horizontal {
border: none;
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 5px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu-item.is-active {
background-color: v-bind('config.getColorVal("menuActiveBackground")');
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<div class="nav-tabs" ref="tabScrollbarRef">
<div
v-for="(item, idx) in navTabs.state.tabsView"
@click="onTab(item)"
@contextmenu.prevent="onContextmenu(item, $event)"
class="ba-nav-tab"
:class="navTabs.state.activeIndex == idx ? 'active' : ''"
:ref="tabsRefs.set"
:key="idx"
>
{{ item.meta.title }}
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in">
<Icon v-show="navTabs.state.tabsView.length > 1" class="close-icon" @click.stop="closeTab(item)" size="15" name="el-icon-Close" />
</transition>
</div>
<div :style="activeBoxStyle" class="nav-tabs-active-box"></div>
<Contextmenu ref="contextmenuRef" :items="state.contextmenuItems" @menuClick="onContextMenuClick" />
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useRoute, useRouter, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { useTemplateRefsList } from '@vueuse/core'
import type { ContextMenuItem, ContextMenuItemClickEmitArg } from '/@/components/contextmenu/interface'
import useCurrentInstance from '/@/utils/useCurrentInstance'
import Contextmenu from '/@/components/contextmenu/index.vue'
import horizontalScroll from '/@/utils/horizontalScroll'
import { getFirstRoute, routePush } from '/@/utils/router'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
const route = useRoute()
const router = useRouter()
const config = useConfig()
const navTabs = useNavTabs()
const { proxy } = useCurrentInstance()
const tabsRefs = useTemplateRefsList<HTMLDivElement>()
const contextmenuRef = useTemplateRef('contextmenuRef')
const tabScrollbarRef = useTemplateRef('tabScrollbarRef')
const state: {
contextmenuItems: ContextMenuItem[]
} = reactive({
contextmenuItems: [
{ name: 'refresh', label: '重新加载', icon: 'fa fa-refresh' },
{ name: 'close', label: '关闭标签', icon: 'fa fa-times' },
{ name: 'fullScreen', label: '当前标签全屏', icon: 'el-icon-FullScreen' },
{ name: 'closeOther', label: '关闭其他标签', icon: 'fa fa-minus' },
{ name: 'closeAll', label: '关闭全部标签', icon: 'fa fa-stop' },
],
})
const activeBoxStyle = reactive({
width: '0',
transform: 'translateX(0px)',
})
const onTab = (menu: RouteLocationNormalized) => {
router.push(menu.fullPath)
}
// tab 激活状态切换
const selectNavTab = function (dom: HTMLDivElement) {
if (!dom) {
return false
}
activeBoxStyle.width = dom.clientWidth + 'px'
activeBoxStyle.transform = `translateX(${dom.offsetLeft}px)`
if (tabScrollbarRef.value) {
let scrollLeft = dom.offsetLeft + dom.clientWidth - tabScrollbarRef.value.clientWidth
if (dom.offsetLeft < tabScrollbarRef.value.scrollLeft) {
tabScrollbarRef.value.scrollTo(dom.offsetLeft, 0)
} else if (scrollLeft > tabScrollbarRef.value.scrollLeft) {
tabScrollbarRef.value.scrollTo(scrollLeft, 0)
}
}
}
const toLastTab = () => {
const lastTab = navTabs.state.tabsView.slice(-1)[0]
if (lastTab) {
router.push(lastTab.fullPath)
} else {
router.push(adminBaseRoutePath)
}
}
const closeTab = (route: RouteLocationNormalized) => {
navTabs._closeTab(route)
proxy.eventBus.emit('onTabViewClose', route)
if (navTabs.state.activeRoute?.fullPath === route.fullPath) {
toLastTab()
} else {
navTabs._setActiveRoute(navTabs.state.activeRoute!)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
contextmenuRef.value?.onHideContextmenu()
}
const closeOtherTab = (menu: RouteLocationNormalized) => {
navTabs._closeTabs(menu)
navTabs._setActiveRoute(menu)
if (navTabs.state.activeRoute?.fullPath !== route.fullPath) {
router.push(menu!.fullPath)
}
}
/**
* 关闭所有tab等同于 navTabs.closeAllTab
* @param menu 需要保留的标签,否则关闭全部标签
*/
const closeAllTab = (menu?: RouteLocationNormalized) => {
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (menu && firstRoute && firstRoute.path == menu.fullPath) {
return closeOtherTab(menu)
}
if (firstRoute && firstRoute.path == navTabs.state.activeRoute?.fullPath) {
return closeOtherTab(navTabs.state.activeRoute)
}
navTabs._closeTabs(false)
if (firstRoute) routePush(firstRoute.path)
}
const onContextmenu = (menu: RouteLocationNormalized, el: MouseEvent) => {
// 禁用刷新
state.contextmenuItems[0].disabled = route.fullPath !== menu.fullPath
// 禁用关闭其他和关闭全部
state.contextmenuItems[4].disabled = state.contextmenuItems[3].disabled = navTabs.state.tabsView.length == 1 ? true : false
const { clientX, clientY } = el
contextmenuRef.value?.onShowContextmenu(menu, {
x: clientX,
y: clientY,
})
}
const onContextMenuClick = (item: ContextMenuItemClickEmitArg<RouteLocationNormalized>) => {
const { name, sourceData } = item
if (!sourceData) return
switch (name) {
case 'refresh':
proxy.eventBus.emit('onTabViewRefresh', sourceData)
break
case 'close':
closeTab(sourceData)
break
case 'closeOther':
closeOtherTab(sourceData)
break
case 'closeAll':
closeAllTab(sourceData)
break
case 'fullScreen':
if (route.fullPath !== sourceData.fullPath) {
router.push(sourceData.fullPath as string)
}
navTabs.setFullScreen(true)
break
}
}
const updateTab = function (newRoute: RouteLocationNormalized) {
// 添加tab
navTabs._addTab(newRoute)
// 激活当前tab
navTabs._setActiveRoute(newRoute)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
onBeforeRouteUpdate(async (to) => {
updateTab(to)
})
onMounted(() => {
updateTab(router.currentRoute.value)
if (tabScrollbarRef.value) {
new horizontalScroll(tabScrollbarRef.value)
}
})
/**
* 通过路由路径关闭tab等同于 navTabs.closeTabByPath
* @param fullPath 需要关闭的 tab 的路径
*/
const closeTabByPath = (fullPath: string) => {
for (const key in navTabs.state.tabsView) {
if (navTabs.state.tabsView[key].fullPath == fullPath) {
closeTab(navTabs.state.tabsView[key])
break
}
}
}
/**
* 修改 tab 标题(等同于 navTabs.updateTabTitle
* @param fullPath 需要修改标题的 tab 的路径
* @param title 新的标题
*/
const updateTabTitle = (fullPath: string, title: string) => {
navTabs._updateTabTitle(fullPath, title)
nextTick(() => {
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
})
}
defineExpose({
closeAllTab,
closeTabByPath,
updateTabTitle,
})
</script>
<style scoped lang="scss">
.dark {
.close-icon {
color: v-bind('config.getColorVal("headerBarTabColor")') !important;
}
.ba-nav-tab.active {
.close-icon {
color: v-bind('config.getColorVal("headerBarTabActiveColor")') !important;
}
}
}
.nav-tabs {
overflow-x: auto;
overflow-y: hidden;
margin-right: var(--ba-main-space);
scrollbar-width: none;
&::-webkit-scrollbar {
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #eaeaea;
border-radius: var(--el-border-radius-base);
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: v-bind('config.layout.layoutMode == "Default" ? "none":config.getColorVal("headerBarBackground")');
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #c8c9cc;
}
}
}
.ba-nav-tab {
white-space: nowrap;
height: 40px;
}
</style>

View File

@@ -0,0 +1,329 @@
<template>
<div class="nav-menus" :class="[configStore.layout.layoutMode, configStore.layout.shrink ? 'shrink' : '']">
<!-- 需要重启 Vite 热更新服务警告 -->
<el-popover
ref="reloadHotServerPopover"
@show="onCurrentNavMenu(true, 'reloadHotServer')"
@hide="onCurrentNavMenu(false, 'reloadHotServer')"
:width="360"
v-if="hotUpdateState.dirtyFile"
>
<div>
<div class="el-popover__title">{{ t('vite.Reload hot server title') }}</div>
<div class="reload-hot-server-content">
<p>
<span>{{ t('vite.Reload hot server tips 1') }}</span>
<span>{{ t(`vite.Close type ${hotUpdateState.closeType}`) }}</span>
<span>{{ t('vite.Reload hot server tips 2') }}</span>
</p>
<p>{{ t('vite.Reload hot server tips 3') }}</p>
<div class="reload-hot-server-buttons">
<el-button @click="onHotServerOpt('cancel')">{{ t('vite.Later') }}</el-button>
<el-button @click="onHotServerOpt('reload')" type="primary">{{ t('vite.Restart hot update') }}</el-button>
</div>
</div>
</div>
<template #reference>
<div class="nav-menu-item" :class="state.currentNavMenu == 'reloadHotServer' ? 'hover' : ''">
<Icon color="var(--el-color-danger)" class="nav-menu-icon" name="el-icon-Warning" size="18" />
</div>
</template>
</el-popover>
<!-- 站点主页 -->
<router-link class="h100" target="_blank" :title="t('Home')" to="/">
<div class="nav-menu-item">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="el-icon-Monitor" size="18" />
</div>
</router-link>
<!-- 语言切换 -->
<el-dropdown
@visible-change="onCurrentNavMenu($event, 'lang')"
class="h100"
size="large"
:hide-timeout="50"
placement="bottom"
trigger="click"
:hide-on-click="true"
>
<div class="nav-menu-item pt2" :class="state.currentNavMenu == 'lang' ? 'hover' : ''">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-lang" size="18" />
</div>
<template #dropdown>
<el-dropdown-menu class="dropdown-menu-box">
<el-dropdown-item v-for="item in configStore.lang.langArray" :key="item.name" @click="editDefaultLang(item.name)">
{{ item.value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 全屏切换 -->
<div @click="onFullScreen" class="nav-menu-item" :class="state.isFullScreen ? 'hover' : ''">
<Icon
:color="configStore.getColorVal('headerBarTabColor')"
class="nav-menu-icon"
v-if="state.isFullScreen"
name="local-full-screen-cancel"
size="18"
/>
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" v-else name="el-icon-FullScreen" size="18" />
</div>
<!-- 终端 - 仅超管 -->
<div v-if="adminInfo.super" @click="terminal.toggle()" class="nav-menu-item pt2">
<el-badge :is-dot="terminal.state.showDot">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-terminal" size="26" />
</el-badge>
</div>
<!-- 清理缓存 - 仅超管 -->
<el-dropdown
v-if="adminInfo.super"
@visible-change="onCurrentNavMenu($event, 'clear')"
class="h100"
size="large"
:hide-timeout="50"
placement="bottom"
trigger="click"
:hide-on-click="true"
>
<div class="nav-menu-item" :class="state.currentNavMenu == 'clear' ? 'hover' : ''">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="el-icon-Delete" size="18" />
</div>
<template #dropdown>
<el-dropdown-menu class="dropdown-menu-box">
<el-dropdown-item @click="onClearCache('tp')">{{ t('utils.Clean up system cache') }}</el-dropdown-item>
<el-dropdown-item @click="onClearCache('storage')">{{ t('utils.Clean up browser cache') }}</el-dropdown-item>
<el-dropdown-item @click="onClearCache('all')" divided>{{ t('utils.Clean up all cache') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 管理员信息 -->
<el-popover
v-if="siteConfig.userInitialize"
@show="onCurrentNavMenu(true, 'adminInfo')"
@hide="onCurrentNavMenu(false, 'adminInfo')"
placement="bottom-end"
:hide-after="0"
:width="260"
trigger="click"
popper-class="admin-info-box"
v-model:visible="state.showAdminInfoPopover"
>
<template #reference>
<div class="admin-info" :class="state.currentNavMenu == 'adminInfo' ? 'hover' : ''">
<el-avatar :size="25" :src="fullUrl(adminInfo.avatar)"></el-avatar>
<div class="admin-name">{{ adminInfo.nickname }}</div>
</div>
</template>
<div>
<div class="admin-info-base">
<el-avatar :size="70" :src="fullUrl(adminInfo.avatar)"></el-avatar>
<div class="admin-info-other">
<div class="admin-info-name">{{ adminInfo.nickname }}</div>
<div class="admin-info-lasttime">{{ timeFormat(adminInfo.last_login_time) }}</div>
</div>
</div>
<div class="admin-info-footer">
<el-button @click="onAdminInfo" type="primary" plain>{{ t('layouts.Profile') }}</el-button>
<el-button @click="onLogout" type="danger" plain>{{ t('layouts.Logout') }}</el-button>
</div>
</div>
</el-popover>
<!-- 配置 -->
<div @click="configStore.setLayout('showDrawer', true)" class="nav-menu-item">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="fa fa-cogs" size="18" />
</div>
<Config />
<Terminal />
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import screenfull from 'screenfull'
import { reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Config from './config.vue'
import { logout } from '/@/api/backend/index'
import { postClearCache } from '/@/api/common'
import Terminal from '/@/components/terminal/index.vue'
import { editDefaultLang } from '/@/lang'
import router from '/@/router'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import { ADMIN_INFO, BA_ACCOUNT } from '/@/stores/constant/cacheKey'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl, timeFormat } from '/@/utils/common'
import { routePush } from '/@/utils/router'
import { Local, Session } from '/@/utils/storage'
import { hotUpdateState, reloadServer } from '/@/utils/vite'
const { t } = useI18n()
const adminInfo = useAdminInfo()
const configStore = useConfig()
const terminal = useTerminal()
const siteConfig = useSiteConfig()
const reloadHotServerPopover = useTemplateRef('reloadHotServerPopover')
const state = reactive({
isFullScreen: false,
currentNavMenu: '',
showLayoutDrawer: false,
showAdminInfoPopover: false,
})
const onCurrentNavMenu = (status: boolean, name: string) => {
state.currentNavMenu = status ? name : ''
}
const onHotServerOpt = (opt: 'reload' | 'cancel') => {
if (opt == 'cancel') {
reloadHotServerPopover.value?.hide()
} else {
reloadServer('manual')
}
}
const onFullScreen = () => {
if (!screenfull.isEnabled) {
ElMessage.warning(t('layouts.Full screen is not supported'))
return false
}
screenfull.toggle()
screenfull.onchange(() => {
state.isFullScreen = screenfull.isFullscreen
})
}
const onAdminInfo = () => {
state.showAdminInfoPopover = false
routePush({ name: 'routine/adminInfo' })
}
const onLogout = () => {
logout().then(() => {
Local.remove(ADMIN_INFO)
router.go(0)
})
}
const onClearCache = (type: string) => {
if (type == 'storage' || type == 'all') {
const adminInfo = Local.get(ADMIN_INFO)
const baAccount = Local.get(BA_ACCOUNT)
Session.clear()
Local.clear()
Local.set(ADMIN_INFO, adminInfo)
Local.set(BA_ACCOUNT, baAccount)
if (type == 'storage') return
}
postClearCache(type).then(() => {})
}
</script>
<style scoped lang="scss">
.nav-menus.Default:not(.shrink) {
border-radius: var(--el-border-radius-base);
box-shadow: var(--el-box-shadow-light);
}
.reload-hot-server-content {
font-size: var(--el-font-size-small);
p {
margin-bottom: 6px;
}
.reload-hot-server-buttons {
display: flex;
justify-content: flex-end;
}
}
.nav-menus {
display: flex;
align-items: center;
height: 100%;
margin-left: auto;
background-color: v-bind('configStore.getColorVal("headerBarBackground")');
.nav-menu-item {
height: 100%;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.nav-menu-icon {
box-sizing: content-box;
color: v-bind('configStore.getColorVal("headerBarTabColor")');
}
&:hover {
.icon {
animation: twinkle 0.3s ease-in-out;
}
}
}
.admin-info {
display: flex;
height: 100%;
padding: 0 10px;
align-items: center;
cursor: pointer;
user-select: none;
color: v-bind('configStore.getColorVal("headerBarTabColor")');
}
.admin-name {
padding-left: 6px;
white-space: nowrap;
}
.nav-menu-item:hover,
.admin-info:hover,
.nav-menu-item.hover,
.admin-info.hover {
background: v-bind('configStore.getColorVal("headerBarHoverBackground")');
}
}
.dropdown-menu-box :deep(.el-dropdown-menu__item) {
justify-content: center;
}
.admin-info-base {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding-top: 10px;
.admin-info-other {
display: block;
width: 100%;
text-align: center;
padding: 10px 0;
.admin-info-name {
font-size: var(--el-font-size-large);
}
}
}
.admin-info-footer {
padding: 10px 0;
margin: 0 -12px -12px -12px;
display: flex;
justify-content: space-around;
}
.pt2 {
padding-top: 2px;
}
@keyframes twinkle {
0% {
transform: scale(0);
}
80% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<el-container class="layout-container">
<Aside />
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Aside from '/@/layouts/backend/components/aside.vue'
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<el-container class="layout-container">
<el-container class="content-wrapper">
<Header />
<Main />
</el-container>
</el-container>
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
</template>
<script setup lang="ts">
import Header from '/@/layouts/backend/components/header.vue'
import Main from '/@/layouts/backend/router-view/main.vue'
import CloseFullScreen from '/@/layouts/backend/components/closeFullScreen.vue'
import { useNavTabs } from '/@/stores/navTabs'
const navTabs = useNavTabs()
</script>
<style scoped>
.layout-container {
height: 100%;
width: 100%;
}
.content-wrapper {
flex-direction: column;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<component :is="config.layout.layoutMode"></component>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { useTerminal } from '/@/stores/terminal'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useRoute } from 'vue-router'
import Default from '/@/layouts/backend/container/default.vue'
import Classic from '/@/layouts/backend/container/classic.vue'
import Streamline from '/@/layouts/backend/container/streamline.vue'
import Double from '/@/layouts/backend/container/double.vue'
import { onMounted, onBeforeMount } from 'vue'
import { Session } from '/@/utils/storage'
import { index } from '/@/api/backend'
import { handleAdminRoute, getFirstRoute, routePush } from '/@/utils/router'
import router from '/@/router/index'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { useEventListener } from '@vueuse/core'
import { BEFORE_RESIZE_LAYOUT } from '/@/stores/constant/cacheKey'
import { isEmpty } from 'lodash-es'
import { setNavTabsWidth } from '/@/utils/layout'
defineOptions({
components: { Default, Classic, Streamline, Double },
})
const terminal = useTerminal()
const navTabs = useNavTabs()
const config = useConfig()
const route = useRoute()
const siteConfig = useSiteConfig()
const adminInfo = useAdminInfo()
const state = reactive({
autoMenuCollapseLock: false,
})
onMounted(() => {
if (!adminInfo.token) return router.push({ name: 'adminLogin' })
init()
setNavTabsWidth()
useEventListener(window, 'resize', setNavTabsWidth)
})
onBeforeMount(() => {
onAdaptiveLayout()
useEventListener(window, 'resize', onAdaptiveLayout)
})
const init = () => {
/**
* 后台初始化请求,获取站点配置,动态路由等信息
*/
index().then((res) => {
siteConfig.dataFill(res.data.siteConfig)
terminal.changePackageManager(res.data.terminal.npmPackageManager)
terminal.changePHPDevelopmentServer(res.data.terminal.phpDevelopmentServer)
siteConfig.setInitialize(true)
if (!isEmpty(res.data.adminInfo)) {
adminInfo.dataFill(res.data.adminInfo)
siteConfig.setUserInitialize(true)
}
if (res.data.menus) {
handleAdminRoute(res.data.menus)
// 预跳转到上次路径
if (route.params.to) {
const lastRoute = JSON.parse(route.params.to as string)
if (lastRoute.path != adminBaseRoutePath) {
let query = !isEmpty(lastRoute.query) ? lastRoute.query : {}
routePush({ path: lastRoute.path, query: query })
return
}
}
// 跳转到第一个菜单
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (firstRoute) routePush(firstRoute.path)
}
})
}
const onAdaptiveLayout = () => {
let defaultBeforeResizeLayout = {
menuCollapse: config.layout.menuCollapse,
}
let beforeResizeLayout = Session.get(BEFORE_RESIZE_LAYOUT)
if (!beforeResizeLayout) Session.set(BEFORE_RESIZE_LAYOUT, defaultBeforeResizeLayout)
const clientWidth = document.body.clientWidth
if (clientWidth < 1024) {
/**
* 锁定窗口改变自动调整 menuCollapse
* 避免已是小窗且打开了菜单栏时,意外的自动关闭菜单栏
*/
if (!state.autoMenuCollapseLock) {
state.autoMenuCollapseLock = true
config.setLayout('menuCollapse', true)
}
config.setLayout('shrink', true)
} else {
state.autoMenuCollapseLock = false
let beforeResizeLayoutTemp = beforeResizeLayout || defaultBeforeResizeLayout
config.setLayout('menuCollapse', beforeResizeLayoutTemp.menuCollapse)
config.setLayout('shrink', false)
}
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<el-main class="layout-main">
<el-scrollbar class="layout-main-scrollbar" :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<router-view v-slot="{ Component }">
<transition :name="config.layout.mainAnimation" mode="out-in">
<keep-alive :include="state.keepAliveComponentNameList">
<component :is="Component" :key="state.componentKey" />
</keep-alive>
</transition>
</router-view>
</el-scrollbar>
</el-main>
</template>
<script setup lang="ts">
import { reactive, onMounted, watch, onBeforeMount, onUnmounted, nextTick } from 'vue'
import { useRoute, type RouteLocationNormalized } from 'vue-router'
import useCurrentInstance from '/@/utils/useCurrentInstance'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
defineOptions({
name: 'layout/main',
})
const { proxy } = useCurrentInstance()
const route = useRoute()
const config = useConfig()
const navTabs = useNavTabs()
const state: {
componentKey: string
keepAliveComponentNameList: string[]
} = reactive({
componentKey: route.fullPath,
keepAliveComponentNameList: [],
})
const addKeepAliveComponentName = function (keepAliveName: string | undefined) {
if (keepAliveName) {
let exist = state.keepAliveComponentNameList.find((name: string) => {
return name === keepAliveName
})
if (exist) return
state.keepAliveComponentNameList.push(keepAliveName)
}
}
const addActiveRouteKeepAlive = () => {
if (navTabs.state.activeRoute) {
const tabView = navTabs.getTabsViewDataByRoute(navTabs.state.activeRoute)
if (tabView && typeof tabView.meta?.keepalive == 'string') {
addKeepAliveComponentName(tabView.meta.keepalive)
}
}
}
onBeforeMount(() => {
proxy.eventBus.on('onTabViewRefresh', (menu: RouteLocationNormalized) => {
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
state.componentKey = ''
nextTick(() => {
state.componentKey = menu.fullPath
addKeepAliveComponentName(menu.meta.keepalive as string)
})
})
proxy.eventBus.on('onTabViewClose', (menu: RouteLocationNormalized) => {
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
})
})
onUnmounted(() => {
proxy.eventBus.off('onTabViewRefresh')
proxy.eventBus.off('onTabViewClose')
})
onMounted(() => {
// 确保刷新页面时也能正确取得当前路由 keepalive 参数(热更新)
addActiveRouteKeepAlive()
})
watch(
() => route.fullPath,
() => {
state.componentKey = route.fullPath
addActiveRouteKeepAlive()
}
)
</script>
<style scoped lang="scss">
.layout-container .layout-main {
padding: 0 !important;
overflow: hidden;
width: 100%;
height: 100%;
}
.layout-main-scrollbar {
width: 100%;
position: relative;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="theme-toggle-content">
<div class="switch">
<div class="switch-action">
<Icon name="local-dark" color="#f2f2f2" size="13px" class="switch-icon dark-icon" />
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.theme-toggle-content {
display: flex;
align-items: center;
height: 24px;
padding: 0 12px;
}
.switch {
display: inline-block;
position: relative;
width: 40px;
height: 20px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
box-sizing: border-box;
background-color: var(--ba-bg-color);
cursor: pointer;
transition:
border-color 0.3s,
background-color 0.5s;
}
.switch-action {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 1px;
left: 1px;
border-radius: 50%;
background-color: #ffffff;
transform: translate(0);
color: var(--el-text-color-primary);
transition: all 0.3s;
}
.switch-icon {
position: absolute;
left: 1px;
bottom: 1px;
transition: all 0.3s;
cursor: pointer;
}
.light-icon {
opacity: 1;
}
.dark-icon {
opacity: 0;
}
@at-root .dark {
.switch {
background-color: #2c2c2c;
}
.switch-action {
transform: translate(20px);
background-color: #141414;
}
.dark-icon {
opacity: 1;
}
.light-icon {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div>
<div
v-loading="true"
element-loading-background="var(--ba-bg-color-overlay)"
:element-loading-text="$t('utils.Loading')"
class="default-main ba-main-loading"
></div>
<div v-if="state.showReload" class="loading-footer">
<el-button @click="refresh" type="warning">{{ $t('utils.Reload') }}</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, reactive } from 'vue'
import router from '/@/router/index'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useNavTabs } from '/@/stores/navTabs'
import { isAdminApp } from '/@/utils/common'
import { getFirstRoute, routePush } from '/@/utils/router'
let timer: number
const navTabs = useNavTabs()
const memberCenter = useMemberCenter()
const state = reactive({
maximumWait: 1000 * 6,
showReload: false,
})
const refresh = () => {
router.go(0)
}
if (isAdminApp() && navTabs.state.tabsViewRoutes) {
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
if (firstRoute) routePush(firstRoute.path)
} else if (memberCenter.state.viewRoutes) {
let firstRoute = getFirstRoute(memberCenter.state.viewRoutes)
if (firstRoute) routePush(firstRoute.path)
}
timer = window.setTimeout(() => {
state.showReload = true
}, state.maximumWait)
onUnmounted(() => {
clearTimeout(timer)
})
</script>
<style scoped lang="scss">
.ba-main-loading {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.loading-footer {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="iframe-main" v-loading="state.loading">
<iframe :src="state.iframeSrc" :style="iframeStyle(35)" height="100%" width="100%" id="iframe" @load="hideLoading"></iframe>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { mainHeight as iframeStyle } from '/@/utils/layout'
const router = useRouter()
const state = reactive({
loading: true,
iframeSrc: router.currentRoute.value.meta.url as string,
})
const hideLoading = () => {
state.loading = false
}
</script>
<style scoped lang="scss">
.iframe-main {
margin: var(--ba-main-space);
iframe {
border: none;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<el-aside class="ba-user-layouts">
<div class="userinfo">
<div @click="routerPush('account/profile')" class="user-avatar-box">
<img class="user-avatar" :src="fullUrl(userInfo.avatar ? userInfo.avatar : '/static/images/avatar.png')" alt="" />
<Icon class="user-avatar-gender" :name="userInfo.getGenderIcon()['name']" size="14" :color="userInfo.getGenderIcon()['color']" />
</div>
<p class="username">{{ userInfo.nickname }}</p>
<el-button-group>
<el-button
@click="routerPush('account/integral')"
v-blur
class="userinfo-button-item"
:title="$t('Integral') + ' ' + userInfo.score"
size="default"
plain
>
<span>{{ $t('Integral') + ' ' + userInfo.score }}</span>
</el-button>
<el-button
@click="routerPush('account/balance')"
v-blur
class="userinfo-button-item"
:title="$t('Balance') + ' ' + userInfo.money"
size="default"
plain
>
<span>{{ $t('Balance') + ' ' + userInfo.money }}</span>
</el-button>
</el-button-group>
</div>
<div class="user-menus">
<template v-for="(item, idx) in memberCenter.state.viewRoutes" :key="idx">
<div v-if="memberCenter.state.showHeadline" class="user-menu-max-title">{{ item.meta?.title }}</div>
<div
v-for="(menu, index) in item.children"
:key="index"
@click="routerPush(menu)"
class="user-menu-item"
:class="route.fullPath == menu.path ? 'active' : ''"
>
<Icon :name="menu.meta?.icon" size="16" color="var(--el-text-color-secondary)" />
<span>{{ menu.meta?.title }}</span>
</div>
</template>
</div>
</el-aside>
</template>
<script setup lang="ts">
import { useRoute, useRouter, type RouteRecordRaw } from 'vue-router'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useUserInfo } from '/@/stores/userInfo'
import { fullUrl } from '/@/utils/common'
import { onClickMenu } from '/@/utils/router'
const route = useRoute()
const router = useRouter()
const userInfo = useUserInfo()
const memberCenter = useMemberCenter()
const routerPush = (route: string | RouteRecordRaw) => {
if (typeof route === 'string') {
router.push({ name: route })
} else {
onClickMenu(route)
}
}
</script>
<style scoped lang="scss">
.ba-user-layouts {
width: 240px;
background-color: var(--ba-bg-color-overlay);
box-shadow: var(--el-box-shadow-light);
}
.userinfo {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.username {
display: block;
text-align: center;
width: 100%;
padding: 10px 0;
font-size: var(--el-font-size-large);
font-weight: bold;
}
.user-avatar-box {
position: relative;
width: 100px;
height: 100px;
cursor: pointer;
}
.user-avatar {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.user-avatar-gender {
position: absolute;
bottom: 0;
right: 10px;
height: 22px;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--ba-bg-color-overlay);
border-radius: 50%;
box-shadow: var(--el-box-shadow);
}
.userinfo-button-item {
font-size: var(--el-font-size-small);
height: 30px;
}
.user-menus {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
padding-bottom: 20px;
}
.user-menu-max-title {
font-size: 15px;
color: var(--el-text-color-secondary);
padding: 5px 30px;
}
.user-menu-item {
padding: 10px 30px;
cursor: pointer;
.icon {
width: 16px;
height: 16px;
text-align: center;
margin-right: 8px;
}
}
.user-menu-item:hover,
.user-menu-item.active {
border-left: 2px solid var(--el-color-primary);
padding-left: 28px;
color: var(--el-color-primary);
.icon {
color: var(--el-color-primary) !important;
}
background-color: var(--el-color-info-light-8);
}
@media screen and (max-width: 991px) {
.ba-user-layouts {
width: 100%;
background-color: var(--ba-bg-color-overlay);
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<el-footer class="footer">
<div>
Copyright @ 2020~{{ new Date().getFullYear() }} {{ siteConfig.siteName }} {{ $t('Copyright') }}
<a href="http://beian.miit.gov.cn/">{{ siteConfig.recordNumber }}</a>
</div>
</el-footer>
</template>
<script setup lang="ts">
import { useSiteConfig } from '/@/stores/siteConfig'
const siteConfig = useSiteConfig()
</script>
<style scoped lang="scss">
.footer {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
background-color: var(--el-color-info-light-7);
a {
color: var(--el-text-color-secondary);
}
@media screen and (max-width: 768px) {
a {
display: block;
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<el-header class="header">
<el-row justify="center">
<el-col class="header-row" :xs="24" :sm="24" :md="16">
<div @click="router.push({ name: '/' })" class="header-logo">
<img src="~assets/logo.png" />
<span class="site-name">{{ siteConfig.siteName }}</span>
</div>
<div v-if="!memberCenter.state.menuExpand" @click="memberCenter.toggleMenuExpand(true)" class="user-menus-expand hidden-md-and-up">
<Icon name="fa fa-indent" color="var(--el-color-primary)" size="20" />
</div>
<el-scrollbar ref="layoutMenuScrollbarRef" class="hidden-sm-and-down">
<Menu class="frontend-header-menu" :ellipsis="false" mode="horizontal" />
</el-scrollbar>
</el-col>
</el-row>
<el-drawer
class="ba-aside-drawer"
:append-to-body="true"
v-model="memberCenter.state.menuExpand"
:with-header="false"
direction="ltr"
:size="memberCenter.state.shrink ? '70%' : '40%'"
>
<div class="header-row">
<div @click="router.push({ name: '/' })" class="header-logo">
<img src="~assets/logo.png" />
<span class="site-name">{{ siteConfig.siteName }}</span>
</div>
<div @click="memberCenter.toggleMenuExpand(false)" class="user-menus-expand hidden-md-and-up">
<Icon name="fa fa-dedent" color="var(--el-color-primary)" size="20" />
</div>
</div>
<Menu :show-icon="true" mode="vertical" />
</el-drawer>
</el-header>
</template>
<script setup lang="ts">
import { onBeforeRouteUpdate, useRouter } from 'vue-router'
import { initialize } from '/@/api/frontend/index'
import Menu from '/@/layouts/frontend/components/menu.vue'
import { useMemberCenter } from '/@/stores/memberCenter'
import { layoutMenuScrollbarRef } from '/@/stores/refs'
import { useSiteConfig } from '/@/stores/siteConfig'
const router = useRouter()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
onBeforeRouteUpdate(() => {
memberCenter.toggleMenuExpand(false)
})
/**
* 前端初始化请求,获取站点配置信息,动态路由信息等
*/
initialize()
</script>
<style scoped lang="scss">
.header {
background-color: var(--ba-bg-color-overlay);
box-shadow: 0 0 8px rgba(0 0 0 / 8%);
.frontend-header-menu {
height: var(--el-header-height);
}
}
.header-row {
display: flex;
justify-content: space-between;
.header-logo {
display: flex;
height: var(--el-header-height);
align-items: center;
padding-right: 15px;
cursor: pointer;
img {
height: 34px;
width: 34px;
}
.site-name {
padding-left: 4px;
font-size: var(--el-font-size-large);
white-space: nowrap;
}
}
.user-menus-expand {
display: flex;
height: var(--el-header-height);
align-items: center;
justify-content: center;
}
}
.ba-aside-drawer {
.header-row {
padding: 10px 20px;
background-color: var(--el-color-info-light-9);
.header-logo {
img {
height: 28px;
width: 28px;
}
}
}
}
@at-root html.dark {
.header-logo .site-name {
color: var(--el-text-color-primary);
}
}
@media screen and (max-width: 768px) {
.user-menus-expand {
padding: 0;
}
}
@media screen and (max-width: 414px) {
.frontend-header-menu :deep(.el-sub-menu .el-sub-menu__title) {
padding: 0 20px;
.el-icon {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<transition :name="config.layout.mainAnimation" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</template>
<script setup lang="ts">
import { useConfig } from '/@/stores/config'
const config = useConfig()
</script>
<style scoped lang="scss">
.layout-main {
padding: 0 !important;
overflow-x: hidden;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<el-menu ref="layoutMenuRef" :default-active="state.activeMenu" @select="onSelect">
<el-menu-item @click="router.push({ name: '/' })" v-blur index="index">
<Icon v-if="props.showIcon" name="fa fa-home" color="var(--el-text-color-primary)" />
<template #title>{{ $t('Home') }}</template>
</el-menu-item>
<!-- 动态菜单 -->
<MenuSub :menus="siteConfig.headNav" :show-icon="showIcon" />
<template v-if="memberCenter.state.open">
<el-sub-menu v-if="userInfo.isLogin()" @click="$attrs.mode == 'vertical' ? '' : router.push({ name: 'user' })" v-blur index="user-box">
<template #title>
<div class="header-user-box">
<img
class="header-user-avatar"
:class="$attrs.mode == 'vertical' ? 'icon-header-user-avatar' : ''"
:src="fullUrl(userInfo.avatar ? userInfo.avatar : '/static/images/avatar.png')"
alt=""
/>
{{ userInfo.nickname }}
</div>
</template>
<el-menu-item @click="router.push({ name: 'user' })" v-blur index="user">
<Icon v-if="showIcon" name="fa fa-user-circle" color="var(--el-text-color-primary)" />
{{ $t('Member Center') }}
</el-menu-item>
<!-- 动态菜单 -->
<MenuSub :menus="memberCenter.state.navUserMenus" :show-icon="showIcon" />
<!-- 会员中心菜单 -->
<MenuSub :menus="memberCenter.state.viewRoutes" :show-icon="showIcon" />
<el-menu-item @click="userInfo.logout()" v-blur index="user-logout">
<Icon v-if="showIcon" name="fa fa-sign-out" color="var(--el-text-color-primary)" />
{{ $t('Logout login') }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else @click="router.push({ name: 'user' })" v-blur index="user">
<Icon v-if="showIcon" name="fa fa-user-circle" color="var(--el-text-color-primary)" />
{{ $t('Member Center') }}
</el-menu-item>
<el-sub-menu v-blur index="language-switch" class="language-switch">
<template #title>
<Icon v-if="showIcon" name="local-lang" color="var(--el-text-color-primary)" />
{{ $t('Language') }}
</template>
<el-menu-item
@click="editDefaultLang(item.name)"
v-for="item in config.lang.langArray"
:key="item.name"
:index="'language-switch-' + item.value"
class="language-switch"
>
<Icon v-if="showIcon" name="fa fa-circle-o" color="var(--el-text-color-primary)" />
{{ item.value }}
</el-menu-item>
</el-sub-menu>
<el-menu-item index="theme-switch" class="theme-switch" :class="$attrs.mode + '-theme-switch'">
<DarkSwitch @click="toggleDark()" />
</el-menu-item>
</template>
</el-menu>
</template>
<script setup lang="ts">
import { nextTick, reactive } from 'vue'
import { editDefaultLang } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useUserInfo } from '/@/stores/userInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { fullUrl } from '/@/utils/common'
import MenuSub from '/@/layouts/frontend/components/menuSub.vue'
import toggleDark from '/@/utils/useDark'
import DarkSwitch from '/@/layouts/common/components/darkSwitch.vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { layoutMenuRef } from '/@/stores/refs'
const route = useRoute()
const router = useRouter()
const config = useConfig()
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
interface Props {
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showIcon: false,
})
const state = reactive({
activeMenu: '',
})
/**
* 设置激活菜单
*/
const setActiveMenu = (route: RouteLocationNormalizedLoaded) => {
if (route.path == '/') return (state.activeMenu = 'index')
const menuId = findMenus(route)
if (menuId) {
state.activeMenu = 'column-' + menuId
} else if (route.path.startsWith('/user')) {
state.activeMenu = 'user'
}
}
/**
* 菜单被点击时额外对无需激活的菜单处理(外链、暗黑模式开关、语言切换等)
* 检查菜单是否需要激活,如果否,还原 state.activeMenu
*/
const onSelect = (index: string) => {
if (
noNeedActive(siteConfig.headNav, index) ||
noNeedActive(memberCenter.state.navUserMenus, index) ||
noNeedActive(memberCenter.state.viewRoutes, index)
) {
const oldActiveMenu = state.activeMenu
state.activeMenu = ''
nextTick(() => {
state.activeMenu = oldActiveMenu
})
}
}
/**
* 检查一个菜单是否需要激活态
* @param menus
* @param index
*/
const noNeedActive = (menus: RouteRecordRaw[], index: string) => {
if (index.indexOf('language-switch') === 0 || index == 'theme-switch') {
return true
}
return isExternalLink(menus, index)
}
/**
* 检查一个菜单是否是外站链接,如果是,不要激活
* @param menus
* @param index
*/
const isExternalLink = (menus: RouteRecordRaw[], index: string): boolean => {
for (const key in menus) {
const columnIndex = `column-${menus[key].meta?.id}`
if (columnIndex == index) {
return menus[key].meta?.menu_type == 'link'
}
if (menus[key].children?.length) {
return isExternalLink(menus[key].children!, index)
}
}
return false
}
/**
* 递归的搜索菜单 Index
*/
const searchMenuIndex = (menus: RouteRecordRaw[], route: RouteLocationNormalizedLoaded): number | false => {
let find: boolean | number = false
for (const key in menus) {
if (menus[key].meta?.id && menus[key].path == route.fullPath) {
return menus[key].meta.id as number
}
if (menus[key].children && menus[key].children?.length) {
find = searchMenuIndex(menus[key].children, route)
if (find !== false) return find
}
}
return find
}
/**
* 从动态菜单(顶栏、会员中心下拉、会员中心菜单)中搜索一个菜单
*/
const findMenus = (route: RouteLocationNormalizedLoaded) => {
// 顶栏菜单
const headNavIndex = searchMenuIndex(siteConfig.headNav, route)
if (headNavIndex !== false) return headNavIndex
// 会员中心下拉菜单
const navUserMenuIndex = searchMenuIndex(memberCenter.state.navUserMenus, route)
if (navUserMenuIndex !== false) return navUserMenuIndex
// 会员中心菜单
return searchMenuIndex(memberCenter.state.viewRoutes, route)
}
setActiveMenu(route)
onBeforeRouteUpdate((to) => {
setActiveMenu(to)
})
</script>
<style scoped lang="scss">
.header-user-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.header-user-avatar {
width: 16px;
height: 16px;
margin-right: 4px;
border-radius: 50%;
}
.icon-header-user-avatar {
margin-left: 4px;
margin-right: 6px;
}
}
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 2px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
.el-menu {
border-bottom: none;
border-right: none;
.theme-switch.is-active,
.language-switch.is-active {
border-bottom: none;
:deep(.el-sub-menu__title) {
border-bottom: none;
}
}
}
.theme-switch {
--el-menu-hover-bg-color: none;
padding-right: 0;
}
.vertical-theme-switch {
.theme-toggle-content {
padding: 0;
}
}
.theme-toggle-content {
padding-right: 0;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<template v-for="(item, idx) in props.menus" :key="idx">
<template v-if="!isEmpty(item.children)">
<el-sub-menu @click="onClickSubMenu(item)" v-blur :index="`column-${item.meta?.id}`">
<template #title>
<Icon v-if="showIcon" :name="item.meta?.icon" color="var(--el-text-color-primary)" />
{{ item.meta?.title }}
</template>
<MenuSub :menus="item.children!" :show-icon="showIcon" />
</el-sub-menu>
</template>
<template v-else>
<el-menu-item @click="onClickMenu(item)" v-blur :index="'column-' + item.meta?.id" :class="(item.name as string).replace(/[\/]/g, '-')">
<Icon v-if="showIcon" :name="item.meta?.icon" color="var(--el-text-color-primary)" />
<template #title>{{ item.meta?.title }}</template>
</el-menu-item>
</template>
</template>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import { onClickMenu } from '/@/utils/router'
import type { RouteRecordRaw } from 'vue-router'
import MenuSub from '/@/layouts/frontend/components/menuSub.vue'
interface Props {
menus: RouteRecordRaw[]
showIcon?: boolean
}
const props = withDefaults(defineProps<Props>(), {
menus: () => [],
showIcon: false,
})
const onClickSubMenu = (menu: RouteRecordRaw) => {
/**
* 1、'/'表示菜单规则的 path 为空
* 2、会员中心菜单目录不需要跳转
*/
if (menu.path == '/' || menu.meta?.type == 'menu_dir') return
onClickMenu(menu)
}
</script>
<style scoped lang="scss">
.el-sub-menu .icon,
.el-menu-item .icon {
vertical-align: middle;
margin-right: 2px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.is-active > .icon {
color: var(--el-menu-active-color) !important;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<el-container class="is-vertical">
<Header />
<el-scrollbar :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<el-row class="frontend-footer-brother" justify="center">
<el-col class="user-layouts" :span="16" :xs="24">
<Aside class="hidden-sm-and-down" />
<Main />
</el-col>
</el-row>
<Footer />
</el-scrollbar>
</el-container>
</template>
<script setup lang="ts">
import Header from '/@/layouts/frontend/components/header.vue'
import Aside from '/@/layouts/frontend/components/aside.vue'
import Main from '/@/layouts/frontend/components/main.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
</script>
<style scoped lang="scss">
.user-layouts {
display: flex;
padding-top: 15px;
align-items: flex-start;
}
@media screen and (max-width: 768px) {
.user-layouts {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-container class="is-vertical">
<Header />
<el-scrollbar :style="layoutMainScrollbarStyle" ref="layoutMainScrollbarRef">
<el-row class="frontend-footer-brother" justify="center">
<el-col class="user-layouts" :span="16" :xs="24">
<el-alert :center="true" :title="$t('Member center disabled')" type="error" />
</el-col>
</el-row>
<Footer />
</el-scrollbar>
</el-container>
</template>
<script setup lang="ts">
import Header from '/@/layouts/frontend/components/header.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { layoutMainScrollbarRef, layoutMainScrollbarStyle } from '/@/stores/refs'
</script>
<style scoped lang="scss">
.user-layouts {
display: flex;
padding-top: 15px;
align-items: flex-start;
}
@media screen and (max-width: 768px) {
.user-layouts {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<component :is="memberCenter.state.layoutMode"></component>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserInfo } from '/@/stores/userInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { initialize } from '/@/api/frontend/index'
import { getFirstRoute, routePush } from '/@/utils/router'
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
import { useRoute, useRouter } from 'vue-router'
import Default from '/@/layouts/frontend/container/default.vue'
import Disable from '/@/layouts/frontend/container/disable.vue'
import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import userMounted from '/@/components/mixins/userMounted'
import { isEmpty } from 'lodash-es'
defineOptions({
components: { Default, Disable },
})
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
onMounted(async () => {
const ret = await userMounted()
if (ret.type == 'break') return
if (ret.type == 'reload') return (window.location.href = ret.url)
if (!userInfo.token) return router.push({ name: 'userLogin' })
/**
* 会员中心初始化请求,获取会员中心菜单信息等
*/
const callback = () => {
if (ret.type == 'jump') return router.push(ret.url)
// 预跳转到上次路径
if (route.params.to) {
const lastRoute = JSON.parse(route.params.to as string)
if (lastRoute.path != memberCenterBaseRoutePath) {
let query = !isEmpty(lastRoute.query) ? lastRoute.query : {}
routePush({ path: lastRoute.path, query: query })
return
}
}
// 跳转到第一个菜单
if (route.name == 'userMainLoading') {
let firstRoute = getFirstRoute(memberCenter.state.viewRoutes)
if (firstRoute) {
router.push({ path: firstRoute.path })
} else {
ElNotification({
type: 'error',
message: t('No route found to jump~'),
})
}
}
}
if (siteConfig.userInitialize) {
callback()
} else {
initialize(callback, true)
}
if (document.body.clientWidth < 1024) {
memberCenter.setShrink(true)
} else {
memberCenter.setShrink(false)
}
})
</script>
<style scoped lang="scss"></style>

36
web/src/main.ts Normal file
View File

@@ -0,0 +1,36 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { loadLang } from '/@/lang/index'
import { registerIcons } from '/@/utils/common'
import ElementPlus from 'element-plus'
import mitt from 'mitt'
import pinia from '/@/stores/index'
import { directives } from '/@/utils/directives'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/display.css'
import 'font-awesome/css/font-awesome.min.css'
import '/@/styles/index.scss'
// modules import mark, Please do not remove.
async function start() {
const app = createApp(App)
app.use(pinia)
// 全局语言包加载
await loadLang(app)
app.use(router)
app.use(ElementPlus)
// 全局注册
directives(app) // 指令
registerIcons(app) // icons
app.mount('#app')
// modules start mark, Please do not remove.
app.config.globalProperties.eventBus = mitt()
}
start()

81
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,81 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import staticRoutes from '/@/router/static'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { loading } from '/@/utils/loading'
import langAutoLoadMap from '/@/lang/autoload'
import { mergeMessage } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { isAdminApp } from '/@/utils/common'
import { uniq } from 'lodash-es'
const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes,
})
router.beforeEach((to, from, next) => {
NProgress.configure({ showSpinner: false })
NProgress.start()
if (!window.existLoading) {
loading.show()
window.existLoading = true
}
// 按需动态加载页面的语言包-start
let loadPath: string[] = []
const config = useConfig()
if (to.path in langAutoLoadMap) {
loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
}
let prefix = ''
if (isAdminApp(to.fullPath)) {
prefix = './backend/' + config.lang.defaultLang
// 去除 path 中的 /admin
const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
if (adminPath) loadPath.push(prefix + adminPath + '.ts')
} else {
prefix = './frontend/' + config.lang.defaultLang
loadPath.push(prefix + to.path + '.ts')
}
// 根据路由 name 加载的语言包
if (to.name) {
loadPath.push(prefix + '/' + to.name.toString() + '.ts')
}
if (!window.loadLangHandle.publicMessageLoaded) window.loadLangHandle.publicMessageLoaded = []
const publicMessagePath = prefix + '.ts'
if (!window.loadLangHandle.publicMessageLoaded.includes(publicMessagePath)) {
loadPath.push(publicMessagePath)
window.loadLangHandle.publicMessageLoaded.push(publicMessagePath)
}
// 去重
loadPath = uniq(loadPath)
for (const key in loadPath) {
loadPath[key] = loadPath[key].replaceAll('${lang}', config.lang.defaultLang)
if (loadPath[key] in window.loadLangHandle) {
window.loadLangHandle[loadPath[key]]().then((res: { default: anyObj }) => {
const pathName = loadPath[key].slice(loadPath[key].lastIndexOf(prefix) + (prefix.length + 1), loadPath[key].lastIndexOf('.'))
mergeMessage(res.default, pathName)
})
}
}
// 动态加载语言包-end
next()
})
// 路由加载后
router.afterEach(() => {
if (window.existLoading) {
loading.hide()
}
NProgress.done()
})
export default router

100
web/src/router/static.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { RouteRecordRaw } from 'vue-router'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
const pageTitle = (name: string): string => {
return `pagesTitle.${name}`
}
/*
* 静态路由
* 自动加载 ./static 目录的所有文件,并 push 到以下数组
*/
const staticRoutes: Array<RouteRecordRaw> = [
{
// 首页
path: '/',
name: '/',
component: () => import('/@/views/frontend/index.vue'),
meta: {
title: pageTitle('home'),
},
},
{
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
path: adminBaseRoutePath + '/login',
name: 'adminLogin',
component: () => import('/@/views/backend/login.vue'),
meta: {
title: pageTitle('adminLogin'),
},
},
{
// 会员登录页
path: memberCenterBaseRoutePath + '/login',
name: 'userLogin',
component: () => import('/@/views/frontend/user/login.vue'),
meta: {
title: pageTitle('userLogin'),
},
},
{
path: '/:path(.*)*',
redirect: '/404',
},
{
// 404
path: '/404',
name: 'notFound',
component: () => import('/@/views/common/error/404.vue'),
meta: {
title: pageTitle('notFound'), // 页面不存在
},
},
{
// 后台找不到页面了-可能是路由未加载上
path: adminBaseRoutePath + ':path(.*)*',
redirect: (to) => {
return {
name: 'adminMainLoading',
params: {
to: JSON.stringify({
path: to.path,
query: to.query,
}),
},
}
},
},
{
// 会员中心找不到页面了
path: memberCenterBaseRoutePath + ':path(.*)*',
redirect: (to) => {
return {
name: 'userMainLoading',
params: {
to: JSON.stringify({
path: to.path,
query: to.query,
}),
},
}
},
},
{
// 无权限访问
path: '/401',
name: 'noPower',
component: () => import('/@/views/common/error/401.vue'),
meta: {
title: pageTitle('noPower'),
},
},
]
const staticFiles: Record<string, Record<string, RouteRecordRaw>> = import.meta.glob('./static/*.ts', { eager: true })
for (const key in staticFiles) {
if (staticFiles[key].default) staticRoutes.push(staticFiles[key].default)
}
export default staticRoutes

View File

@@ -0,0 +1,33 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* 后台基础路由路径
* 您可以随时于后台->系统配置中修改此值程序可自动完成代码修改同时建立对应的API入口和禁止admin应用访问
*/
export const adminBaseRoutePath = '/admin'
/*
* 后台基础静态路由
*/
const adminBaseRoute: RouteRecordRaw = {
path: adminBaseRoutePath,
name: 'admin',
component: () => import('/@/layouts/backend/index.vue'),
// 直接重定向到 loading 路由
redirect: adminBaseRoutePath + '/loading',
meta: {
title: `pagesTitle.admin`,
},
children: [
{
path: 'loading/:to?',
name: 'adminMainLoading',
component: () => import('/@/layouts/common/components/loading.vue'),
meta: {
title: `pagesTitle.loading`,
},
},
],
}
export default adminBaseRoute

View File

@@ -0,0 +1,32 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* 会员中心基础路由路径
*/
export const memberCenterBaseRoutePath = '/user'
/*
* 会员中心基础静态路由
*/
const memberCenterBaseRoute: RouteRecordRaw = {
path: memberCenterBaseRoutePath,
name: 'user',
component: () => import('/@/layouts/frontend/user.vue'),
// 重定向到 loading 路由
redirect: memberCenterBaseRoutePath + '/loading',
meta: {
title: `pagesTitle.user`,
},
children: [
{
path: 'loading/:to?',
name: 'userMainLoading',
component: () => import('/@/layouts/common/components/loading.vue'),
meta: {
title: `pagesTitle.loading`,
},
},
],
}
export default memberCenterBaseRoute

View File

@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ADMIN_INFO } from '/@/stores/constant/cacheKey'
import type { AdminInfo } from '/@/stores/interface'
export const useAdminInfo = defineStore('adminInfo', {
state: (): AdminInfo => {
return {
id: 0,
username: '',
nickname: '',
avatar: '',
last_login_time: '',
token: '',
refresh_token: '',
super: false,
}
},
actions: {
/**
* 状态批量填充
* @param state 新状态数据
* @param [exclude=true] 是否排除某些字段(忽略填充),默认值 true 排除 token 和 refresh_token传递 false 则不排除,还可传递 string[] 指定排除字段列表
*/
dataFill(state: Partial<AdminInfo>, exclude: boolean | string[] = true) {
if (exclude === true) {
exclude = ['token', 'refresh_token']
} else if (exclude === false) {
exclude = []
}
if (Array.isArray(exclude)) {
exclude.forEach((item) => {
delete state[item as keyof AdminInfo]
})
}
this.$patch(state)
},
removeToken() {
this.token = ''
this.refresh_token = ''
},
setToken(token: string, type: 'auth' | 'refresh') {
const field = type == 'auth' ? 'token' : 'refresh_token'
this[field] = token
},
getToken(type: 'auth' | 'refresh' = 'auth') {
return type === 'auth' ? this.token : this.refresh_token
},
setSuper(val: boolean) {
this.super = val
},
},
persist: {
key: ADMIN_INFO,
},
})

View File

@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import router from '../router'
import { baAccountLogout } from '/@/api/backend/index'
import { BA_ACCOUNT } from '/@/stores/constant/cacheKey'
import type { UserInfo } from '/@/stores/interface'
import { Local } from '/@/utils/storage'
export const useBaAccount = defineStore('baAccount', {
state: (): Partial<UserInfo> => {
return {
id: 0,
username: '',
nickname: '',
email: '',
mobile: '',
avatar: '',
gender: 0,
birthday: '',
money: 0,
score: 0,
motto: '',
token: '',
refresh_token: '',
}
},
actions: {
/**
* 状态批量填充
* @param state 新状态数据
* @param [exclude=true] 是否排除某些字段(忽略填充),默认值 true 排除 token 和 refresh_token传递 false 则不排除,还可传递 string[] 指定排除字段列表
*/
dataFill(state: Partial<UserInfo>, exclude: boolean | string[] = true) {
if (exclude === true) {
exclude = ['token', 'refresh_token']
} else if (exclude === false) {
exclude = []
}
if (Array.isArray(exclude)) {
exclude.forEach((item) => {
delete state[item as keyof UserInfo]
})
}
this.$patch(state)
},
removeToken() {
this.token = ''
this.refresh_token = ''
},
getGenderIcon() {
let icon = { name: 'fa fa-transgender-alt', color: 'var(--el-text-color-secondary)' }
switch (this.gender) {
case 1:
icon = { name: 'fa fa-mars-stroke-v', color: 'var(--el-color-primary)' }
break
case 2:
icon = { name: 'fa fa-mars-stroke', color: 'var(--el-color-danger)' }
break
}
return icon
},
setToken(token: string, type: 'auth' | 'refresh') {
const field = type == 'auth' ? 'token' : 'refresh_token'
this[field] = token
},
getToken(type: 'auth' | 'refresh' = 'auth') {
return type === 'auth' ? this.token : this.refresh_token
},
logout() {
baAccountLogout().then((res) => {
if (res.code == 1) {
Local.remove(BA_ACCOUNT)
router.go(0)
}
})
},
},
persist: {
key: BA_ACCOUNT,
},
})

111
web/src/stores/config.ts Normal file
View File

@@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { STORE_CONFIG } from '/@/stores/constant/cacheKey'
import type { Crud, Lang, Layout } from '/@/stores/interface'
export const useConfig = defineStore(
'config',
() => {
const layout: Layout = reactive({
// 全局
showDrawer: false,
shrink: false,
layoutMode: 'Default',
mainAnimation: 'slide-right',
isDark: false,
// 侧边栏
menuBackground: ['#ffffff', '#1d1e1f'],
menuColor: ['#303133', '#CFD3DC'],
menuActiveBackground: ['#ffffff', '#1d1e1f'],
menuActiveColor: ['#409eff', '#3375b9'],
menuTopBarBackground: ['#fcfcfc', '#1d1e1f'],
menuWidth: 260,
menuDefaultIcon: 'fa fa-circle-o',
menuCollapse: false,
menuUniqueOpened: false,
menuShowTopBar: true,
// 顶栏
headerBarTabColor: ['#000000', '#CFD3DC'],
headerBarTabActiveBackground: ['#ffffff', '#1d1e1f'],
headerBarTabActiveColor: ['#000000', '#409EFF'],
headerBarBackground: ['#ffffff', '#1d1e1f'],
headerBarHoverBackground: ['#f5f5f5', '#18222c'],
})
const lang: Lang = reactive({
defaultLang: 'zh-cn',
fallbackLang: 'zh-cn',
langArray: [
{ name: 'zh-cn', value: '中文简体' },
{ name: 'en', value: 'English' },
],
})
const crud: Crud = reactive({
syncType: 'manual',
syncedUpdate: 'yes',
syncAutoPublic: 'no',
})
function menuWidth() {
if (layout.shrink) {
return layout.menuCollapse ? '0px' : layout.menuWidth + 'px'
}
// 菜单是否折叠
return layout.menuCollapse ? '64px' : layout.menuWidth + 'px'
}
function setLang(val: string) {
lang.defaultLang = val
}
function onSetLayoutColor(data = layout.layoutMode) {
// 切换布局时,如果是为默认配色方案,对菜单激活背景色重新赋值
const tempValue = layout.isDark ? { idx: 1, color: '#1d1e1f', newColor: '#141414' } : { idx: 0, color: '#ffffff', newColor: '#f5f5f5' }
if (
data == 'Classic' &&
layout.headerBarBackground[tempValue.idx] == tempValue.color &&
layout.headerBarTabActiveBackground[tempValue.idx] == tempValue.color
) {
layout.headerBarTabActiveBackground[tempValue.idx] = tempValue.newColor
} else if (
data == 'Default' &&
layout.headerBarBackground[tempValue.idx] == tempValue.color &&
layout.headerBarTabActiveBackground[tempValue.idx] == tempValue.newColor
) {
layout.headerBarTabActiveBackground[tempValue.idx] = tempValue.color
}
}
function setLayoutMode(data: string) {
layout.layoutMode = data
onSetLayoutColor(data)
}
const setLayout = (name: keyof Layout, value: any) => {
;(layout[name] as any) = value
}
const getColorVal = function (name: keyof Layout): string {
const colors = layout[name] as string[]
if (layout.isDark) {
return colors[1]
} else {
return colors[0]
}
}
const setCrud = (name: keyof Crud, value: any) => {
;(crud[name] as any) = value
}
return { layout, lang, crud, menuWidth, setLang, setLayoutMode, setLayout, getColorVal, onSetLayoutColor, setCrud }
},
{
persist: {
key: STORE_CONFIG,
},
}
)

View File

@@ -0,0 +1,25 @@
/**
* 本地缓存Key
*/
// 管理员资料
export const ADMIN_INFO = 'adminInfo'
// WEB端布局配置
export const STORE_CONFIG = 'storeConfig_v2'
// 后台标签页
export const STORE_TAB_VIEW_CONFIG = 'storeTabViewConfig'
// 终端
export const STORE_TERMINAL = 'storeTerminal'
// 工作时间
export const WORKING_TIME = 'workingTime'
// 切换到手机端前的上次布局方式
export const BEFORE_RESIZE_LAYOUT = 'beforeResizeLayout'
// 会员资料
export const USER_INFO = 'userInfo'
// ba官网用户信息
export const BA_ACCOUNT = 'ba_account'

View File

@@ -0,0 +1,8 @@
/**
* 公共常量定义
*/
/**
* 系统级 z-index 配置,比如全局通知消息的 z-index浏览器支持的最大值通常为 2147483647
*/
export const SYSTEM_ZINDEX = 2147483600

View File

@@ -0,0 +1,8 @@
export const enum taskStatus {
Waiting,
Connecting,
Executing,
Success,
Failed,
Unknown,
}

7
web/src/stores/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia

View File

@@ -0,0 +1,203 @@
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
export interface Layout {
/* 全局 - s */
// 是否显示布局配置抽屉
showDrawer: boolean
// 是否收缩布局(小屏设备)
shrink: boolean
// 后台布局方式,可选值<Default|Classic|Streamline|Double>
layoutMode: string
// 后台主页面切换动画,可选值<slide-right|slide-left|el-fade-in-linear|el-fade-in|el-zoom-in-center|el-zoom-in-top|el-zoom-in-bottom>
mainAnimation: string
// 是否暗黑模式
isDark: boolean
/* 全局 - e */
/* 侧边栏 - s */
// 侧边菜单宽度展开时单位px
menuWidth: number
// 侧边菜单项默认图标
menuDefaultIcon: string
// 是否水平折叠收起菜单
menuCollapse: boolean
// 是否只保持一个子菜单的展开(手风琴)
menuUniqueOpened: boolean
// 显示菜单栏顶栏LOGO
menuShowTopBar: boolean
// 侧边菜单背景色
menuBackground: string[]
// 侧边菜单文字颜色
menuColor: string[]
// 侧边菜单激活项背景色
menuActiveBackground: string[]
// 侧边菜单激活项文字色
menuActiveColor: string[]
// 侧边菜单顶栏背景色
menuTopBarBackground: string[]
/* 侧边栏 - e */
/* 顶栏 - s */
// 顶栏文字色
headerBarTabColor: string[]
// 顶栏背景色
headerBarBackground: string[]
// 顶栏悬停时背景色
headerBarHoverBackground: string[]
// 顶栏激活项背景色
headerBarTabActiveBackground: string[]
// 顶栏激活项文字色
headerBarTabActiveColor: string[]
/* 顶栏 - e */
}
export interface Lang {
// 默认语言,可选值<zh-cn|en>
defaultLang: string
// 当在默认语言包找不到翻译时,继续在 fallbackLang 语言包内查找翻译
fallbackLang: string
// 支持的语言列表
langArray: { name: string; value: string }[]
}
export interface Crud {
// 日志同步方式
syncType: 'manual' | 'automatic'
// 已同步记录被更新时,是否自动重新同步
syncedUpdate: 'no' | 'yes'
// 自动同步时是否分享至开源社区
syncAutoPublic: 'no' | 'yes'
}
export interface NavTabs {
// 激活 tab 的 index
activeIndex: number
// 激活的 tab
activeRoute: RouteLocationNormalized | null
// tab 列表
tabsView: RouteLocationNormalized[]
// 当前 tab 是否全屏
tabFullScreen: boolean
// 从后台加载到的菜单路由列表
tabsViewRoutes: RouteRecordRaw[]
// 权限节点
authNode: Map<string, string[]>
}
export interface MemberCenter {
// 是否开启会员中心
open: boolean
// 布局模式
layoutMode: string
// 从后台加载到的菜单
viewRoutes: RouteRecordRaw[]
// 是否显示一级菜单标题(当有多个一级菜单分组时显示)
showHeadline: boolean
// 权限节点
authNode: Map<string, string[]>
// 收缩布局(小屏设备)
shrink: boolean
// 菜单展开状态(小屏设备)
menuExpand: boolean
// 顶栏会员菜单下拉项
navUserMenus: RouteRecordRaw[]
}
export interface AdminInfo {
id: number
username: string
nickname: string
avatar: string
last_login_time: string
token: string
refresh_token: string
// 是否是 superAdmin用于判定是否显示终端按钮等不做任何权限判断
super: boolean
}
export interface UserInfo {
id: number
username: string
nickname: string
email: string
mobile: string
gender: number
birthday: string
money: number
score: number
avatar: string
last_login_time: string
last_login_ip: string
join_time: string
motto: string
token: string
refresh_token: string
}
export interface TaskItem {
// 任务唯一标识
uuid: string
// 创建时间
createTime: string
// 状态
status: number
// 命令
command: string
// 命令执行日志
message: string[]
// 显示命令执行日志
showMessage: boolean
// 失败阻断后续命令执行
blockOnFailure: boolean
// 扩展信息,自动发送到后台
extend: string
// 执行结果回调
callback: Function
}
export interface Terminal {
// 显示终端窗口
show: boolean
// 在后台终端按钮上显示一个红点
showDot: boolean
// 任务列表
taskList: TaskItem[]
// 包管理器
packageManager: string
// 显示终端设置窗口
showConfig: boolean
// 开始任务时自动清理已完成任务
automaticCleanupTask: string
// PHP 开发服务环境
phpDevelopmentServer: boolean
// NPM 源
npmRegistry: string
// composer 源
composerRegistry: string
}
export interface SiteConfig {
// 站点名称
siteName: string
// 系统版本号
version: string
// 内容分发网络URL
cdnUrl: string
// 中心接口地址(用于请求模块市场的数据等用途)
apiUrl: string
// 上传配置
upload: {
mode: string
[key: string]: any
}
// 顶部导航菜单数据
headNav: RouteRecordRaw[]
// 备案号
recordNumber?: string
// 内容分发网络URL的参数格式如 imageMogr2/format/heif
cdnUrlParams: string
// 初始化状态
initialize: boolean
userInitialize: boolean
}

View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import type { MemberCenter } from '/@/stores/interface/index'
export const useMemberCenter = defineStore('memberCenter', () => {
const state: MemberCenter = reactive({
open: true,
layoutMode: 'Default',
viewRoutes: [],
showHeadline: false,
authNode: new Map(),
shrink: false,
menuExpand: false,
navUserMenus: [],
})
const setNavUserMenus = (menus: RouteRecordRaw[]) => {
state.navUserMenus = menus
}
const mergeNavUserMenus = (menus: RouteRecordRaw[]) => {
state.navUserMenus = [...state.navUserMenus, ...menus]
}
const setAuthNode = (key: string, data: string[]) => {
state.authNode.set(key, data)
}
const mergeAuthNode = (authNode: Map<string, string[]>) => {
state.authNode = new Map([...state.authNode, ...authNode])
}
const setViewRoutes = (data: RouteRecordRaw[]): void => {
state.viewRoutes = encodeRoutesURI(data)
}
const setShowHeadline = (show: boolean): void => {
state.showHeadline = show
}
const setShrink = (shrink: boolean) => {
state.shrink = shrink
}
const setStatus = (status: boolean) => {
state.open = status
}
const setLayoutMode = (mode: string) => {
state.layoutMode = mode
}
const toggleMenuExpand = (expand = !state.menuExpand) => {
state.menuExpand = expand
}
return {
state,
setNavUserMenus,
mergeNavUserMenus,
setAuthNode,
mergeAuthNode,
setViewRoutes,
setShowHeadline,
setShrink,
setStatus,
setLayoutMode,
toggleMenuExpand,
}
})
function encodeRoutesURI(data: RouteRecordRaw[]) {
data.forEach((item) => {
if (item.meta?.menu_type == 'iframe') {
item.path = '/user/iframe/' + encodeURIComponent(item.path)
}
if (item.children && item.children.length) {
item.children = encodeRoutesURI(item.children)
}
})
return data
}

245
web/src/stores/navTabs.ts Normal file
View File

@@ -0,0 +1,245 @@
import { isEmpty } from 'lodash-es'
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
import { i18n } from '../lang'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { STORE_TAB_VIEW_CONFIG } from '/@/stores/constant/cacheKey'
import type { NavTabs } from '/@/stores/interface/index'
import { layoutNavTabsRef } from '/@/stores/refs'
export const useNavTabs = defineStore(
'navTabs',
() => {
const state: NavTabs = reactive({
activeIndex: 0,
activeRoute: null,
tabsView: [],
tabFullScreen: false,
tabsViewRoutes: [],
authNode: new Map(),
})
/**
* 通过路由路径关闭tab
* @param fullPath 需要关闭的 tab 的路径
*/
const closeTabByPath = (fullPath: string) => {
layoutNavTabsRef.value?.closeTabByPath(fullPath)
}
/**
* 关闭所有tab
* @param menu 需要保留的标签,否则关闭全部标签并打开第一个路由
*/
const closeAllTab = (menu?: RouteLocationNormalized) => {
layoutNavTabsRef.value?.closeAllTab(menu)
}
/**
* 修改 tab 标题
* @param fullPath 需要修改标题的 tab 的路径
* @param title 新的标题
*/
const updateTabTitle = (fullPath: string, title: string) => {
layoutNavTabsRef.value?.updateTabTitle(fullPath, title)
}
/**
* 添加 tab内部
* ps: router.push 时可自动完成 tab 添加,无需调用此方法
*/
function _addTab(route: RouteLocationNormalized) {
const tabView = { ...route, matched: [], meta: { ...route.meta } }
if (!tabView.meta.addtab) return
// 通过路由寻找菜单的原始数据
const tabViewRoute = getTabsViewDataByRoute(tabView)
if (tabViewRoute && tabViewRoute.meta) {
tabView.name = tabViewRoute.name
tabView.meta.id = tabViewRoute.meta.id
tabView.meta.title = tabViewRoute.meta.title
}
for (const key in state.tabsView) {
// 菜单已在 tabs 存在,更新 params 和 query
if (state.tabsView[key].meta.id === tabView.meta.id || state.tabsView[key].fullPath == tabView.fullPath) {
state.tabsView[key].fullPath = tabView.fullPath
state.tabsView[key].params = !isEmpty(tabView.params) ? tabView.params : state.tabsView[key].params
state.tabsView[key].query = !isEmpty(tabView.query) ? tabView.query : state.tabsView[key].query
return
}
}
if (typeof tabView.meta.title == 'string') {
tabView.meta.title = i18n.global.te(tabView.meta.title) ? i18n.global.t(tabView.meta.title) : tabView.meta.title
}
state.tabsView.push(tabView)
}
/**
* 设置激活 tab内部
* ps: router.push 时可自动完成 tab 激活,无需调用此方法
*/
const _setActiveRoute = (route: RouteLocationNormalized): void => {
const currentRouteIndex: number = state.tabsView.findIndex((item: RouteLocationNormalized) => {
return item.fullPath === route.fullPath
})
if (currentRouteIndex === -1) return
state.activeRoute = route
state.activeIndex = currentRouteIndex
}
/**
* 关闭 tab内部
* ps: 使用 closeTabByPath 代替
*/
function _closeTab(route: RouteLocationNormalized) {
state.tabsView.map((v, k) => {
if (v.fullPath == route.fullPath) {
state.tabsView.splice(k, 1)
return
}
})
}
/**
* 关闭多个标签(内部)
* ps使用 closeAllTab 代替
*/
const _closeTabs = (retainMenu: RouteLocationNormalized | false = false) => {
if (retainMenu) {
state.tabsView = [retainMenu]
} else {
state.tabsView = []
}
}
/**
* 更新标签标题(内部)
* ps: 使用 updateTabTitle 代替
*/
const _updateTabTitle = (fullPath: string, title: string) => {
for (const key in state.tabsView) {
if (state.tabsView[key].fullPath == fullPath) {
state.tabsView[key].meta.title = title
break
}
}
}
/**
* 设置从后台加载到的菜单路由列表
*/
const setTabsViewRoutes = (data: RouteRecordRaw[]): void => {
state.tabsViewRoutes = encodeRoutesURI(data)
}
/**
* 以key设置权限节点
*/
const setAuthNode = (key: string, data: string[]) => {
state.authNode.set(key, data)
}
/**
* 覆盖设置权限节点
*/
const fillAuthNode = (data: Map<string, string[]>) => {
state.authNode = data
}
/**
* 设置当前 tab 是否全屏
* @param status 全屏状态
*/
const setFullScreen = (status: boolean): void => {
state.tabFullScreen = status
}
/**
* 寻找路由在菜单中的数据
* @param route 路由
* @param returnType 返回值要求:normal=返回被搜索的路径对应的菜单数据,above=返回被搜索的路径对应的上一级菜单数组
*/
const getTabsViewDataByRoute = (route: RouteLocationNormalized, returnType: 'normal' | 'above' = 'normal'): RouteRecordRaw | false => {
// 以完整路径寻找
let found = getTabsViewDataByPath(route.fullPath, state.tabsViewRoutes, returnType)
if (found) {
found.meta!.matched = route.fullPath
return found
}
// 以路径寻找
found = getTabsViewDataByPath(route.path, state.tabsViewRoutes, returnType)
if (found) {
found.meta!.matched = route.path
return found
}
return false
}
/**
* 递归的寻找路由路径在菜单中的数据
* @param path 路由路径
* @param menus 菜单数据(只有 path 代表完整 url没有 fullPath
* @param returnType 返回值要求:normal=返回被搜索的路径对应的菜单数据,above=返回被搜索的路径对应的上一级菜单数组
*/
const getTabsViewDataByPath = (path: string, menus: RouteRecordRaw[], returnType: 'normal' | 'above'): RouteRecordRaw | false => {
for (const key in menus) {
// 找到目标
if (menus[key].path === path) {
return menus[key]
}
// 从子级继续寻找
if (menus[key].children && menus[key].children.length) {
const find = getTabsViewDataByPath(path, menus[key].children, returnType)
if (find) {
return returnType == 'above' ? menus[key] : find
}
}
}
return false
}
return {
state,
closeAllTab,
closeTabByPath,
updateTabTitle,
setTabsViewRoutes,
setAuthNode,
fillAuthNode,
setFullScreen,
getTabsViewDataByPath,
getTabsViewDataByRoute,
_addTab,
_closeTab,
_closeTabs,
_setActiveRoute,
_updateTabTitle,
}
},
{
persist: {
key: STORE_TAB_VIEW_CONFIG,
pick: ['state.tabFullScreen'],
},
}
)
/**
* 对iframe的url进行编码
*/
function encodeRoutesURI(data: RouteRecordRaw[]) {
data.forEach((item) => {
if (item.meta?.menu_type == 'iframe') {
item.path = adminBaseRoutePath + '/iframe/' + encodeURIComponent(item.path)
}
if (item.children && item.children.length) {
item.children = encodeRoutesURI(item.children)
}
})
return data
}

34
web/src/stores/refs.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* references
* 全局提供:引用(指向)一些对象(组件)的句柄
*/
import type { ScrollbarInstance } from 'element-plus'
import type { CSSProperties } from 'vue'
import { computed, ref } from 'vue'
import NavTabs from '/@/layouts/backend/components/navBar/tabs.vue'
import { mainHeight } from '/@/utils/layout'
/**
* 后台顶栏(tabs)组件ref仅默认和经典布局
*/
export const layoutNavTabsRef = ref<InstanceType<typeof NavTabs>>()
/**
* 前后台布局的主体的滚动条组件ref
*/
export const layoutMainScrollbarRef = ref<ScrollbarInstance>()
/**
* 前后台布局的主体滚动条的额外样式,包括高度
*/
export const layoutMainScrollbarStyle = computed<CSSProperties>(() => mainHeight())
/**
* 前后台布局的菜单组件ref
*/
export const layoutMenuRef = ref<ScrollbarInstance>()
/**
* 前后台布局的菜单栏滚动条组件ref
*/
export const layoutMenuScrollbarRef = ref<ScrollbarInstance>()

View File

@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import type { RouteRecordRaw } from 'vue-router'
import type { SiteConfig } from '/@/stores/interface'
export const useSiteConfig = defineStore('siteConfig', {
state: (): SiteConfig => {
return {
siteName: '',
version: '',
cdnUrl: '',
apiUrl: '',
upload: {
mode: 'local',
},
headNav: [],
recordNumber: '',
cdnUrlParams: '',
initialize: false,
userInitialize: false,
}
},
actions: {
dataFill(state: SiteConfig) {
// 使用 this.$patch(state) 时 headNav 的类型异常,直接赋值
this.$state = state
},
setHeadNav(headNav: RouteRecordRaw[]) {
this.headNav = headNav
},
setInitialize(initialize: boolean) {
this.initialize = initialize
},
setUserInitialize(userInitialize: boolean) {
this.userInitialize = userInitialize
},
},
})

292
web/src/stores/terminal.ts Normal file
View File

@@ -0,0 +1,292 @@
import { ElNotification } from 'element-plus'
import { defineStore } from 'pinia'
import { nextTick, reactive } from 'vue'
import { buildTerminalUrl } from '/@/api/common'
import { i18n } from '/@/lang/index'
import { STORE_TERMINAL } from '/@/stores/constant/cacheKey'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { Terminal } from '/@/stores/interface/index'
import { timeFormat } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
export const useTerminal = defineStore(
'terminal',
() => {
const state: Terminal = reactive({
show: false,
showDot: false,
taskList: [],
packageManager: 'pnpm',
showConfig: false,
automaticCleanupTask: '1',
phpDevelopmentServer: false,
npmRegistry: 'unknown',
composerRegistry: 'unknown',
})
function init() {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
state.taskList[key].status = taskStatus.Unknown
}
}
}
function toggle(val = !state.show) {
state.show = val
if (val) {
toggleDot(false)
}
}
function toggleDot(val = !state.showDot) {
state.showDot = val
}
function toggleConfigDialog(val = !state.showConfig) {
toggle(!val)
state.showConfig = val
}
function changeRegistry(val: string, type: 'npm' | 'composer') {
state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] = val
}
function changePackageManager(val: string) {
state.packageManager = val
}
function changePHPDevelopmentServer(val: boolean) {
state.phpDevelopmentServer = val
}
function changeAutomaticCleanupTask(val: '0' | '1') {
state.automaticCleanupTask = val
}
function setTaskStatus(idx: number, status: number) {
state.taskList[idx].status = status
if ((status == taskStatus.Failed || status == taskStatus.Unknown) && state.taskList[idx].blockOnFailure) {
setTaskShowMessage(idx, true)
}
}
function taskCompleted(idx: number) {
// 命令执行完毕,重新打开热更新
openHotUpdate('terminal')
if (typeof state.taskList[idx].callback != 'function') return
const status = state.taskList[idx].status
if (status == taskStatus.Failed || status == taskStatus.Unknown) {
state.taskList[idx].callback(taskStatus.Failed)
} else if (status == taskStatus.Success) {
state.taskList[idx].callback(taskStatus.Success)
}
}
function setTaskShowMessage(idx: number, val = !state.taskList[idx].showMessage) {
state.taskList[idx].showMessage = val
}
function addTaskMessage(idx: number, message: string) {
if (!state.show) toggleDot(true)
state.taskList[idx].message = state.taskList[idx].message.concat(message)
nextTick(() => {
execMessageScrollbarKeep(state.taskList[idx].uuid)
})
}
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({
uuid: uuid(),
createTime: timeFormat(),
status: taskStatus.Waiting,
command: command,
message: [],
showMessage: false,
blockOnFailure: blockOnFailure,
extend: extend,
callback: callback,
})
// 清理任务列表
if (parseInt(state.automaticCleanupTask) === 1) {
clearSuccessTask()
}
// 检查是否有已经失败的任务
if (state.show === false) {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
ElNotification({
type: 'error',
message: i18n.global.t('terminal.Newly added tasks will never start because they are blocked by failed tasks'),
zIndex: SYSTEM_ZINDEX,
})
break
}
}
}
startTask()
}
function addTaskPM(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
addTask(command + '.' + state.packageManager, blockOnFailure, extend, callback)
}
function delTask(idx: number) {
if (state.taskList[idx].status != taskStatus.Connecting && state.taskList[idx].status != taskStatus.Executing) {
state.taskList.splice(idx, 1)
}
startTask()
}
function startTask() {
let taskKey = null
// 寻找可以开始执行的命令
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Waiting) {
taskKey = parseInt(key)
break
}
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
break
}
if (state.taskList[key].status == taskStatus.Success) {
continue
}
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
if (state.taskList[key].blockOnFailure) {
break
} else {
continue
}
}
}
if (taskKey !== null) {
setTaskStatus(taskKey, taskStatus.Connecting)
startEventSource(taskKey)
}
}
function startEventSource(taskKey: number) {
// 命令执行期间禁用热更新
closeHotUpdate('terminal')
window.eventSource = new EventSource(
buildTerminalUrl(state.taskList[taskKey].command, state.taskList[taskKey].uuid, state.taskList[taskKey].extend)
)
window.eventSource.onmessage = function (e) {
const data = JSON.parse(e.data)
if (!data || !data.data) {
return
}
const taskIdx = findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) {
return
}
if (data.data == 'command-exec-error') {
setTaskStatus(taskIdx, taskStatus.Failed)
window.eventSource.close()
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-exec-completed') {
window.eventSource.close()
if (state.taskList[taskIdx].status != taskStatus.Success) {
setTaskStatus(taskIdx, taskStatus.Failed)
}
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-link-success') {
setTaskStatus(taskIdx, taskStatus.Executing)
} else if (data.data == 'command-exec-success') {
setTaskStatus(taskIdx, taskStatus.Success)
} else {
addTaskMessage(taskIdx, data.data)
}
}
window.eventSource.onerror = function () {
window.eventSource.close()
const taskIdx = findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
setTaskStatus(taskIdx, taskStatus.Failed)
taskCompleted(taskIdx)
}
}
function retryTask(idx: number) {
state.taskList[idx].message = []
setTaskStatus(idx, taskStatus.Waiting)
startTask()
}
function clearSuccessTask() {
state.taskList = state.taskList.filter((item) => item.status != taskStatus.Success)
}
function findTaskIdxFromUuid(uuid: string) {
for (const key in state.taskList) {
if (state.taskList[key].uuid == uuid) {
return parseInt(key)
}
}
return false
}
function findTaskIdxFromGuess(idx: number) {
if (!state.taskList[idx]) {
let taskKey = -1
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
taskKey = parseInt(key)
}
}
return taskKey === -1 ? false : taskKey
} else {
return idx
}
}
function execMessageScrollbarKeep(uuid: string) {
const execMessageEl = document.querySelector('.exec-message-' + uuid) as Element
if (execMessageEl && execMessageEl.scrollHeight) {
execMessageEl.scrollTop = execMessageEl.scrollHeight
}
}
return {
state,
init,
toggle,
toggleDot,
setTaskStatus,
setTaskShowMessage,
addTaskMessage,
addTask,
addTaskPM,
delTask,
startTask,
retryTask,
clearSuccessTask,
toggleConfigDialog,
changeRegistry,
changePackageManager,
changePHPDevelopmentServer,
changeAutomaticCleanupTask,
}
},
{
persist: {
key: STORE_TERMINAL,
pick: ['state.showDot', 'state.taskList', 'state.automaticCleanupTask', 'state.npmRegistry', 'state.composerRegistry'],
},
}
)

View File

@@ -0,0 +1,88 @@
import { defineStore } from 'pinia'
import router from '../router'
import { postLogout } from '/@/api/frontend/user/index'
import { USER_INFO } from '/@/stores/constant/cacheKey'
import type { UserInfo } from '/@/stores/interface'
import { Local } from '/@/utils/storage'
export const useUserInfo = defineStore('userInfo', {
state: (): UserInfo => {
return {
id: 0,
username: '',
nickname: '',
email: '',
mobile: '',
avatar: '',
gender: 0,
birthday: '',
money: 0,
score: 0,
last_login_time: '',
last_login_ip: '',
join_time: '',
motto: '',
token: '',
refresh_token: '',
}
},
actions: {
/**
* 状态批量填充
* @param state 新状态数据
* @param [exclude=true] 是否排除某些字段(忽略填充),默认值 true 排除 token 和 refresh_token传递 false 则不排除,还可传递 string[] 指定排除字段列表
*/
dataFill(state: Partial<UserInfo>, exclude: boolean | string[] = true) {
if (exclude === true) {
exclude = ['token', 'refresh_token']
} else if (exclude === false) {
exclude = []
}
if (Array.isArray(exclude)) {
exclude.forEach((item) => {
delete state[item as keyof UserInfo]
})
}
this.$patch(state)
},
removeToken() {
this.token = ''
this.refresh_token = ''
},
setToken(token: string, type: 'auth' | 'refresh') {
const field = type == 'auth' ? 'token' : 'refresh_token'
this[field] = token
},
getToken(type: 'auth' | 'refresh' = 'auth') {
return type === 'auth' ? this.token : this.refresh_token
},
getGenderIcon() {
let icon = { name: 'fa fa-transgender-alt', color: 'var(--el-text-color-secondary)' }
switch (this.gender) {
case 1:
icon = { name: 'fa fa-mars-stroke-v', color: 'var(--el-color-primary)' }
break
case 2:
icon = { name: 'fa fa-mars-stroke', color: 'var(--el-color-danger)' }
break
}
return icon
},
logout() {
postLogout().then((res) => {
if (res.code == 1) {
Local.remove(USER_INFO)
router.go(0)
}
})
},
isLogin() {
return this.id && this.token
},
},
persist: {
key: USER_INFO,
},
})

240
web/src/styles/app.scss Normal file
View File

@@ -0,0 +1,240 @@
/* 基本样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
outline: none !important;
}
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family:
Helvetica Neue,
Helvetica,
PingFang SC,
Hiragino Sans GB,
Microsoft YaHei,
SimSun,
sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
background-color: var(--ba-bg-color);
color: var(--el-text-color-primary);
font-size: var(--el-font-size-base);
}
// 阿里 iconfont Symbol引用css
.iconfont-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.w100 {
width: 100% !important;
}
.h100 {
height: 100% !important;
}
.ba-center {
display: flex;
align-items: center;
justify-content: center;
}
.default-main {
margin: var(--ba-main-space) var(--ba-main-space) 60px var(--ba-main-space);
}
.zoom-handle {
position: absolute;
width: 20px;
height: 20px;
bottom: -10px;
right: -10px;
cursor: se-resize;
}
.block-help {
display: block;
width: 100%;
color: #909399;
font-size: 13px;
line-height: 16px;
padding-top: 5px;
}
/* 表格顶部菜单-s */
.table-header {
.table-header-operate .icon {
font-size: 14px !important;
color: var(--el-color-white) !important;
}
.el-button.is-disabled .icon {
color: var(--el-button-disabled-text-color) !important;
}
}
/* 表格顶部菜单-e */
/* 鼠标置入浮动效果-s */
.suspension {
transition: all 0.3s ease;
}
.suspension:hover {
-webkit-transform: translateY(-4px) scale(1.02);
-moz-transform: translateY(-4px) scale(1.02);
-ms-transform: translateY(-4px) scale(1.02);
-o-transform: translateY(-4px) scale(1.02);
transform: translateY(-4px) scale(1.02);
-webkit-box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
z-index: 2147483600;
border-radius: 6px;
}
/* 鼠标置入浮动效果-e */
/* 表格-s */
.ba-table-box {
border-radius: var(--el-border-radius-round);
}
.ba-table-alert {
background-color: var(--el-fill-color-darker) !important;
border: 1px solid var(--ba-boder-color);
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* 表格-e */
/* 新增/编辑表单-s */
.ba-operate-dialog {
overflow: hidden;
border-radius: var(--el-border-radius-base);
padding-bottom: 52px;
}
.ba-operate-dialog .el-dialog__header {
border-bottom: 1px solid var(--ba-bg-color);
.el-dialog__headerbtn {
top: 4px;
}
}
.ba-operate-dialog .el-dialog__body {
height: 58vh;
}
.ba-operate-dialog .el-dialog__footer {
padding: 10px var(--el-dialog-padding-primary);
box-shadow: var(--el-box-shadow);
position: absolute;
width: 100%;
bottom: 0;
left: 0;
}
.ba-operate-form {
padding-top: 20px;
}
/* 新增/编辑表单-e */
/* 全局遮罩-s */
.ba-layout-shade {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2147483599;
}
/* 全局遮罩-e */
/* 图片上传预览-s */
.img-preview-dialog .el-dialog__body {
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
}
}
/* 图片上传预览-e */
/* 页面切换动画-s */
.slide-right-enter-active,
.slide-right-leave-active,
.slide-left-enter-active,
.slide-left-leave-active {
will-change: transform;
transition: all 0.3s ease;
}
// slide-right
.slide-right-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(20px);
}
// slide-left
.slide-left-enter-from {
@extend .slide-right-leave-to;
}
.slide-left-leave-to {
@extend .slide-right-enter-from;
}
/* 页面切换动画-e */
/* 布局相关-s */
.frontend-footer-brother {
min-height: calc(100vh - 120px);
}
.user-views {
padding-left: 15px;
.user-views-card {
margin-bottom: 15px;
}
}
.ba-aside-drawer {
.el-drawer__body {
padding: 0;
}
}
/* 布局相关-e */
/* 暗黑模式公共样式-s */
.ba-icon-dark {
color: var(--el-text-color-primary) !important;
}
/* 暗黑模式公共样式-e */
/* NProgress-s */
#nprogress {
.bar,
.spinner {
z-index: 2147483600;
}
}
/* NProgress-e */
/* 自适应-s */
@media screen and (max-width: 768px) {
.xs-hidden {
display: none;
}
}
@media screen and (max-width: 1024px) {
.ba-operate-dialog {
width: 96%;
}
}
@media screen and (max-width: 991px) {
.user-views {
padding: 0;
}
}
/* 自适应-e */

27
web/src/styles/dark.scss Normal file
View File

@@ -0,0 +1,27 @@
@use 'sass:map';
@use 'mixins.scss' as *;
@use 'element-plus/theme-chalk/src/dark/css-vars.scss';
// Background
$bg-color: () !default;
$bg-color: map.merge(
(
'': #141414,
'overlay': #1d1e1f,
),
$bg-color
);
// Border
$border-color: () !default;
$border-color: map.merge(
(
'': #4c4d4f,
),
$border-color
);
html.dark {
@include set-component-css-var('bg-color', $bg-color);
@include set-component-css-var('border-color', $border-color);
}

View File

@@ -0,0 +1,87 @@
.el-menu {
user-select: none;
.el-sub-menu__title:hover {
background-color: var(--el-color-primary-light-9) !important;
}
}
.el-table {
--el-table-border-color: var(--ba-border-color);
}
.el-card {
border: none;
.el-card__header {
border-bottom: 1px solid var(--el-border-color-extra-light);
}
}
.el-divider__text.is-center {
transform: translateX(-50%) translateY(-62%);
}
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题开始 <<< */
.el-input {
.el-input__inner {
line-height: calc(var(--el-input-height, 40px) - 4px);
}
}
/* 修复 Chrome 浏览器输入框内选中字符行高异常的问题结束 >>> */
/* 输入框样式统一开始 <<< */
.el-input-number.is-controls-right {
.el-input__wrapper {
padding-left: 11px;
}
.el-input__inner {
text-align: left;
}
}
.el-textarea__inner {
padding: 5px 11px;
}
.datetime-picker {
height: 32px;
padding-top: 0;
padding-bottom: 0;
}
/* 输入框样式统一结束 >>> */
/* dialog 滚动条样式优化开始 <<< */
.el-overlay-dialog,
.ba-scroll-style {
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #eaeaea;
border-radius: var(--el-border-radius-base);
box-shadow: none;
-webkit-box-shadow: none;
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #c8c9cc;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.el-overlay-dialog,
.ba-scroll-style {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
/* dialog 滚动条样式优化结束 >>> */
/* 小屏设备 el-radio-group 样式优化开始 <<< */
.ba-input-item-radio {
margin-bottom: 10px;
.el-radio-group {
.el-radio {
margin-bottom: 8px;
}
}
}
/* 小屏设备 el-radio-group 样式优化结束 >>> */

View File

@@ -0,0 +1,5 @@
@use '/@/styles/app';
@use '/@/styles/element';
@use '/@/styles/var';
@use '/@/styles/dark';
@use '/@/styles/markdown';

View File

@@ -0,0 +1,54 @@
.block-loading {
width: 100%;
height: 100%;
position: fixed;
z-index: 2147483600;
background-color: var(--ba-bg-color);
}
.block-loading .block-loading-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.block-loading .block-loading-box-warp {
width: 80px;
height: 80px;
}
.block-loading .block-loading-box-warp .block-loading-box-item {
width: 33.333333%;
height: 33.333333%;
background: #409eff;
float: left;
animation: block-loading-animation 1.2s infinite ease;
border-radius: 1px;
}
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(7) {
animation-delay: 0s;
}
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(4),
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(8) {
animation-delay: 0.1s;
}
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(1),
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(5),
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(9) {
animation-delay: 0.2s;
}
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(2),
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(6) {
animation-delay: 0.3s;
}
.block-loading .block-loading-box-warp .block-loading-box-item:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes block-loading-animation {
0%,
70%,
100% {
transform: scale3D(1, 1, 1);
}
35% {
transform: scale3D(0, 0, 1);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
@mixin set-css-var-value($name, $value) {
#{joinVarName($name)}: #{$value};
}
@function joinVarName($list) {
$name: '--ba';
@each $item in $list {
@if $item != '' {
$name: $name + '-' + $item;
}
}
@return $name;
}
@function getCssVarName($args...) {
@return joinVarName($args);
}
/*
* 通过映射设置所有的CSS变量
*/
@mixin set-component-css-var($name, $variables) {
@each $attribute, $value in $variables {
@if $attribute == 'default' {
#{getCssVarName($name)}: #{$value};
} @else {
#{getCssVarName($name, $attribute)}: #{$value};
}
}
}

32
web/src/styles/var.scss Normal file
View File

@@ -0,0 +1,32 @@
@use 'sass:map';
@use 'mixins' as *;
// 后台主体窗口左右间距
$main-space: 16px;
$primary-light: #3f6ad8;
// --ba-background
$bg-color: () !default;
$bg-color: map.merge(
(
'': #f5f5f5,
'overlay': #ffffff,
),
$bg-color
);
// --ba-border-color
$border-color: () !default;
$border-color: map.merge(
(
'': #f6f6f6,
),
$border-color
);
:root {
@include set-css-var-value('main-space', $main-space);
@include set-css-var-value('color-primary-light', $primary-light);
@include set-component-css-var('bg-color', $bg-color);
@include set-component-css-var('border-color', $border-color);
}

382
web/src/utils/axios.ts Normal file
View File

@@ -0,0 +1,382 @@
import type { AxiosRequestConfig, Method } from 'axios'
import axios from 'axios'
import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus'
import { refreshToken } from '/@/api/common'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
import adminBaseRoute from '/@/router/static/adminBase'
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { useUserInfo } from '/@/stores/userInfo'
import { isAdminApp } from '/@/utils/common'
window.requests = []
window.tokenRefreshing = false
const pendingMap = new Map()
const loadingInstance: LoadingInstance = {
target: null,
count: 0,
}
/**
* 根据运行环境获取基础请求URL
*/
export const getUrl = (): string => {
const value: string = import.meta.env.VITE_AXIOS_BASE_URL as string
return value == 'getCurrentDomain' ? window.location.protocol + '//' + window.location.host : value
}
/**
* 根据运行环境获取基础请求URL的端口
*/
export const getUrlPort = (): string => {
const url = getUrl()
return new URL(url).port
}
/**
* 创建`Axios`
* 默认开启`reductDataFormat(简洁响应)`,返回类型为`ApiPromise`
* 关闭`reductDataFormat`,返回类型则为`AxiosPromise`
*/
function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequestConfig, options: Options = {}, loading: LoadingOptions = {}): T {
const config = useConfig()
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
const Axios = axios.create({
baseURL: getUrl(),
timeout: 1000 * 10,
headers: {
'think-lang': config.lang.defaultLang,
server: true,
},
responseType: 'json',
})
// 自定义后台入口
if (adminBaseRoute.path != '/admin' && isAdminApp() && /^\/admin\//.test(axiosConfig.url!)) {
axiosConfig.url = axiosConfig.url!.replace(/^\/admin\//, adminBaseRoute.path + '.php/')
}
// 合并默认请求选项
options = Object.assign(
{
cancelDuplicateRequest: true, // 是否开启取消重复请求, 默认为 true
loading: false, // 是否开启loading层效果, 默认为false
reductDataFormat: true, // 是否开启简洁的数据结构响应, 默认为true
showErrorMessage: true, // 是否开启接口错误信息展示,默认为true
showCodeMessage: true, // 是否开启code不为1时的信息提示, 默认为true
showSuccessMessage: false, // 是否开启code为1时的信息提示, 默认为false
anotherToken: '', // 当前请求使用另外的用户token
},
options
)
// 请求拦截
Axios.interceptors.request.use(
(config) => {
removePending(config)
options.cancelDuplicateRequest && addPending(config)
// 创建loading实例
if (options.loading) {
loadingInstance.count++
if (loadingInstance.count === 1) {
loadingInstance.target = ElLoading.service(loading)
}
}
// 自动携带token
if (config.headers) {
const token = adminInfo.getToken()
if (token) (config.headers as anyObj).batoken = token
const userToken = options.anotherToken || userInfo.getToken()
if (userToken) (config.headers as anyObj)['ba-user-token'] = userToken
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截
Axios.interceptors.response.use(
(response) => {
removePending(response.config)
options.loading && closeLoading(options) // 关闭loading
if (response.config.responseType == 'json') {
if (response.data && response.data.code !== 1) {
if (response.data.code == 409) {
if (!window.tokenRefreshing) {
window.tokenRefreshing = true
return refreshToken()
.then((res) => {
if (res.data.type == 'admin-refresh') {
adminInfo.setToken(res.data.token, 'auth')
response.headers.batoken = `${res.data.token}`
window.requests.forEach((cb) => cb(res.data.token, 'admin-refresh'))
} else if (res.data.type == 'user-refresh') {
userInfo.setToken(res.data.token, 'auth')
response.headers['ba-user-token'] = `${res.data.token}`
window.requests.forEach((cb) => cb(res.data.token, 'user-refresh'))
}
window.requests = []
return Axios(response.config)
})
.catch((err) => {
if (isAdminApp()) {
adminInfo.removeToken()
if (router.currentRoute.value.name != 'adminLogin') {
router.push({ name: 'adminLogin' })
return Promise.reject(err)
} else {
response.headers.batoken = ''
window.requests.forEach((cb) => cb('', 'admin-refresh'))
window.requests = []
return Axios(response.config)
}
} else {
userInfo.removeToken()
if (router.currentRoute.value.name != 'userLogin') {
router.push({ name: 'userLogin' })
return Promise.reject(err)
} else {
response.headers['ba-user-token'] = ''
window.requests.forEach((cb) => cb('', 'user-refresh'))
window.requests = []
return Axios(response.config)
}
}
})
.finally(() => {
window.tokenRefreshing = false
})
} else {
return new Promise((resolve) => {
// 用函数形式将 resolve 存入,等待刷新后再执行
window.requests.push((token: string, type: string) => {
if (type == 'admin-refresh') {
response.headers.batoken = `${token}`
} else {
response.headers['ba-user-token'] = `${token}`
}
resolve(Axios(response.config))
})
})
}
}
if (options.showCodeMessage) {
ElNotification({
type: 'error',
message: response.data.msg,
zIndex: SYSTEM_ZINDEX,
})
}
// 自动跳转到路由name或path
if (response.data.code == 302) {
router.push({ path: response.data.data.routePath ?? '', name: response.data.data.routeName ?? '' })
}
if (response.data.code == 303) {
const isAdminAppFlag = isAdminApp()
let routerPath = isAdminAppFlag ? adminBaseRoute.path : memberCenterBaseRoutePath
// 需要登录,清理 token转到登录页
if (response.data.data.type == 'need login') {
if (isAdminAppFlag) {
adminInfo.removeToken()
} else {
userInfo.removeToken()
}
routerPath += '/login'
}
router.push({ path: routerPath })
}
// code不等于1, 页面then内的具体逻辑就不执行了
return Promise.reject(response.data)
} else if (options.showSuccessMessage && response.data && response.data.code == 1) {
ElNotification({
message: response.data.msg ? response.data.msg : i18n.global.t('axios.Operation successful'),
type: 'success',
zIndex: SYSTEM_ZINDEX,
})
}
}
return options.reductDataFormat ? response.data : response
},
(error) => {
error.config && removePending(error.config)
options.loading && closeLoading(options) // 关闭loading
options.showErrorMessage && httpErrorStatusHandle(error) // 处理错误状态码
return Promise.reject(error) // 错误继续返回给到具体页面
}
)
return Axios(axiosConfig) as T
}
export default createAxios
/**
* 处理异常
* @param {*} error
*/
function httpErrorStatusHandle(error: any) {
// 处理被取消的请求
if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message)
let message = ''
if (error && error.response) {
switch (error.response.status) {
case 302:
message = i18n.global.t('axios.Interface redirected!')
break
case 400:
message = i18n.global.t('axios.Incorrect parameter!')
break
case 401:
message = i18n.global.t('axios.You do not have permission to operate!')
break
case 403:
message = i18n.global.t('axios.You do not have permission to operate!')
break
case 404:
message = i18n.global.t('axios.Error requesting address:') + error.response.config.url
break
case 408:
message = i18n.global.t('axios.Request timed out!')
break
case 409:
message = i18n.global.t('axios.The same data already exists in the system!')
break
case 500:
message = i18n.global.t('axios.Server internal error!')
break
case 501:
message = i18n.global.t('axios.Service not implemented!')
break
case 502:
message = i18n.global.t('axios.Gateway error!')
break
case 503:
message = i18n.global.t('axios.Service unavailable!')
break
case 504:
message = i18n.global.t('axios.The service is temporarily unavailable Please try again later!')
break
case 505:
message = i18n.global.t('axios.HTTP version is not supported!')
break
default:
message = i18n.global.t('axios.Abnormal problem, please contact the website administrator!')
break
}
}
if (error.message.includes('timeout')) message = i18n.global.t('axios.Network request timeout!')
if (error.message.includes('Network'))
message = window.navigator.onLine ? i18n.global.t('axios.Server exception!') : i18n.global.t('axios.You are disconnected!')
ElNotification({
type: 'error',
message,
zIndex: SYSTEM_ZINDEX,
})
}
/**
* 关闭Loading层实例
*/
function closeLoading(options: Options) {
if (options.loading && loadingInstance.count > 0) loadingInstance.count--
if (loadingInstance.count === 0) {
loadingInstance.target.close()
loadingInstance.target = null
}
}
/**
* 储存每个请求的唯一cancel回调, 以此为标识
*/
function addPending(config: AxiosRequestConfig) {
const pendingKey = getPendingKey(config)
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
if (!pendingMap.has(pendingKey)) {
pendingMap.set(pendingKey, cancel)
}
})
}
/**
* 删除重复的请求
*/
function removePending(config: AxiosRequestConfig) {
const pendingKey = getPendingKey(config)
if (pendingMap.has(pendingKey)) {
const cancelToken = pendingMap.get(pendingKey)
cancelToken(pendingKey)
pendingMap.delete(pendingKey)
}
}
/**
* 生成每个请求的唯一key
*/
function getPendingKey(config: AxiosRequestConfig) {
let { data } = config
const { url, method, params, headers } = config
if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象
return [
url,
method,
headers && (headers as anyObj).batoken ? (headers as anyObj).batoken : '',
headers && (headers as anyObj)['ba-user-token'] ? (headers as anyObj)['ba-user-token'] : '',
JSON.stringify(params),
JSON.stringify(data),
].join('&')
}
/**
* 根据请求方法组装请求数据/参数
*/
export function requestPayload(method: Method, data: anyObj) {
if (method == 'GET') {
return {
params: data,
}
} else if (method == 'POST') {
return {
data: data,
}
}
}
interface LoadingInstance {
target: any
count: number
}
interface Options {
// 是否开启取消重复请求, 默认为 true
cancelDuplicateRequest?: boolean
// 是否开启loading层效果, 默认为false
loading?: boolean
// 是否开启简洁的数据结构响应, 默认为true
reductDataFormat?: boolean
// 是否开启接口错误信息展示,默认为true
showErrorMessage?: boolean
// 是否开启code不为1时的信息提示, 默认为true
showCodeMessage?: boolean
// 是否开启code为1时的信息提示, 默认为false
showSuccessMessage?: boolean
// 当前请求使用另外的用户token
anotherToken?: string
}
/*
* 感谢掘金@橙某人提供的思路和分享
* 本axios封装详细解释请参考https://juejin.cn/post/6968630178163458084?share_token=7831c9e0-bea0-469e-8028-b587e13681a8#heading-27
*/

684
web/src/utils/baTable.ts Normal file
View File

@@ -0,0 +1,684 @@
import type { FormInstance, TableColumnCtx } from 'element-plus'
import { ElNotification, dayjs } from 'element-plus'
import { cloneDeep, isArray, isEmpty } from 'lodash-es'
import Sortable from 'sortablejs'
import { reactive } from 'vue'
import { useRoute } from 'vue-router'
import type { baTableApi } from '/@/api/common'
import { findIndexRow } from '/@/components/table'
import { i18n } from '/@/lang/index'
import { auth, getArrayKey } from '/@/utils/common'
/**
* 表格管家类
*/
export default class baTable {
/** baTableApi 类的实例,开发者可重写该类 */
public api: baTableApi
/** 表格状态,属性对应含义请查阅 BaTable 的类型定义 */
public table: BaTable = reactive({
ref: undefined,
pk: 'id',
data: [],
remark: null,
loading: false,
selection: [],
column: [],
total: 0,
filter: {},
dragSortLimitField: 'pid',
acceptQuery: true,
showComSearch: false,
dblClickNotEditColumn: [undefined],
expandAll: false,
extend: {},
})
/** 表单状态,属性对应含义请查阅 BaTableForm 的类型定义 */
public form: BaTableForm = reactive({
ref: undefined,
labelWidth: 160,
operate: '',
operateIds: [],
items: {},
submitLoading: false,
defaultItems: {},
loading: false,
extend: {},
})
/** BaTable 前置处理函数列表(前置埋点) */
public before: BaTableBefore
/** BaTable 后置处理函数列表(后置埋点) */
public after: BaTableAfter
/** 公共搜索数据 */
public comSearch: ComSearch = reactive({
form: {},
fieldData: new Map(),
})
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
this.api = api
this.form = Object.assign(this.form, form)
this.table = Object.assign(this.table, table)
this.before = before
this.after = after
}
/**
* 表格内部鉴权方法
* 此方法在表头或表行组件内部自动调用传递权限节点名add、edit
* 若需自定义表格内部鉴权,重写此方法即可
*/
auth(node: string) {
return auth(node)
}
/**
* 运行前置函数
* @param funName 函数名
* @param args 参数
*/
runBefore(funName: string, args: any = {}) {
if (this.before && this.before[funName] && typeof this.before[funName] == 'function') {
return this.before[funName]!({ ...args }) === false ? false : true
}
return true
}
/**
* 运行后置函数
* @param funName 函数名
* @param args 参数
*/
runAfter(funName: string, args: any = {}) {
if (this.after && this.after[funName] && typeof this.after[funName] == 'function') {
return this.after[funName]!({ ...args }) === false ? false : true
}
return true
}
/**
* 表格数据获取(请求表格对应控制器的查看方法)
* @alias getIndex
*/
getData = () => {
if (this.runBefore('getData') === false) return
if (this.runBefore('getIndex') === false) return
this.table.loading = true
return this.api
.index(this.table.filter)
.then((res) => {
this.table.data = res.data.list
this.table.total = res.data.total
this.table.remark = res.data.remark
this.runAfter('getData', { res })
this.runAfter('getIndex', { res })
})
.catch((err) => {
this.runAfter('getData', { err })
this.runAfter('getIndex', { err })
})
.finally(() => {
this.table.loading = false
})
}
/**
* 删除数据
*/
postDel = (ids: string[]) => {
if (this.runBefore('postDel', { ids }) === false) return
this.api.del(ids).then((res) => {
this.onTableHeaderAction('refresh', { event: 'delete', ids })
this.runAfter('postDel', { res })
})
}
/**
* 获取被编辑行数据
* @alias requestEdit
*/
getEditData = (id: string) => {
if (this.runBefore('getEditData', { id }) === false) return
if (this.runBefore('requestEdit', { id }) === false) return
this.form.loading = true
this.form.items = {}
return this.api
.edit({
[this.table.pk!]: id,
})
.then((res) => {
this.form.items = res.data.row
this.runAfter('getEditData', { res })
this.runAfter('requestEdit', { res })
})
.catch((err) => {
this.toggleForm()
this.runAfter('getEditData', { err })
this.runAfter('requestEdit', { err })
})
.finally(() => {
this.form.loading = false
})
}
/**
* 双击表格
* @param row 行数据
* @param column 列上下文数据
*/
onTableDblclick = (row: TableRow, column: TableColumnCtx<TableRow>) => {
if (!this.table.dblClickNotEditColumn!.includes('all') && !this.table.dblClickNotEditColumn!.includes(column.property)) {
if (this.runBefore('onTableDblclick', { row, column }) === false) return
this.toggleForm('Edit', [row[this.table.pk!]])
this.runAfter('onTableDblclick', { row, column })
}
}
/**
* 打开表单
* @param operate 操作:Add=添加,Edit=编辑
* @param operateIds 被操作项的数组:Add=[],Edit=[1,2,...]
*/
toggleForm = (operate = '', operateIds: string[] = []) => {
if (this.runBefore('toggleForm', { operate, operateIds }) === false) return
if (operate == 'Edit') {
if (!operateIds.length) {
return false
}
this.getEditData(operateIds[0])
} else if (operate == 'Add') {
this.form.items = cloneDeep(this.form.defaultItems)
}
this.form.operate = operate
this.form.operateIds = operateIds
this.runAfter('toggleForm', { operate, operateIds })
}
/**
* 提交表单
* @param formEl 表单组件ref
*/
onSubmit = (formEl?: FormInstance | null) => {
// 当前操作的首字母小写
const operate = this.form.operate!.replace(this.form.operate![0], this.form.operate![0].toLowerCase())
if (this.runBefore('onSubmit', { formEl: formEl, operate: operate, items: this.form.items! }) === false) return
// 表单验证通过后执行的 api 请求操作
const submitCallback = () => {
this.form.submitLoading = true
this.api
.postData(operate, this.form.items!)
.then((res) => {
this.onTableHeaderAction('refresh', { event: 'submit', operate, items: this.form.items })
this.form.operateIds?.shift()
if (this.form.operateIds!.length > 0) {
this.toggleForm('Edit', this.form.operateIds)
} else {
this.toggleForm()
}
this.runAfter('onSubmit', { res })
})
.finally(() => {
this.form.submitLoading = false
})
}
if (formEl) {
this.form.ref = formEl
formEl.validate((valid: boolean) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
}
/**
* 获取表格选择项的主键数组
*/
getSelectionIds() {
const ids: string[] = []
this.table.selection?.forEach((item) => {
ids.push(item[this.table.pk!])
})
return ids
}
/**
* 表格内的事件统一响应
* @param event 事件名称,含义请参考其类型定义
* @param data 携带数据
*/
onTableAction = (event: BaTableActionEventName, data: anyObj) => {
if (this.runBefore('onTableAction', { event, data }) === false) return
const actionFun = new Map([
[
'selection-change',
() => {
this.table.selection = data as TableRow[]
},
],
[
'page-size-change',
() => {
this.table.filter!.limit = data.size
this.onTableHeaderAction('refresh', { event: 'page-size-change', ...data })
},
],
[
'current-page-change',
() => {
this.table.filter!.page = data.page
this.onTableHeaderAction('refresh', { event: 'current-page-change', ...data })
},
],
[
'sort-change',
() => {
let newOrder: string | undefined
if (data.prop && data.order) {
newOrder = data.prop + ',' + data.order
}
if (newOrder != this.table.filter!.order) {
this.table.filter!.order = newOrder
this.onTableHeaderAction('refresh', { event: 'sort-change', ...data })
}
},
],
[
'edit',
() => {
this.toggleForm('Edit', [data.row[this.table.pk!]])
},
],
[
'delete',
() => {
this.postDel([data.row[this.table.pk!]])
},
],
[
'field-change',
() => {
if (data.field && data.field.prop && this.table.data![data.index]) {
this.table.data![data.index][data.field.prop!] = data.value
}
},
],
[
'com-search',
() => {
// 主动触发公共搜索,采用覆盖模式设定请求筛选数据
this.setFilterSearchData(this.getComSearchData(), 'cover')
// 刷新表格
this.onTableHeaderAction('refresh', { event: 'com-search', data: this.table.filter!.search })
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableAction', { event, data })
}
/**
* 表格顶栏按钮事件统一响应
* @param event 事件名称,含义参考其类型定义
* @param data 携带数据
*/
onTableHeaderAction = (event: BaTableHeaderActionEventName, data: anyObj) => {
if (this.runBefore('onTableHeaderAction', { event, data }) === false) return
const actionFun = new Map([
[
'refresh',
() => {
// 刷新表格在大多数情况下无需置空 data但任需防范表格列组件的 :key 不会被更新的问题,比如关联表的数据列
this.table.data = []
this.getData()
},
],
[
'add',
() => {
this.toggleForm('Add')
},
],
[
'edit',
() => {
this.toggleForm('Edit', this.getSelectionIds())
},
],
[
'delete',
() => {
this.postDel(this.getSelectionIds())
},
],
[
'unfold',
() => {
if (!this.table.ref) {
console.warn('Collapse/expand failed because table ref is not defined. Please assign table ref when onMounted')
return
}
this.table.expandAll = data.unfold
this.table.ref.unFoldAll(data.unfold)
},
],
[
'quick-search',
() => {
this.onTableHeaderAction('refresh', { event: 'quick-search', ...data })
},
],
[
'change-show-column',
() => {
const columnKey = getArrayKey(this.table.column, 'prop', data.field)
this.table.column[columnKey].show = data.value
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableHeaderAction', { event, data })
}
/**
* 初始化默认排序
* el-table 的 `default-sort` 在自定义排序时无效
* 此方法只有在表格数据请求结束后执行有效
*/
initSort = () => {
if (this.table.defaultOrder && this.table.defaultOrder.prop) {
if (!this.table.ref) {
console.warn('Failed to initialize default sorting because table ref is not defined. Please assign table ref when onMounted')
return
}
const defaultOrder = this.table.defaultOrder.prop + ',' + this.table.defaultOrder.order
if (this.table.filter && this.table.filter.order != defaultOrder) {
this.table.filter.order = defaultOrder
this.table.ref.getRef()?.sort(this.table.defaultOrder.prop, this.table.defaultOrder.order == 'desc' ? 'descending' : 'ascending')
}
}
}
/**
* 初始化表格拖动排序
*/
dragSort = () => {
const buttonsKey = getArrayKey(this.table.column, 'render', 'buttons')
if (buttonsKey === false) return
const moveButton = getArrayKey(this.table.column[buttonsKey]?.buttons, 'render', 'moveButton')
if (moveButton === false) return
if (!this.table.ref) {
console.warn('Failed to initialize drag sort because table ref is not defined. Please assign table ref when onMounted')
return
}
const el = this.table.ref.getRef()?.$el.querySelector('.el-table__body-wrapper .el-table__body tbody')
const disabledTip = this.table.column[buttonsKey].buttons![moveButton].disabledTip
Sortable.create(el, {
animation: 200,
handle: '.table-row-weigh-sort',
ghostClass: 'ba-table-row',
onStart: () => {
this.table.column[buttonsKey].buttons![moveButton].disabledTip = true
},
onEnd: (evt: Sortable.SortableEvent) => {
this.table.column[buttonsKey].buttons![moveButton].disabledTip = disabledTip
// 目标位置不变
if (evt.oldIndex == evt.newIndex || typeof evt.newIndex == 'undefined' || typeof evt.oldIndex == 'undefined') return
// 找到对应行id
const moveRow = findIndexRow(this.table.data!, evt.oldIndex) as TableRow
const targetRow = findIndexRow(this.table.data!, evt.newIndex) as TableRow
const eventData = {
move: moveRow[this.table.pk!],
target: targetRow[this.table.pk!],
order: this.table.filter?.order,
direction: evt.newIndex > evt.oldIndex ? 'down' : 'up',
}
if (this.table.dragSortLimitField && moveRow[this.table.dragSortLimitField] != targetRow[this.table.dragSortLimitField]) {
this.onTableHeaderAction('refresh', { event: 'sort', ...eventData })
ElNotification({
type: 'error',
message: i18n.global.t('utils.The moving position is beyond the movable range!'),
})
return
}
this.api.sortable(eventData).finally(() => {
this.onTableHeaderAction('refresh', { event: 'sort', ...eventData })
})
},
})
}
/**
* 表格初始化
*/
mount = () => {
if (this.runBefore('mount') === false) return
// 记录表格的路由路径
const route = useRoute()
this.table.routePath = route.fullPath
// 按需初始化公共搜索表单数据和字段Map
if (this.comSearch.fieldData.size === 0) {
this.initComSearch()
}
if (this.table.acceptQuery && !isEmpty(route.query)) {
// 根据当前 URL 的 query 初始化公共搜索默认值
this.setComSearchData(route.query)
// 获取公共搜索数据合并至表格筛选条件
this.setFilterSearchData(this.getComSearchData(), 'merge')
}
}
/**
* 公共搜索初始化
*/
initComSearch = () => {
const form: anyObj = {}
const field = this.table.column
if (field.length <= 0) return
for (const key in field) {
// 关闭搜索的字段
if (field[key].operator === false) continue
// 取默认操作符号
if (typeof field[key].operator == 'undefined') {
field[key].operator = 'eq'
}
// 公共搜索表单字段初始化
const prop = field[key].prop
if (prop) {
if (field[key].operator == 'RANGE' || field[key].operator == 'NOT RANGE') {
// 范围查询
form[prop] = ''
form[prop + '-start'] = ''
form[prop + '-end'] = ''
} else if (field[key].operator == 'NULL' || field[key].operator == 'NOT NULL') {
// 复选框
form[prop] = false
} else {
// 普通文本框
form[prop] = ''
}
// 初始化字段的公共搜索数据
this.comSearch.fieldData.set(prop, {
operator: field[key].operator,
render: field[key].render,
comSearchRender: field[key].comSearchRender,
})
}
}
this.comSearch.form = Object.assign(this.comSearch.form, form)
}
/**
* 设置公共搜索表单数据
*/
setComSearchData = (query: anyObj) => {
// 必需已经完成公共搜索数据的初始化
if (this.comSearch.fieldData.size === 0) {
this.initComSearch()
}
for (const key in this.table.column) {
const prop = this.table.column[key].prop
if (prop && typeof query[prop] !== 'undefined') {
const queryProp = query[prop] ?? ''
if (this.table.column[key].operator == 'RANGE' || this.table.column[key].operator == 'NOT RANGE') {
const range = queryProp.split(',')
if (this.table.column[key].render == 'datetime' || this.table.column[key].comSearchRender == 'date') {
if (range && range.length >= 2) {
const rangeDayJs = [dayjs(range[0]), dayjs(range[1])]
if (rangeDayJs[0].isValid() && rangeDayJs[1].isValid()) {
if (this.table.column[key].comSearchRender == 'date') {
this.comSearch.form[prop] = [rangeDayJs[0].format('YYYY-MM-DD'), rangeDayJs[1].format('YYYY-MM-DD')]
} else {
this.comSearch.form[prop] = [
rangeDayJs[0].format('YYYY-MM-DD HH:mm:ss'),
rangeDayJs[1].format('YYYY-MM-DD HH:mm:ss'),
]
}
}
}
} else if (this.table.column[key].comSearchRender == 'time') {
if (range && range.length >= 2) {
this.comSearch.form[prop] = [range[0], range[1]]
}
} else {
this.comSearch.form[prop + '-start'] = range[0] ?? ''
this.comSearch.form[prop + '-end'] = range[1] ?? ''
}
} else if (this.table.column[key].operator == 'NULL' || this.table.column[key].operator == 'NOT NULL') {
this.comSearch.form[prop] = queryProp ? true : false
} else if (this.table.column[key].render == 'datetime' || this.table.column[key].comSearchRender == 'date') {
const propDayJs = dayjs(queryProp)
if (propDayJs.isValid()) {
this.comSearch.form[prop] = propDayJs.format(
this.table.column[key].comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'
)
}
} else {
this.comSearch.form[prop] = queryProp
}
}
}
}
/**
* 获取公共搜索表单数据
*/
getComSearchData = () => {
// 必需已经完成公共搜索数据的初始化
if (this.comSearch.fieldData.size === 0) {
this.initComSearch()
}
const comSearchData: ComSearchData[] = []
for (const key in this.comSearch.form) {
if (!this.comSearch.fieldData.has(key)) continue
let val = null
const fieldDataTemp = this.comSearch.fieldData.get(key)
if (
(fieldDataTemp.render == 'datetime' || ['datetime', 'date', 'time'].includes(fieldDataTemp.comSearchRender)) &&
(fieldDataTemp.operator == 'RANGE' || fieldDataTemp.operator == 'NOT RANGE')
) {
if (this.comSearch.form[key] && this.comSearch.form[key].length >= 2) {
// 日期范围
if (fieldDataTemp.comSearchRender == 'date') {
val = this.comSearch.form[key][0] + ' 00:00:00' + ',' + this.comSearch.form[key][1] + ' 23:59:59'
} else {
// 时间范围、时间日期范围
val = this.comSearch.form[key][0] + ',' + this.comSearch.form[key][1]
}
}
} else if (fieldDataTemp.operator == 'RANGE' || fieldDataTemp.operator == 'NOT RANGE') {
// 普通的范围筛选,公共搜索初始化时已准备好 start 和 end 字段
if (!this.comSearch.form[key + '-start'] && !this.comSearch.form[key + '-end']) {
continue
}
val = this.comSearch.form[key + '-start'] + ',' + this.comSearch.form[key + '-end']
} else if (this.comSearch.form[key]) {
val = this.comSearch.form[key]
}
if (val === null) continue
if (isArray(val) && !val.length) continue
comSearchData.push({
field: key,
val: val,
operator: fieldDataTemp.operator,
render: fieldDataTemp.render,
})
}
return comSearchData
}
/**
* 设置 getData 请求时的过滤条件(搜索数据)
* @param search 新的搜索数据
* @param mode 模式:cover=覆盖到已有搜索数据,merge=合并到已有搜索数据
*/
setFilterSearchData = (search: ComSearchData[], mode: 'cover' | 'merge' = 'merge') => {
if (mode == 'cover' || !this.table.filter?.search) {
this.table.filter!.search = search
} else {
const merged = this.table.filter!.search.concat(search)
const fieldMap = new Map<string, ComSearchData>()
merged.forEach((item) => {
fieldMap.set(item.field, item)
})
this.table.filter!.search = Array.from(fieldMap.values())
}
}
// 方法别名
getIndex = this.getData
requestEdit = this.getEditData
}

37
web/src/utils/build.ts Normal file
View File

@@ -0,0 +1,37 @@
import { readdirSync, writeFile } from 'fs'
import { trimEnd } from 'lodash-es'
function getFileNames(dir: string) {
const dirents = readdirSync(dir, {
withFileTypes: true,
})
const fileNames: string[] = []
for (const dirent of dirents) {
if (!dirent.isDirectory()) fileNames.push(dirent.name.replace('.vue', ''))
}
return fileNames
}
/**
* 生成 ./types/tableRenderer.d.ts 文件
*/
const buildTableRendererType = () => {
let tableRenderer = getFileNames('./src/components/table/fieldRender/')
// 增加 slot去除 default
tableRenderer.push('slot')
tableRenderer = tableRenderer.filter((item) => item !== 'default')
let tableRendererContent =
'/** 可用的表格单元格渲染器,以 ./src/components/table/fieldRender/ 目录中的文件名自动生成 */\ntype TableRenderer =\n | '
for (const key in tableRenderer) {
tableRendererContent += `'${tableRenderer[key]}'\n | `
}
tableRendererContent = trimEnd(tableRendererContent, ' | ')
writeFile('./types/tableRenderer.d.ts', tableRendererContent, 'utf-8', (err) => {
if (err) throw err
})
}
buildTableRendererType()

404
web/src/utils/common.ts Normal file
View File

@@ -0,0 +1,404 @@
import * as elIcons from '@element-plus/icons-vue'
import { useTitle } from '@vueuse/core'
import type { FormInstance } from 'element-plus'
import { isArray, isNull, trim, trimStart } from 'lodash-es'
import type { App } from 'vue'
import { nextTick } from 'vue'
import type { TranslateOptions } from 'vue-i18n'
import { i18n } from '../lang'
import { useSiteConfig } from '../stores/siteConfig'
import { getUrl } from './axios'
import Icon from '/@/components/icon/index.vue'
import router from '/@/router/index'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useNavTabs } from '/@/stores/navTabs'
export function registerIcons(app: App) {
/*
* 全局注册 Icon
* 使用方式: <Icon name="name" size="size" color="color" />
* 详见<待完善>
*/
app.component('Icon', Icon)
/*
* 全局注册element Plus的icon
*/
const icons = elIcons as any
for (const i in icons) {
app.component(`el-icon-${icons[i].name}`, icons[i])
}
}
/**
* 加载网络css文件
* @param url css资源url
*/
export function loadCss(url: string): void {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
link.crossOrigin = 'anonymous'
document.getElementsByTagName('head')[0].appendChild(link)
}
/**
* 加载网络js文件
* @param url js资源url
*/
export function loadJs(url: string): void {
const link = document.createElement('script')
link.src = url
document.body.appendChild(link)
}
/**
* 根据路由 meta.title 设置浏览器标题
*/
export function setTitleFromRoute() {
nextTick(() => {
if (typeof router.currentRoute.value.meta.title != 'string') {
return
}
const webTitle = i18n.global.te(router.currentRoute.value.meta.title)
? i18n.global.t(router.currentRoute.value.meta.title)
: router.currentRoute.value.meta.title
const title = useTitle()
const siteConfig = useSiteConfig()
title.value = `${webTitle}${siteConfig.siteName ? ' - ' + siteConfig.siteName : ''}`
})
}
/**
* 设置浏览器标题
* @param webTitle 新的标题
*/
export function setTitle(webTitle: string) {
if (router.currentRoute.value) {
router.currentRoute.value.meta.title = webTitle
}
nextTick(() => {
const title = useTitle()
const siteConfig = useSiteConfig()
title.value = `${webTitle}${siteConfig.siteName ? ' - ' + siteConfig.siteName : ''}`
})
}
/**
* 是否是外部链接
* @param path
*/
export function isExternal(path: string): boolean {
return /^(https?|ftp|mailto|tel):/.test(path)
}
/**
* 全局防抖
* 与 _.debounce 不同的是,间隔期间如果再次传递不同的函数,两个函数也只会执行一次
* @param fn 执行函数
* @param ms 间隔毫秒数
*/
export const debounce = (fn: Function, ms: number) => {
return (...args: any[]) => {
if (window.lazy) {
clearTimeout(window.lazy)
}
window.lazy = window.setTimeout(() => {
fn(...args)
}, ms)
}
}
/**
* 根据pk字段的值从数组中获取key
* @param arr
* @param pk
* @param value
*/
export const getArrayKey = (arr: any, pk: string, value: any): any => {
for (const key in arr) {
if (arr[key][pk] == value) {
return key
}
}
return false
}
/**
* 表单重置
* @param formEl
*/
export const onResetForm = (formEl?: FormInstance | null) => {
typeof formEl?.resetFields == 'function' && formEl.resetFields()
}
/**
* 将数据构建为ElTree的data {label:'', children: []}
* @param data
*/
export const buildJsonToElTreeData = (data: any): ElTreeData[] => {
if (typeof data == 'object') {
const childrens = []
for (const key in data) {
childrens.push({
label: key + ': ' + data[key],
children: buildJsonToElTreeData(data[key]),
})
}
return childrens
} else {
return []
}
}
/**
* 是否在后台应用内
* @param path 不传递则通过当前路由 path 检查
*/
export const isAdminApp = (path = '') => {
const regex = new RegExp(`^${adminBaseRoutePath}`)
if (path) {
return regex.test(path)
}
if (regex.test(getCurrentRoutePath())) {
return true
}
return false
}
/**
* 是否为手机设备
*/
export const isMobile = () => {
return !!navigator.userAgent.match(
/android|webos|ip(hone|ad|od)|opera (mini|mobi|tablet)|iemobile|windows.+(phone|touch)|mobile|fennec|kindle (Fire)|Silk|maemo|blackberry|playbook|bb10\; (touch|kbd)|Symbian(OS)|Ubuntu Touch/i
)
}
/**
* 从一个文件路径中获取文件名
* @param path 文件路径
*/
export const getFileNameFromPath = (path: string) => {
const paths = path.split('/')
return paths[paths.length - 1]
}
export function auth(node: string): boolean
export function auth(node: { name: string; subNodeName?: string }): boolean
/**
* 鉴权
* 提供 string 将根据当前路由 path 自动拼接和鉴权,还可以提供路由的 name 对象进行鉴权
* @param node
*/
export function auth(node: string | { name: string; subNodeName?: string }) {
const store = isAdminApp() ? useNavTabs() : useMemberCenter()
if (typeof node === 'string') {
const path = getCurrentRoutePath()
if (store.state.authNode.has(path)) {
const subNodeName = path + (path == '/' ? '' : '/') + node
if (store.state.authNode.get(path)!.some((v: string) => v == subNodeName)) {
return true
}
}
} else {
// 节点列表中没有找到 name
if (!node.name || !store.state.authNode.has(node.name)) return false
// 无需继续检查子节点或未找到子节点
if (!node.subNodeName || store.state.authNode.get(node.name)?.includes(node.subNodeName)) return true
}
return false
}
/**
* 获取资源完整地址
* @param relativeUrl 资源相对地址
* @param domain 指定域名
*/
export const fullUrl = (relativeUrl: string, domain = '') => {
const siteConfig = useSiteConfig()
if (!domain) {
domain = siteConfig.cdnUrl ? siteConfig.cdnUrl : getUrl()
}
if (!relativeUrl) return domain
const regUrl = new RegExp(/^http(s)?:\/\//)
const regexImg = new RegExp(/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i)
if (!domain || regUrl.test(relativeUrl) || regexImg.test(relativeUrl)) {
return relativeUrl
}
let url = domain + relativeUrl
if (domain === siteConfig.cdnUrl && siteConfig.cdnUrlParams) {
const separator = url.includes('?') ? '&' : '?'
url += separator + siteConfig.cdnUrlParams
}
return url
}
/**
* 获取路由 path
*/
export const getCurrentRoutePath = () => {
let path = router.currentRoute.value.path
if (path == '/') path = trimStart(window.location.hash, '#')
if (path.indexOf('?') !== -1) path = path.replace(/\?.*/, '')
return path
}
/**
* 获取根据当前路由路径动态加载的语言翻译
* @param key 无需语言路径的翻译key亦可使用完整路径
* @param named — 命名插值的值
* @param options — 其他翻译选项
* @returns — Translated message
*/
export const __ = (key: string, named?: Record<string, unknown>, options?: TranslateOptions<string>) => {
let langPath = ''
const path = getCurrentRoutePath()
if (isAdminApp()) {
langPath = path.slice(path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
langPath = trim(langPath, '/').replaceAll('/', '.')
} else {
langPath = trim(path, '/').replaceAll('/', '.')
}
langPath = langPath ? langPath + '.' + key : key
return i18n.global.te(langPath)
? i18n.global.t(langPath, named ?? {}, options ? options : {})
: i18n.global.t(key, named ?? {}, options ? options : {})
}
/**
* 文件类型效验,前端根据服务端配置进行初步检查
* @param fileName 文件名
* @param fileType 文件 mimeType不一定存在
*/
export const checkFileMimetype = (fileName: string, fileType: string) => {
if (!fileName) return false
const siteConfig = useSiteConfig()
const allowedSuffixes = isArray(siteConfig.upload.allowedSuffixes)
? siteConfig.upload.allowedSuffixes
: siteConfig.upload.allowedSuffixes.toLowerCase().split(',')
const allowedMimeTypes = isArray(siteConfig.upload.allowedMimeTypes)
? siteConfig.upload.allowedMimeTypes
: siteConfig.upload.allowedMimeTypes.toLowerCase().split(',')
const fileSuffix = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
if (allowedSuffixes.includes(fileSuffix) || allowedSuffixes.includes('.' + fileSuffix)) {
return true
}
if (fileType && allowedMimeTypes.includes(fileType)) {
return true
}
return false
}
/**
* 获取一组资源的完整地址
* @param relativeUrls 资源相对地址
* @param domain 指定域名
*/
export const arrayFullUrl = (relativeUrls: string | string[], domain = '') => {
if (typeof relativeUrls === 'string') {
relativeUrls = relativeUrls == '' ? [] : relativeUrls.split(',')
}
for (const key in relativeUrls) {
relativeUrls[key] = fullUrl(relativeUrls[key], domain)
}
return relativeUrls
}
/**
* 格式化时间戳
* @param dateTime 时间戳,默认使用当前时间戳
* @param fmt 格式化方式默认yyyy-mm-dd hh:MM:ss
*/
export const timeFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
if (dateTime == 'none') {
return i18n.global.t('None')
}
if (isNull(dateTime)) {
dateTime = Number(new Date())
}
/**
* 1. 秒级时间戳10位需要转换为毫秒级才能供 Date 对象直接使用
* 2. yyyy-mm-dd 也是10位使用 isFinite 进行排除
*/
if (String(dateTime).length === 10 && isFinite(Number(dateTime))) {
dateTime = +dateTime * 1000
}
let date = new Date(dateTime)
if (isNaN(date.getTime())) {
date = new Date(Number(dateTime))
if (isNaN(date.getTime())) {
return 'Invalid Date'
}
}
let ret
const opt: anyObj = {
'y+': date.getFullYear().toString(), // 年
'm+': (date.getMonth() + 1).toString(), // 月
'd+': date.getDate().toString(), // 日
'h+': date.getHours().toString(), // 时
'M+': date.getMinutes().toString(), // 分
's+': date.getSeconds().toString(), // 秒
}
for (const k in opt) {
ret = new RegExp('(' + k + ')').exec(fmt)
if (ret) {
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
}
}
return fmt
}
/**
* 字符串补位
*/
const padStart = (str: string, maxLength: number, fillString = ' ') => {
if (str.length >= maxLength) return str
const fillLength = maxLength - str.length
let times = Math.ceil(fillLength / fillString.length)
while ((times >>= 1)) {
fillString += fillString
if (times === 1) {
fillString += fillString
}
}
return fillString.slice(0, fillLength) + str
}
/**
* 根据当前时间生成问候语
*/
export const getGreet = () => {
const now = new Date()
const hour = now.getHours()
let greet = ''
if (hour < 5) {
greet = i18n.global.t('utils.Late at night, pay attention to your body!')
} else if (hour < 9) {
greet = i18n.global.t('utils.good morning!') + i18n.global.t('utils.welcome back')
} else if (hour < 12) {
greet = i18n.global.t('utils.Good morning!') + i18n.global.t('utils.welcome back')
} else if (hour < 14) {
greet = i18n.global.t('utils.Good noon!') + i18n.global.t('utils.welcome back')
} else if (hour < 18) {
greet = i18n.global.t('utils.good afternoon') + i18n.global.t('utils.welcome back')
} else if (hour < 24) {
greet = i18n.global.t('utils.Good evening') + i18n.global.t('utils.welcome back')
} else {
greet = i18n.global.t('utils.Hello!') + i18n.global.t('utils.welcome back')
}
return greet
}

224
web/src/utils/directives.ts Normal file
View File

@@ -0,0 +1,224 @@
import type { App } from 'vue'
import { nextTick } from 'vue'
import horizontalScroll from '/@/utils/horizontalScroll'
import { useEventListener } from '@vueuse/core'
import { isString } from 'lodash-es'
import { auth } from '/@/utils/common'
export function directives(app: App) {
// 鉴权指令
authDirective(app)
// 拖动指令
dragDirective(app)
// 缩放指令
zoomDirective(app)
// 点击后自动失焦指令
blurDirective(app)
// 表格横向拖动指令
tableLateralDragDirective(app)
}
/**
* 页面按钮鉴权指令
* @description v-auth="'name'"name可以为index,add,edit,del,...
*/
function authDirective(app: App) {
app.directive('auth', {
mounted(el, binding) {
if (!binding.value) return false
if (!auth(binding.value)) el.parentNode.removeChild(el)
},
})
}
/**
* 表格横向滚动指令
* @description v-table-lateral-drag
*/
function tableLateralDragDirective(app: App) {
app.directive('tableLateralDrag', {
created(el) {
new horizontalScroll(el.querySelector('.el-table__body-wrapper .el-scrollbar .el-scrollbar__wrap'))
},
})
}
/**
* 点击后自动失焦指令
* @description v-blur
*/
function blurDirective(app: App) {
app.directive('blur', {
mounted(el) {
useEventListener(el, 'focus', () => el.blur())
},
})
}
/**
* el-dialog 的缩放指令
* 可以传递字符串和数组
* 当为字符串时传递dialog的class即可实际被缩放的元素为'.el-dialog__body'
* 当为数组时,参数一为句柄,参数二为实际被缩放的元素
* @description v-zoom="'.handle-class-name'"
* @description v-zoom="['.handle-class-name', '.zoom-dom-class-name', 句柄元素高度是否跟随缩放默认false句柄元素宽度是否跟随缩放默认true]"
*/
function zoomDirective(app: App) {
app.directive('zoom', {
mounted(el, binding) {
if (!binding.value) return false
const zoomDomBindData = isString(binding.value) ? [binding.value, '.el-dialog__body', false, true] : binding.value
zoomDomBindData[1] = zoomDomBindData[1] ? zoomDomBindData[1] : '.el-dialog__body'
zoomDomBindData[2] = typeof zoomDomBindData[2] == 'undefined' ? false : zoomDomBindData[2]
zoomDomBindData[3] = typeof zoomDomBindData[3] == 'undefined' ? true : zoomDomBindData[3]
nextTick(() => {
const zoomDom = document.querySelector(zoomDomBindData[1]) as HTMLElement // 实际被缩放的元素
const zoomDomBox = document.querySelector(zoomDomBindData[0]) as HTMLElement // 动态添加缩放句柄的元素
const zoomHandleEl = document.createElement('div') // 缩放句柄
zoomHandleEl.className = 'zoom-handle'
zoomHandleEl.onmouseenter = () => {
zoomHandleEl.onmousedown = (e: MouseEvent) => {
const x = e.clientX
const y = e.clientY
const zoomDomWidth = zoomDom.offsetWidth
const zoomDomHeight = zoomDom.offsetHeight
const zoomDomBoxWidth = zoomDomBox.offsetWidth
const zoomDomBoxHeight = zoomDomBox.offsetHeight
document.onmousemove = (e: MouseEvent) => {
e.preventDefault() // 移动时禁用默认事件
const w = zoomDomWidth + (e.clientX - x) * 2
const h = zoomDomHeight + (e.clientY - y)
zoomDom.style.width = `${w}px`
zoomDom.style.height = `${h}px`
if (zoomDomBindData[2]) {
const boxH = zoomDomBoxHeight + (e.clientY - y)
zoomDomBox.style.height = `${boxH}px`
}
if (zoomDomBindData[3]) {
const boxW = zoomDomBoxWidth + (e.clientX - x) * 2
zoomDomBox.style.width = `${boxW}px`
}
}
document.onmouseup = function () {
document.onmousemove = null
document.onmouseup = null
}
}
}
zoomDomBox.appendChild(zoomHandleEl)
})
},
})
}
/**
* 拖动指令
* @description v-drag="[domEl,handleEl]"
* @description domEl=被拖动的元素handleEl=在此元素内可以拖动`dom`
*/
interface downReturn {
[key: string]: number
}
function dragDirective(app: App) {
app.directive('drag', {
mounted(el, binding) {
if (!binding.value) return false
const dragDom = document.querySelector(binding.value[0]) as HTMLElement
const dragHandle = document.querySelector(binding.value[1]) as HTMLElement
if (!dragHandle || !dragDom) {
return false
}
function down(e: MouseEvent | TouchEvent, type: string): downReturn {
// 鼠标按下,记录鼠标位置
const disX = type === 'pc' ? (e as MouseEvent).clientX : (e as TouchEvent).touches[0].clientX
const disY = type === 'pc' ? (e as MouseEvent).clientY : (e as TouchEvent).touches[0].clientY
// body宽度
const screenWidth = document.body.clientWidth
const screenHeight = document.body.clientHeight || document.documentElement.clientHeight
// 被拖动元素宽度
const dragDomWidth = dragDom.offsetWidth
// 被拖动元素高度
const dragDomheight = dragDom.offsetHeight
// 拖动限位
const minDragDomLeft = dragDom.offsetLeft
const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth
const minDragDomTop = dragDom.offsetTop
const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight
// 获取到的值带px 正则匹配替换
let styL: string | number = getComputedStyle(dragDom).left
let styT: string | number = getComputedStyle(dragDom).top
styL = +styL.replace(/\px/g, '')
styT = +styT.replace(/\px/g, '')
return {
disX,
disY,
minDragDomLeft,
maxDragDomLeft,
minDragDomTop,
maxDragDomTop,
styL,
styT,
}
}
function move(e: MouseEvent | TouchEvent, type: string, obj: downReturn) {
const { disX, disY, minDragDomLeft, maxDragDomLeft, minDragDomTop, maxDragDomTop, styL, styT } = obj
// 通过事件委托,计算移动的距离
let left = type === 'pc' ? (e as MouseEvent).clientX - disX : (e as TouchEvent).touches[0].clientX - disX
let top = type === 'pc' ? (e as MouseEvent).clientY - disY : (e as TouchEvent).touches[0].clientY - disY
// 边界处理
if (-left > minDragDomLeft) {
left = -minDragDomLeft
} else if (left > maxDragDomLeft) {
left = maxDragDomLeft
}
if (-top > minDragDomTop) {
top = -minDragDomTop
} else if (top > maxDragDomTop) {
top = maxDragDomTop
}
// 移动当前元素
dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`
}
dragHandle.onmouseover = () => (dragHandle.style.cursor = `move`)
dragHandle.onmousedown = (e) => {
const obj = down(e, 'pc')
document.onmousemove = (e) => {
move(e, 'pc', obj)
}
document.onmouseup = () => {
document.onmousemove = null
document.onmouseup = null
}
}
dragHandle.ontouchstart = (e) => {
const obj = down(e, 'app')
document.ontouchmove = (e) => {
move(e, 'app', obj)
}
document.ontouchend = () => {
document.ontouchmove = null
document.ontouchend = null
}
}
},
})
}

View File

@@ -0,0 +1,33 @@
/**
* 横向滚动条
*/
export default class horizontalScroll {
private el: HTMLElement
constructor(nativeElement: HTMLElement) {
this.el = nativeElement
this.handleWheelEvent()
}
handleWheelEvent() {
let wheel = ''
if ('onmousewheel' in this.el) {
wheel = 'mousewheel'
} else if ('onwheel' in this.el) {
wheel = 'wheel'
} else if ('attachEvent' in window) {
wheel = 'onmousewheel'
} else {
wheel = 'DOMMouseScroll'
}
this.el['addEventListener'](wheel, this.scroll, { passive: true })
}
scroll = (event: any) => {
if (this.el.clientWidth >= this.el.scrollWidth) {
return
}
this.el.scrollLeft += event.deltaY ? event.deltaY : event.detail && event.detail !== 0 ? event.detail : -event.wheelDelta
}
}

170
web/src/utils/iconfont.ts Normal file
View File

@@ -0,0 +1,170 @@
import { nextTick } from 'vue'
import { loadCss, loadJs } from './common'
import * as elIcons from '@element-plus/icons-vue'
import { getUrl } from '/@/utils/axios'
/**
* 动态加载的 css 和 js
*/
const cssUrls: Array<string> = ['//at.alicdn.com/t/font_3135462_5axiswmtpj.css']
const jsUrls: Array<string> = []
/*
* 加载预设的字体图标资源
*/
export default function init() {
if (cssUrls.length > 0) {
cssUrls.map((v) => {
loadCss(v)
})
}
if (jsUrls.length > 0) {
jsUrls.map((v) => {
loadJs(v)
})
}
}
/*
* 获取当前页面中从指定域名加载到的样式表内容
* 样式表未载入前无法获取
*/
function getStylesFromDomain(domain: string) {
const sheets = []
const styles: StyleSheetList = document.styleSheets
for (const key in styles) {
if (styles[key].href && (styles[key].href as string).indexOf(domain) > -1) {
sheets.push(styles[key])
}
}
return sheets
}
/**
* 获取Vite开发服务/编译后的样式表内容
* @param devID style 标签的 viteDevId只开发服务有
*/
function getStylesFromVite(devId: string) {
const sheets = []
const styles: StyleSheetList = document.styleSheets
if (import.meta.env.MODE == 'production') {
const url = getUrl()
for (const key in styles) {
if (styles[key].href && styles[key].href?.indexOf(url) === 0) {
sheets.push(styles[key])
}
}
return sheets
}
for (const key in styles) {
const ownerNode = styles[key].ownerNode as HTMLMapElement
if (ownerNode && ownerNode.dataset?.viteDevId && ownerNode.dataset.viteDevId!.indexOf(devId) > -1) {
sheets.push(styles[key])
}
}
return sheets
}
/*
* 获取本地自带的图标
* /src/assets/icons文件夹内的svg文件
*/
export function getLocalIconfontNames() {
return new Promise<string[]>((resolve, reject) => {
nextTick(() => {
let iconfonts: string[] = []
const svgEl = document.getElementById('local-icon')
if (svgEl?.dataset.iconName) {
iconfonts = (svgEl?.dataset.iconName as string).split(',')
}
if (iconfonts.length > 0) {
resolve(iconfonts)
} else {
reject('No Local Icons')
}
})
})
}
/*
* 获取 Awesome-Iconfont 的 name 列表
*/
export function getAwesomeIconfontNames() {
return new Promise<string[]>((resolve, reject) => {
nextTick(() => {
const iconfonts = []
const sheets = getStylesFromVite('font-awesome.min.css')
for (const key in sheets) {
const rules: any = sheets[key].cssRules
for (const k in rules) {
if (!rules[k].selectorText || rules[k].selectorText.indexOf('.fa-') !== 0) {
continue
}
if (/^\.fa-(.*)::before$/g.test(rules[k].selectorText)) {
if (rules[k].selectorText.indexOf(', ') > -1) {
const iconNames = rules[k].selectorText.split(', ')
iconfonts.push(`${iconNames[0].substring(1, iconNames[0].length).replace(/\:\:before/gi, '')}`)
} else {
iconfonts.push(`${rules[k].selectorText.substring(1, rules[k].selectorText.length).replace(/\:\:before/gi, '')}`)
}
}
}
}
if (iconfonts.length > 0) {
resolve(iconfonts)
} else {
reject('No AwesomeIcon style sheet')
}
})
})
}
/*
* 获取 Iconfont 的 name 列表
*/
export function getIconfontNames() {
return new Promise<string[]>((resolve, reject) => {
nextTick(() => {
const iconfonts = []
const sheets = getStylesFromDomain('at.alicdn.com')
for (const key in sheets) {
const rules: any = sheets[key].cssRules
for (const k in rules) {
if (rules[k].selectorText && /^\.icon-(.*)::before$/g.test(rules[k].selectorText)) {
iconfonts.push(`${rules[k].selectorText.substring(1, rules[k].selectorText.length).replace(/\:\:before/gi, '')}`)
}
}
}
if (iconfonts.length > 0) {
resolve(iconfonts)
} else {
reject('No Iconfont style sheet')
}
})
})
}
/*
* 获取element plus 自带的图标
*/
export function getElementPlusIconfontNames() {
return new Promise<string[]>((resolve, reject) => {
nextTick(() => {
const iconfonts = []
const icons = elIcons as any
for (const i in icons) {
iconfonts.push(`el-icon-${icons[i].name}`)
}
if (iconfonts.length > 0) {
resolve(iconfonts)
} else {
reject('No ElementPlus Icons')
}
})
})
}

60
web/src/utils/layout.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { CSSProperties } from 'vue'
import { useConfig } from '/@/stores/config'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useNavTabs } from '/@/stores/navTabs'
import { isAdminApp } from '/@/utils/common'
/**
* 管理员后台各个布局顶栏高度
*/
export const adminLayoutHeaderBarHeight = {
Default: 70,
Classic: 50,
Streamline: 60,
Double: 60,
}
/**
* 前台会员中心各个布局顶栏高度
*/
export const userLayoutHeaderBarHeight = {
Default: 60,
Disable: 60,
}
/**
* main高度
* @param extra main高度额外减去的px数,可以实现隐藏原有的滚动条
* @returns CSSProperties
*/
export function mainHeight(extra = 0): CSSProperties {
let height = extra
if (isAdminApp()) {
const config = useConfig()
const navTabs = useNavTabs()
if (!navTabs.state.tabFullScreen) {
height += adminLayoutHeaderBarHeight[config.layout.layoutMode as keyof typeof adminLayoutHeaderBarHeight]
}
} else {
const memberCenter = useMemberCenter()
height += userLayoutHeaderBarHeight[memberCenter.state.layoutMode as keyof typeof userLayoutHeaderBarHeight]
}
return {
height: 'calc(100vh - ' + height.toString() + 'px)',
}
}
/**
* 设置导航栏宽度
* @returns
*/
export function setNavTabsWidth() {
const navTabs = document.querySelector('.nav-tabs') as HTMLElement
if (!navTabs) {
return
}
const navBar = document.querySelector('.nav-bar') as HTMLElement
const navMenus = document.querySelector('.nav-menus') as HTMLElement
const minWidth = navBar.offsetWidth - (navMenus.offsetWidth + 20)
navTabs.style.width = minWidth.toString() + 'px'
}

34
web/src/utils/loading.ts Normal file
View File

@@ -0,0 +1,34 @@
import { nextTick } from 'vue'
import '/@/styles/loading.scss'
export const loading = {
show: () => {
const bodys: Element = document.body
const div = document.createElement('div')
div.className = 'block-loading'
div.innerHTML = `
<div class="block-loading-box">
<div class="block-loading-box-warp">
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
<div class="block-loading-box-item"></div>
</div>
</div>
`
bodys.insertBefore(div, bodys.childNodes[0])
},
hide: () => {
nextTick(() => {
setTimeout(() => {
const el = document.querySelector('.block-loading')
el && el.parentNode?.removeChild(el)
}, 1000)
})
},
}

104
web/src/utils/pageBubble.ts Normal file
View File

@@ -0,0 +1,104 @@
// 页面气泡效果
const bubble: {
width: number
height: number
bubbleEl: any
canvas: any
ctx: any
circles: any[]
animate: boolean
requestId: any
} = {
width: 0,
height: 0,
bubbleEl: null,
canvas: null,
ctx: {},
circles: [],
animate: true,
requestId: null,
}
export const init = function (): void {
bubble.width = window.innerWidth
bubble.height = window.innerHeight
bubble.bubbleEl = document.getElementById('bubble')
bubble.bubbleEl.style.height = bubble.height + 'px'
bubble.canvas = document.getElementById('bubble-canvas')
bubble.canvas.width = bubble.width
bubble.canvas.height = bubble.height
bubble.ctx = bubble.canvas.getContext('2d')
// create particles
bubble.circles = []
for (let x = 0; x < bubble.width * 0.5; x++) {
const c = new Circle()
bubble.circles.push(c)
}
animate()
addListeners()
}
function scrollCheck() {
bubble.animate = document.body.scrollTop > bubble.height ? false : true
}
function resize() {
bubble.width = window.innerWidth
bubble.height = window.innerHeight
bubble.bubbleEl.style.height = bubble.height + 'px'
bubble.canvas.width = bubble.width
bubble.canvas.height = bubble.height
}
function animate() {
if (bubble.animate) {
bubble.ctx.clearRect(0, 0, bubble.width, bubble.height)
for (const i in bubble.circles) {
bubble.circles[i].draw()
}
}
bubble.requestId = requestAnimationFrame(animate)
}
class Circle {
pos: {
x: number
y: number
}
alpha: number
scale: number
velocity: number
draw: () => void
constructor() {
this.pos = {
x: Math.random() * bubble.width,
y: bubble.height + Math.random() * 100,
}
this.alpha = 0.1 + Math.random() * 0.3
this.scale = 0.1 + Math.random() * 0.3
this.velocity = Math.random()
this.draw = function () {
this.pos.y -= this.velocity
this.alpha -= 0.0005
bubble.ctx.beginPath()
bubble.ctx.arc(this.pos.x, this.pos.y, this.scale * 10, 0, 2 * Math.PI, false)
bubble.ctx.fillStyle = 'rgba(255,255,255,' + this.alpha + ')'
bubble.ctx.fill()
}
}
}
function addListeners() {
window.addEventListener('scroll', scrollCheck)
window.addEventListener('resize', resize)
}
export function removeListeners() {
window.removeEventListener('scroll', scrollCheck)
window.removeEventListener('resize', resize)
cancelAnimationFrame(bubble.requestId)
}

View File

@@ -0,0 +1,22 @@
import { useEventListener } from '@vueuse/core'
/*
* 显示页面遮罩
*/
export const showShade = function (className = 'shade', closeCallBack: Function): void {
const containerEl = document.querySelector('.layout-container') as HTMLElement
const shadeDiv = document.createElement('div')
shadeDiv.setAttribute('class', 'ba-layout-shade ' + className)
containerEl.appendChild(shadeDiv)
useEventListener(shadeDiv, 'click', () => closeShade(closeCallBack))
}
/*
* 隐藏页面遮罩
*/
export const closeShade = function (closeCallBack: Function = () => {}): void {
const shadeEl = document.querySelector('.ba-layout-shade') as HTMLElement
shadeEl && shadeEl.remove()
closeCallBack()
}

57
web/src/utils/random.ts Normal file
View File

@@ -0,0 +1,57 @@
const hexList: string[] = []
for (let i = 0; i <= 15; i++) {
hexList[i] = i.toString(16)
}
/**
* 生成随机数
* @param min 最小值
* @param max 最大值
* @returns 生成的随机数
*/
export function randomNum(min: number, max: number) {
switch (arguments.length) {
case 1:
return parseInt((Math.random() * min + 1).toString(), 10)
break
case 2:
return parseInt((Math.random() * (max - min + 1) + min).toString(), 10)
break
default:
return 0
break
}
}
/**
* 生成全球唯一标识
* @returns uuid
*/
export function uuid(): string {
let uuid = ''
for (let i = 1; i <= 36; i++) {
if (i === 9 || i === 14 || i === 19 || i === 24) {
uuid += '-'
} else if (i === 15) {
uuid += 4
} else if (i === 20) {
uuid += hexList[(Math.random() * 4) | 8]
} else {
uuid += hexList[(Math.random() * 16) | 0]
}
}
return uuid
}
/**
* 生成唯一标识
* @param prefix 前缀
* @returns 唯一标识
*/
export function shortUuid(prefix = ''): string {
const time = Date.now()
const random = Math.floor(Math.random() * 1000000000)
if (!window.unique) window.unique = 0
window.unique++
return prefix + '_' + random + window.unique + String(time)
}

318
web/src/utils/router.ts Normal file
View File

@@ -0,0 +1,318 @@
import { ElNotification } from 'element-plus'
import { compact, isEmpty, reverse } from 'lodash-es'
import type { RouteLocationRaw, RouteRecordRaw } from 'vue-router'
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
import adminBaseRoute from '/@/router/static/adminBase'
import memberCenterBaseRoute from '/@/router/static/memberCenterBase'
import { useConfig } from '/@/stores/config'
import { useMemberCenter } from '/@/stores/memberCenter'
import { useNavTabs } from '/@/stores/navTabs'
import { useSiteConfig } from '/@/stores/siteConfig'
import { isAdminApp } from '/@/utils/common'
import { closeShade } from '/@/utils/pageShade'
/**
* 导航失败有错误消息的路由push
* @param to — 导航位置,同 router.push
*/
export const routePush = async (to: RouteLocationRaw) => {
try {
const failure = await router.push(to)
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
ElNotification({
message: i18n.global.t('utils.Navigation failed, navigation guard intercepted!'),
type: 'error',
})
} else if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
ElNotification({
message: i18n.global.t('utils.Navigation failed, it is at the navigation target position!'),
type: 'warning',
})
}
} catch (error) {
ElNotification({
message: i18n.global.t('utils.Navigation failed, invalid route!'),
type: 'error',
})
console.error(error)
}
}
/**
* 获取第一个菜单
*/
export const getFirstRoute = (routes: RouteRecordRaw[]): false | RouteRecordRaw => {
const routerPaths: string[] = []
const routers = router.getRoutes()
routers.forEach((item) => {
if (item.path) routerPaths.push(item.path)
})
let find: boolean | RouteRecordRaw = false
for (const key in routes) {
if (routes[key].meta?.type == 'menu' && routerPaths.indexOf(routes[key].path) !== -1) {
return routes[key]
} else if (routes[key].children && routes[key].children?.length) {
find = getFirstRoute(routes[key].children!)
if (find) return find
}
}
return find
}
/**
* 打开侧边菜单
* @param menu 菜单数据
*/
export const onClickMenu = (menu: RouteRecordRaw) => {
switch (menu.meta?.menu_type) {
case 'iframe':
case 'tab':
routePush(menu.path)
break
case 'link':
window.open(menu.path, '_blank')
break
default:
ElNotification({
message: i18n.global.t('utils.Navigation failed, the menu type is unrecognized!'),
type: 'error',
})
break
}
const config = useConfig()
if (config.layout.shrink) {
closeShade(() => {
config.setLayout('menuCollapse', true)
})
}
}
/**
* 处理前台的路由
* @param routes 路由规则
* @param menus 会员中心菜单路由规则
*/
export const handleFrontendRoute = (routes: any, menus: any) => {
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
const viewsComponent = import.meta.glob('/src/views/frontend/**/*.vue')
if (routes.length) {
addRouteAll(viewsComponent, routes, '', true)
memberCenter.mergeAuthNode(handleAuthNode(routes, '/'))
siteConfig.setHeadNav(handleMenuRule(routes, '/', ['nav']))
memberCenter.mergeNavUserMenus(handleMenuRule(routes, '/', ['nav_user_menu']))
}
if (menus.length && isEmpty(memberCenter.state.viewRoutes)) {
addRouteAll(viewsComponent, menus, memberCenterBaseRoute.name as string)
const menuMemberCenterBaseRoute = (memberCenterBaseRoute.path as string) + '/'
memberCenter.mergeAuthNode(handleAuthNode(menus, menuMemberCenterBaseRoute))
memberCenter.mergeNavUserMenus(handleMenuRule(menus, '/', ['nav_user_menu']))
memberCenter.setShowHeadline(menus.length > 1)
memberCenter.setViewRoutes(handleMenuRule(menus, menuMemberCenterBaseRoute))
}
}
/**
* 处理后台的路由
*/
export const handleAdminRoute = (routes: any) => {
const viewsComponent = import.meta.glob('/src/views/backend/**/*.vue')
addRouteAll(viewsComponent, routes, adminBaseRoute.name as string)
const menuAdminBaseRoute = (adminBaseRoute.path as string) + '/'
// 更新stores中的路由菜单数据
const navTabs = useNavTabs()
navTabs.setTabsViewRoutes(handleMenuRule(routes, menuAdminBaseRoute))
navTabs.fillAuthNode(handleAuthNode(routes, menuAdminBaseRoute))
}
/**
* 获取菜单的paths
*/
export const getMenuPaths = (menus: RouteRecordRaw[]): string[] => {
let menuPaths: string[] = []
menus.forEach((item) => {
menuPaths.push(item.path)
if (item.children && item.children.length > 0) {
menuPaths = menuPaths.concat(getMenuPaths(item.children))
}
})
return menuPaths
}
/**
* 获取菜单唯一标识
* @param menu 菜单数据
* @param prefix 前缀
*/
export const getMenuKey = (menu: RouteRecordRaw, prefix = '') => {
if (prefix === '') {
prefix = menu.path
}
return `${prefix}-${menu.name as string}-${menu.meta && menu.meta.id ? menu.meta.id : ''}`
}
/**
* 会员中心和后台的菜单处理
*/
const handleMenuRule = (routes: any, pathPrefix = '/', type = ['menu', 'menu_dir']) => {
const menuRule: RouteRecordRaw[] = []
for (const key in routes) {
if (routes[key].extend == 'add_rules_only') {
continue
}
if (!type.includes(routes[key].type)) {
continue
}
if (routes[key].type == 'menu_dir' && routes[key].children && !routes[key].children.length) {
continue
}
if (
['route', 'menu', 'nav_user_menu', 'nav'].includes(routes[key].type) &&
((routes[key].menu_type == 'tab' && !routes[key].component) || (['link', 'iframe'].includes(routes[key].menu_type) && !routes[key].url))
) {
continue
}
const currentPath = ['link', 'iframe'].includes(routes[key].menu_type) ? routes[key].url : pathPrefix + routes[key].path
let children: RouteRecordRaw[] = []
if (routes[key].children && routes[key].children.length > 0) {
children = handleMenuRule(routes[key].children, pathPrefix, type)
}
menuRule.push({
path: currentPath,
name: routes[key].name,
component: routes[key].component,
meta: {
id: routes[key].id,
title: routes[key].title,
icon: routes[key].icon,
keepalive: routes[key].keepalive,
menu_type: routes[key].menu_type,
type: routes[key].type,
},
children: children,
})
}
return menuRule
}
/**
* 处理权限节点
* @param routes 路由数据
* @param prefix 节点前缀
* @returns 组装好的权限节点
*/
const handleAuthNode = (routes: any, prefix = '/') => {
const authNode: Map<string, string[]> = new Map([])
assembleAuthNode(routes, authNode, prefix, prefix)
return authNode
}
const assembleAuthNode = (routes: any, authNode: Map<string, string[]>, prefix = '/', parent = '/') => {
const authNodeTemp = []
for (const key in routes) {
if (routes[key].type == 'button') authNodeTemp.push(prefix + routes[key].name)
if (routes[key].children && routes[key].children.length > 0) {
assembleAuthNode(routes[key].children, authNode, prefix, prefix + routes[key].name)
}
}
if (authNodeTemp && authNodeTemp.length > 0) {
authNode.set(parent, authNodeTemp)
}
}
/**
* 动态添加路由-带子路由
* @param viewsComponent
* @param routes
* @param parentName
* @param analyticRelation 根据 name 从已注册路由分析父级路由
*/
export const addRouteAll = (viewsComponent: Record<string, any>, routes: any, parentName: string, analyticRelation = false) => {
for (const idx in routes) {
if (routes[idx].extend == 'add_menu_only') {
continue
}
if ((routes[idx].menu_type == 'tab' && viewsComponent[routes[idx].component]) || routes[idx].menu_type == 'iframe') {
addRouteItem(viewsComponent, routes[idx], parentName, analyticRelation)
}
if (routes[idx].children && routes[idx].children.length > 0) {
addRouteAll(viewsComponent, routes[idx].children, parentName, analyticRelation)
}
}
}
/**
* 动态添加路由
* @param viewsComponent
* @param route
* @param parentName
* @param analyticRelation 根据 name 从已注册路由分析父级路由
*/
export const addRouteItem = (viewsComponent: Record<string, any>, route: any, parentName: string, analyticRelation: boolean) => {
let path = '',
component
if (route.menu_type == 'iframe') {
path = (isAdminApp() ? adminBaseRoute.path : memberCenterBaseRoute.path) + '/iframe/' + encodeURIComponent(route.url)
component = () => import('/@/layouts/common/router-view/iframe.vue')
} else {
path = parentName ? route.path : '/' + route.path
component = viewsComponent[route.component]
}
if (route.menu_type == 'tab' && analyticRelation) {
const parentNames = getParentNames(route.name)
if (parentNames.length) {
for (const key in parentNames) {
if (router.hasRoute(parentNames[key])) {
parentName = parentNames[key]
break
}
}
}
}
const routeBaseInfo: RouteRecordRaw = {
path: path,
name: route.name,
component: component,
meta: {
title: route.title,
extend: route.extend,
icon: route.icon,
keepalive: route.keepalive,
menu_type: route.menu_type,
type: route.type,
url: route.url,
addtab: true,
},
}
if (parentName) {
router.addRoute(parentName, routeBaseInfo)
} else {
router.addRoute(routeBaseInfo)
}
}
/**
* 根据name字符串获取父级name组合的数组
* @param name
*/
const getParentNames = (name: string) => {
const names = compact(name.split('/'))
const tempNames = []
const parentNames = []
for (const key in names) {
tempNames.push(names[key])
if (parseInt(key) != names.length - 1) {
parentNames.push(tempNames.join('/'))
}
}
return reverse(parentNames)
}

45
web/src/utils/storage.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* window.localStorage
* @method set 设置
* @method get 获取
* @method remove 移除
* @method clear 移除全部
*/
export const Local = {
set(key: string, val: any) {
window.localStorage.setItem(key, JSON.stringify(val))
},
get(key: string) {
const json: any = window.localStorage.getItem(key)
return JSON.parse(json)
},
remove(key: string) {
window.localStorage.removeItem(key)
},
clear() {
window.localStorage.clear()
},
}
/**
* window.sessionStorage
* @method set 设置会话缓存
* @method get 获取会话缓存
* @method remove 移除会话缓存
* @method clear 移除全部会话缓存
*/
export const Session = {
set(key: string, val: any) {
window.sessionStorage.setItem(key, JSON.stringify(val))
},
get(key: string) {
const json: any = window.sessionStorage.getItem(key)
return JSON.parse(json)
},
remove(key: string) {
window.sessionStorage.removeItem(key)
},
clear() {
window.sessionStorage.clear()
},
}

View File

@@ -0,0 +1,13 @@
import { getCurrentInstance } from 'vue'
import type { ComponentInternalInstance } from 'vue'
export default function useCurrentInstance() {
if (!getCurrentInstance()) {
throw new Error('useCurrentInstance() can only be used inside setup() or functional components!')
}
const { appContext } = getCurrentInstance() as ComponentInternalInstance
const proxy = appContext.config.globalProperties
return {
proxy,
}
}

49
web/src/utils/useDark.ts Normal file
View File

@@ -0,0 +1,49 @@
import { useDark, useToggle } from '@vueuse/core'
import { useConfig } from '/@/stores/config'
import { onMounted, onUnmounted, ref, watch } from 'vue'
const isDark = useDark({
onChanged(dark: boolean) {
const config = useConfig()
updateHtmlDarkClass(dark)
config.setLayout('isDark', dark)
config.onSetLayoutColor()
},
})
/**
* 切换暗黑模式
*/
const toggleDark = useToggle(isDark)
/**
* 切换当前页面的暗黑模式
*/
export function togglePageDark(val: boolean) {
const config = useConfig()
const isDark = ref(config.layout.isDark)
onMounted(() => {
if (isDark.value !== val) updateHtmlDarkClass(val)
})
onUnmounted(() => {
updateHtmlDarkClass(isDark.value)
})
watch(
() => config.layout.isDark,
(newVal) => {
isDark.value = newVal
if (isDark.value !== val) updateHtmlDarkClass(val)
}
)
}
export function updateHtmlDarkClass(val: boolean) {
const htmlEl = document.getElementsByTagName('html')[0]
if (val) {
htmlEl.setAttribute('class', 'dark')
} else {
htmlEl.setAttribute('class', '')
}
}
export default toggleDark

169
web/src/utils/validate.ts Normal file
View File

@@ -0,0 +1,169 @@
import type { RuleType } from 'async-validator'
import type { FormItemRule } from 'element-plus'
import { i18n } from '../lang'
/**
* 手机号码验证
*/
export function validatorMobile(rule: any, mobile: string | number, callback: Function) {
// 允许空值,若需必填请添加多验证规则
if (!mobile) {
return callback()
}
if (!/^(1[3-9])\d{9}$/.test(mobile.toString())) {
return callback(new Error(i18n.global.t('validate.Please enter the correct mobile number')))
}
return callback()
}
/**
* 身份证号验证
*/
export function validatorIdNumber(rule: any, idNumber: string | number, callback: Function) {
if (!idNumber) {
return callback()
}
if (!/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(idNumber.toString())) {
return callback(new Error(i18n.global.t('validate.Please enter the correct ID number')))
}
return callback()
}
/**
* 账户名验证
*/
export function validatorAccount(rule: any, val: string, callback: Function) {
if (!val) {
return callback()
}
if (!/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/.test(val)) {
return callback(new Error(i18n.global.t('validate.Please enter the correct account')))
}
return callback()
}
/**
* 密码验证
*/
export function regularPassword(val: string) {
return /^(?!.*[&<>"'\n\r]).{6,32}$/.test(val)
}
export function validatorPassword(rule: any, val: string, callback: Function) {
if (!val) {
return callback()
}
if (!regularPassword(val)) {
return callback(new Error(i18n.global.t('validate.Please enter the correct password')))
}
return callback()
}
/**
* 变量名验证
*/
export function regularVarName(val: string) {
return /^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/.test(val)
}
export function validatorVarName(rule: any, val: string, callback: Function) {
if (!val) {
return callback()
}
if (!regularVarName(val)) {
return callback(new Error(i18n.global.t('validate.Please enter the correct name')))
}
return callback()
}
export function validatorEditorRequired(rule: any, val: string, callback: Function) {
if (!val || val == '<p><br></p>') {
return callback(new Error(i18n.global.t('validate.Content cannot be empty')))
}
return callback()
}
/**
* 支持的表单验证规则
*/
export const validatorType = {
required: i18n.global.t('validate.required'),
mobile: i18n.global.t('utils.mobile'),
idNumber: i18n.global.t('utils.Id number'),
account: i18n.global.t('utils.account'),
password: i18n.global.t('utils.password'),
varName: i18n.global.t('utils.variable name'),
editorRequired: i18n.global.t('validate.editor required'),
url: 'URL',
email: i18n.global.t('utils.email'),
date: i18n.global.t('utils.date'),
number: i18n.global.t('validate.number'), // 数字(包括浮点和整数)
integer: i18n.global.t('validate.integer'), // 整数(不包括浮点数)
float: i18n.global.t('validate.float'), // 浮点数(不包括整数)
}
export interface buildValidatorParams {
// 规则名:required=必填,mobile=手机号,idNumber=身份证号,account=账户,password=密码,varName=变量名,editorRequired=富文本必填,number、integer、float、date、url、email
name:
| 'required'
| 'mobile'
| 'idNumber'
| 'account'
| 'password'
| 'varName'
| 'editorRequired'
| 'number'
| 'integer'
| 'float'
| 'date'
| 'url'
| 'email'
// 自定义验证错误消息
message?: string
// 验证项的标题,这些验证方式不支持:mobile、account、password、varName、editorRequired
title?: string
// 验证触发方式
trigger?: 'change' | 'blur'
}
/**
* 构建表单验证规则
* @param {buildValidatorParams} paramsObj 参数对象
*/
export function buildValidatorData({ name, message, title, trigger = 'blur' }: buildValidatorParams): FormItemRule {
// 必填
if (name == 'required') {
return {
required: true,
message: message ? message : i18n.global.t('Please input field', { field: title }),
trigger: trigger,
}
}
// 常见类型
const validatorType = ['number', 'integer', 'float', 'date', 'url', 'email']
if (validatorType.includes(name)) {
return {
type: name as RuleType,
message: message ? message : i18n.global.t('Please enter the correct field', { field: title }),
trigger: trigger,
}
}
// 自定义验证方法
const validatorCustomFun: anyObj = {
mobile: validatorMobile,
idNumber: validatorIdNumber,
account: validatorAccount,
password: validatorPassword,
varName: validatorVarName,
editorRequired: validatorEditorRequired,
}
if (validatorCustomFun[name]) {
return {
required: name == 'editorRequired' ? true : false,
validator: validatorCustomFun[name],
trigger: trigger,
message: message,
}
}
return {}
}

184
web/src/utils/vite.ts Normal file
View File

@@ -0,0 +1,184 @@
import type { Plugin, ViteDevServer } from 'vite'
import { reactive } from 'vue'
interface HotUpdateState {
// 热更新状态
switch: boolean
// 热更新关闭类型:terminal=WEB终端执行命令,crud=CRUD,modules=模块安装服务,config=修改系统配置
closeType: string
// 是否有脏文件(热更新 switch 为 false又触发了热更新就会产生脏文件
dirtyFile: boolean
// 监听是否有脏文件
listenDirtyFileSwitch: boolean
}
/**
* 调试模式下的 Vite 热更新相关状态(这些状态均由 Vite 服务器记录并随时同步至客户端)
*/
export const hotUpdateState = reactive<HotUpdateState>({
switch: true,
closeType: '',
dirtyFile: false,
listenDirtyFileSwitch: true,
})
/**
* Vite 相关初始化
*/
export function init() {
if (import.meta.hot) {
// 监听 Vite 服务器通知热更新相关状态更新
import.meta.hot.on('custom:change-hot-update-state', (state: Partial<HotUpdateState>) => {
hotUpdateState.switch = state.switch ?? hotUpdateState.switch
hotUpdateState.closeType = state.closeType ?? hotUpdateState.closeType
hotUpdateState.dirtyFile = state.dirtyFile ?? hotUpdateState.dirtyFile
hotUpdateState.listenDirtyFileSwitch = state.listenDirtyFileSwitch ?? hotUpdateState.listenDirtyFileSwitch
})
// 保持脏文件监听功能开启(同时可以从服务端同步一次热更新服务的状态数据)
changeListenDirtyFileSwitch(true)
}
}
/**
* 是否在开发环境
*/
export function isDev(mode: string): boolean {
return mode === 'development'
}
/**
* 是否在生产环境
*/
export function isProd(mode: string | undefined): boolean {
return mode === 'production'
}
/**
* 调试模式下关闭热更新
*/
export const closeHotUpdate = (type: string) => {
if (import.meta.hot) {
import.meta.hot.send('custom:close-hot', { type })
}
}
/**
* 调试模式下开启热更新
*/
export const openHotUpdate = (type: string) => {
if (import.meta.hot) {
import.meta.hot.send('custom:open-hot', { type })
}
}
/**
* 调试模式下重启服务并刷新网页
*/
export const reloadServer = (type: string) => {
if (import.meta.hot) {
import.meta.hot.send('custom:reload-server', { type })
}
}
/**
* 改变脏文件监听功能的开关
*/
export const changeListenDirtyFileSwitch = (status: boolean) => {
if (import.meta.hot) {
import.meta.hot.send('custom:change-listen-dirty-file-switch', status)
}
}
/**
* 自定义热更新/热替换处理的 Vite 插件
*/
export const customHotUpdate = (): Plugin => {
type Listeners = ((...args: any[]) => void)[]
let addFunctionBack: Listeners = []
let unlinkFunctionBack: Listeners = []
// 本服务端的热更新状态数据
const hotUpdateState: HotUpdateState = {
switch: true,
closeType: '',
dirtyFile: false,
listenDirtyFileSwitch: true,
}
/**
* 同步所有热更新状态数据至客户端
*/
const syncHotUpdateState = (server: ViteDevServer) => {
server.ws.send('custom:change-hot-update-state', hotUpdateState)
}
return {
name: 'vite-plugin-custom-hot-update',
apply: 'serve',
configureServer(server) {
// 关闭热更新
server.ws.on('custom:close-hot', ({ type }) => {
hotUpdateState.switch = false
hotUpdateState.closeType = type
// 备份文件添加和删除时的函数列表
addFunctionBack = server.watcher.listeners('add') as Listeners
unlinkFunctionBack = server.watcher.listeners('unlink') as Listeners
// 关闭文件添加和删除的监听
server.watcher.removeAllListeners('add')
server.watcher.removeAllListeners('unlink')
syncHotUpdateState(server)
// 文件添加时通知客户端新增了脏文件(文件删除无需记录为脏文件)
server.watcher.on('add', () => {
if (hotUpdateState.listenDirtyFileSwitch) {
hotUpdateState.dirtyFile = true
syncHotUpdateState(server)
}
})
})
// 开启热更新
server.ws.on('custom:open-hot', () => {
hotUpdateState.switch = true
server.watcher.removeAllListeners('add')
server.watcher.removeAllListeners('unlink')
// 恢复备份的函数列表
for (const key in addFunctionBack) {
server.watcher.on('add', addFunctionBack[key])
}
for (const key in unlinkFunctionBack) {
server.watcher.on('unlink', unlinkFunctionBack[key])
}
syncHotUpdateState(server)
})
// 重启热更新
server.ws.on('custom:reload-server', () => {
server.restart()
})
// 客户端可从本服务端获取热更新服务状态数据
server.ws.on('custom:get-hot-update-state', () => {
syncHotUpdateState(server)
})
// 修改监听脏文件的开关
server.ws.on('custom:change-listen-dirty-file-switch', (status: boolean) => {
hotUpdateState.listenDirtyFileSwitch = status
syncHotUpdateState(server)
})
},
handleHotUpdate() {
if (!hotUpdateState.switch) {
return []
}
},
}
}

View File

@@ -0,0 +1,97 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.admin.username') + '/' + t('auth.admin.nickname') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'auth/admin',
})
const { t } = useI18n()
const adminInfo = useAdminInfo()
const optButtons = defaultOptButtons(['edit', 'delete'])
optButtons[1].display = (row) => {
return row.id != adminInfo.id
}
const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' },
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.Last login'),
prop: 'last_login_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: 'enable',
},
}
)
provide('baTable', baTable)
baTable.mount()
baTable.getData()
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,198 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
:label="t('auth.admin.username')"
v-model="baTable.form.items!.username"
type="string"
prop="username"
:placeholder="t('auth.admin.Administrator login')"
/>
<FormItem
:label="t('auth.admin.nickname')"
v-model="baTable.form.items!.nickname"
type="string"
prop="nickname"
:placeholder="t('Please input field', { field: t('auth.admin.nickname') })"
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
prop="group_arr"
type="remoteSelect"
:key="'group-' + baTable.form.items!.id"
:input-attr="{
multiple: true,
params: { isTree: true, absoluteAuth: adminInfo.id == baTable.form.items!.id ? 0 : 1 },
field: 'name',
remoteUrl: '/admin/auth.Group/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('auth.admin.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<FormItem
:label="t('auth.admin.email')"
prop="email"
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('auth.admin.email') })"
/>
<FormItem
:label="t('auth.admin.mobile')"
prop="mobile"
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('auth.admin.mobile') })"
/>
<FormItem
:label="t('auth.admin.Password')"
prop="password"
v-model="baTable.form.items!.password"
type="password"
:input-attr="{ autocomplete: 'new-password' }"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('auth.admin.Password') })
: t('auth.admin.Please leave blank if not modified')
"
/>
<el-form-item prop="motto" :label="t('auth.admin.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('auth.admin.Personal signature') })"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword, buildValidatorData } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('auth.admin.Password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
})
watch(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
}
)
</script>
<style scoped lang="scss">
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.adminLog.title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<Info />
</div>
</template>
<script setup lang="ts">
import { concat, cloneDeep } from 'lodash-es'
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import Info from './info.vue'
import { buildJsonToElTreeData } from '/@/utils/common'
defineOptions({
name: 'auth/adminLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-edit',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row)
},
},
]
optButtons = concat(optButtons, defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('auth.adminLog.admin_id'),
prop: 'admin_id',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 70,
},
{
label: t('auth.adminLog.username'),
prop: 'username',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 160,
},
{ label: t('auth.adminLog.title'), prop: 'title', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
show: false,
label: t('auth.adminLog.data'),
prop: 'data',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('auth.adminLog.url'),
prop: 'url',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
render: 'url',
},
{ label: t('auth.adminLog.ip'), prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tag' },
{
label: t('auth.adminLog.useragent'),
prop: 'useragent',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('Create time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row)
return false
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
/** 点击查看详情按钮响应 */
const infoButtonClick = (row: TableRow) => {
if (!row) return
// 数据来自表格数据,未重新请求api,深克隆,不然可能会影响表格
let rowClone = cloneDeep(row)
rowClone.data = rowClone.data ? [{ label: '点击展开', children: buildJsonToElTreeData(JSON.parse(rowClone.data)) }] : []
baTable.form.extend!['info'] = rowClone
baTable.form.operate = 'Info'
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,62 @@
<template>
<!-- 查看详情 -->
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions :column="2" border>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.Operation administrator')">
{{ baTable.form.extend!.info.username }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.title')">
{{ baTable.form.extend!.info.title }}
</el-descriptions-item>
<el-descriptions-item :label="t('auth.adminLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="URL">
{{ baTable.form.extend!.info.url }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('Create time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('auth.adminLog.Request data')">
<el-tree class="table-el-tree" :data="baTable.form.extend!.info.data" :props="{ label: 'label', children: 'children' }" />
</el-descriptions-item>
</el-descriptions>
</div>
</el-scrollbar>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
</script>
<style scoped lang="scss">
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="default-main ba-table-box">
<el-alert
class="ba-table-alert group-super-alert"
v-if="!adminInfo.super"
:title="t('auth.group.Manage subordinate role groups here')"
type="info"
show-icon
/>
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getAdminRules } from '/@/api/backend/auth/group'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import baTableClass from '/@/utils/baTable'
import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'auth/group',
})
const { t } = useI18n()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable: baTableClass = new baTableClass(
new baTableApi('/admin/auth.Group/'),
{
expandAll: true,
dblClickNotEditColumn: [undefined],
column: [
{ type: 'selection', align: 'center' },
{ label: t('auth.group.Group name'), prop: 'name', align: 'left', width: '200' },
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons(['edit', 'delete']) },
],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
return baTable.table.extend!.adminGroup.indexOf(row.id) === -1
}
// 获取到数据后钩子
baTable.after.getData = ({ res }) => {
baTable.table.extend!.adminGroup = res.data.group
let buttonsKey = getArrayKey(baTable.table.column, 'render', 'buttons')
baTable.table.column[buttonsKey].buttons!.forEach((value: OptButton) => {
value.display = (row) => {
return res.data.group.indexOf(row.id) === -1
}
})
}
// 切换表单后钩子
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 编辑请求完成后钩子
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getAdminRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.group-super-alert {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
:label="t('auth.group.Parent group')"
v-model="baTable.form.items!.pid"
type="remoteSelect"
prop="pid"
:input-attr="{
params: { isTree: true },
field: 'name',
remoteUrl: baTable.api.actionUrl.get('index'),
placeholder: t('Click select'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item prop="name" :label="t('auth.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('auth.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('auth.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElTree, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('auth.group.jurisdiction') })))
}
return callback()
},
},
],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.group.The parent group cannot be the group itself')))
}
return callback()
},
trigger: 'blur',
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.title') })"
/>
<!-- 设置合适的 max-height 实现隐藏布局主体部分本身的滚动条这样就可以监听表格的 @scroll -->
<!-- max-height = 100vh - (当前布局顶栏高度 + 表头栏高度 + 表格上边距 + 预留的底部下边距) -->
<Table
ref="tableRef"
:max-height="`calc(-${adminLayoutHeaderBarHeight[config.layout.layoutMode as keyof typeof adminLayoutHeaderBarHeight] + 75 + 16}px + 100vh)`"
:pagination="false"
@expand-change="onExpandChange"
@scroll="onScroll"
/>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { cloneDeep, debounce } from 'lodash-es'
import { nextTick, onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import { useConfig } from '/@/stores/config'
import baTableClass from '/@/utils/baTable'
import { adminLayoutHeaderBarHeight } from '/@/utils/layout'
defineOptions({
name: 'auth/rule',
})
const { t } = useI18n()
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/auth.Rule/'),
{
expandAll: false,
dblClickNotEditColumn: [undefined, 'keepalive', 'status'],
column: [
{ type: 'selection', align: 'center' },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', button: 'info' },
replaceValue: { menu: t('auth.rule.type menu'), menu_dir: t('auth.rule.type menu_dir'), button: t('auth.rule.type button') },
},
{ label: t('auth.rule.cache'), prop: 'keepalive', align: 'center', width: '80', render: 'switch' },
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(),
},
],
dragSortLimitField: 'pid',
},
{
defaultItems: {
type: 'menu',
menu_type: 'tab',
extend: 'none',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
buttons: ['index', 'add', 'edit', 'del'],
},
}
)
/**
* 内存缓存表格的一些状态数据,供数据刷新后恢复
*/
const sessionStateDefault = {
expanded: [] as any[],
scrollTop: 0,
scrollLeft: 0,
expandAll: false,
}
let sessionState = sessionStateDefault
/**
* 记录表格行展开状态
*/
const onExpandChange = (row: any, expanded: boolean) => {
if (expanded) {
sessionState.expanded.push(row)
} else {
sessionState.expanded = sessionState.expanded.filter((item: any) => item.id !== row.id)
}
}
/**
* 记录表格滚动条位置
*/
const onScroll = debounce(({ scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number }) => {
sessionState.scrollTop = scrollTop
sessionState.scrollLeft = scrollLeft
}, 500)
/**
* 记录表格行展开折叠状态
*/
const onUnfoldAll = (state: boolean) => {
sessionState.expandAll = state
}
/**
* 恢复已记录的表格状态
*/
const restoreState = () => {
nextTick(() => {
const sessionStateTemp = sessionState
// 重置 sessionState 为默认值,恢复缓存的记录时将自动重设
sessionState = cloneDeep(sessionStateDefault)
for (const key in sessionStateTemp.expanded) {
tableRef.value?.getRef()?.toggleRowExpansion(sessionStateTemp.expanded[key], true)
}
nextTick(() => {
if (sessionStateTemp.scrollTop || sessionStateTemp.scrollLeft) {
tableRef.value?.getRef()?.scrollTo({ top: sessionStateTemp.scrollTop || 0, left: sessionStateTemp.scrollLeft || 0 })
}
/**
* expandAll 为 “是否默认展开所有行”
* 此处表格数据已渲染,仅做顶部按钮状态标记用,不会实际上的执行展开折叠操作
* 展开全部行之后再只对某一行进行折叠时expandAll 不会改变,所以此处并不根据 expandAll 值执行折叠展开所有行的操作
*/
baTable.table.expandAll = sessionStateTemp.expandAll
onUnfoldAll(sessionStateTemp.expandAll)
})
})
}
// 获取数据前钩子
baTable.before.getData = () => {
baTable.table.expandAll = baTable.table.filter?.quickSearch ? true : false
}
// 获取到编辑行数据后的钩子
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
// 表格顶部按钮事件触发后的钩子
baTable.after.onTableHeaderAction = ({ event, data }) => {
if (event == 'unfold') {
onUnfoldAll(data.unfold)
}
}
// 获取到表格数据后的钩子
baTable.after.getData = () => {
restoreState()
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss">
.default-main {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<FormItem
:label="t('auth.rule.Rule type')"
v-model="baTable.form.items!.type"
type="radio"
:input-attr="{
border: true,
content: { menu_dir: t('auth.rule.type menu_dir'), menu: t('auth.rule.type menu'), button: t('auth.rule.type button') },
}"
/>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('auth.rule.English name, which does not need to start with `/admin`, such as auth/menu')"
></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item prop="path" v-if="baTable.form.items!.type != 'button'" :label="t('auth.rule.Routing path')">
<el-input
v-model="baTable.form.items!.path"
type="string"
:placeholder="t('auth.rule.The web side routing path (path) does not need to start with `/admin`, such as auth/menu')"
></el-input>
</el-form-item>
<FormItem
v-if="baTable.form.operate && baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{
showIconName: true,
}"
/>
<FormItem
v-if="baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<el-form-item
prop="url"
v-if="baTable.form.items!.menu_type != 'tab' && baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<el-form-item
prop="component"
v-if="baTable.form.items!.type == 'menu' && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('auth.rule.Web side component path, please start with /src, such as: /src/views/backend/dashboard')"
></el-input>
</el-form-item>
<el-form-item
v-if="baTable.form.items!.type == 'menu' && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">{{ t('auth.rule.extend Title') }}</div>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="
t(
'auth.rule.Use in controller `get_ route_ Remark()` function, which can obtain the value of this field for your own use, such as the banner file of the console'
)
"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
v-if="baTable.form.operate == 'Add' && baTable.form.items!.type == 'menu'"
:label="t('auth.rule.Create Page Button')"
v-model="baTable.form.items!.buttons"
type="selects"
:input-attr="{
content: {
index: t('auth.rule.Create Page Button index'),
add: t('auth.rule.Create Page Button add'),
edit: t('auth.rule.Create Page Button edit'),
del: t('auth.rule.Create Page Button del'),
sortable: t('auth.rule.Create Page Button sortable'),
},
}"
:placeholder="t('auth.rule.Please select the button for automatically creating the desired page')"
:block-help="t('auth.rule.Create Page Button tips')"
/>
<FormItem
:label="t('auth.rule.cache')"
v-model="baTable.form.items!.keepalive"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import { buildValidatorData } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', title: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
component: [buildValidatorData({ name: 'required', message: t('auth.rule.Component path') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,2075 @@
<template>
<div class="default-main">
<div class="header-config-box">
<el-row class="header-box">
<div class="header">
<div class="header-item-box">
<FormItem
class="mr-20 table-name-item"
:label="t('crud.log.table_name')"
v-model="state.table.name"
type="string"
:placeholder="t('crud.crud.Name of the data table')"
:input-attr="{
onChange: onTableNameChange,
}"
:error="state.error.tableName"
/>
<FormItem
class="table-comment-item"
:label="t('crud.crud.Data Table Notes')"
v-model="state.table.comment"
type="string"
:placeholder="t('crud.crud.For example: `user table` will be generated into `user management`')"
/>
</div>
<div class="header-right">
<el-link v-if="crudState.type != 'create'" @click="state.showDesignChangeLog = true" class="design-change-log" type="primary">
{{ t('crud.crud.Table design change') }}
</el-link>
<el-button type="primary" :loading="state.loading.generate" @click="onGenerate" v-blur>
{{ t('crud.crud.Generate CRUD code') }}
</el-button>
<el-button @click="onAbandonDesign" type="danger" v-blur>{{ t('crud.crud.give up') }}</el-button>
</div>
</div>
</el-row>
<transition :name="state.showHeaderSeniorConfig ? 'el-zoom-in-top' : 'el-zoom-in-bottom'">
<div v-if="state.showHeaderSeniorConfig" class="header-senior-config-box">
<div class="header-senior-config-form">
<el-form-item :label-width="140" :label="t('crud.crud.Table Quick Search Fields')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.quickSearchField" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<div class="default-sort-field-box">
<el-form-item :label-width="140" class="default-sort-field mr-20" :label="t('crud.crud.Table Default Sort Fields')">
<el-select :clearable="true" v-model="state.table.defaultSortField" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<FormItem
class="default-sort-field-type"
:label="t('crud.crud.sort order')"
v-model="state.table.defaultSortType"
type="select"
:input-attr="{
content: { desc: t('crud.crud.sort order desc'), asc: t('crud.crud.sort order asc') },
}"
/>
</div>
<el-form-item :label-width="140" :label="t('crud.crud.Fields as Table Columns')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.columnFields" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<el-form-item :label-width="140" :label="t('crud.crud.Fields as form items')">
<el-select :clearable="true" :multiple="true" class="w100" v-model="state.table.formFields" placement="bottom">
<el-option
v-for="(item, idx) in state.fields"
:key="idx + item.uuid!"
:label="item.name + (item.comment ? '-' + item.comment : item.title)"
:value="item.uuid!"
/>
</el-select>
</el-form-item>
<FormItem
:label="t('crud.crud.The relative path to the generated code')"
v-model="state.table.generateRelativePath"
type="string"
:label-width="140"
:block-help="t('crud.crud.For quick combination code generation location, please fill in the relative path')"
:input-attr="{
onChange: onTableChange,
}"
/>
<FormItem
:label="t('crud.crud.Generated Controller Location')"
v-model="state.table.controllerFile"
type="string"
:label-width="140"
/>
<el-form-item :label="t('crud.crud.Generated Data Model Location')" :label-width="140">
<el-input v-model="state.table.modelFile" type="string">
<template #append>
<el-checkbox
@change="onChangeCommonModel"
v-model="state.table.isCommonModel"
:label="t('crud.crud.Common model')"
size="small"
:true-value="1"
:false-value="0"
/>
</template>
</el-input>
</el-form-item>
<FormItem
:label="t('crud.crud.Generated Validator Location')"
v-model="state.table.validateFile"
type="string"
:label-width="140"
/>
<FormItem :label="t('crud.crud.WEB end view directory')" v-model="state.table.webViewsDir" type="string" :label-width="140" />
<FormItem
:label="t('Database connection')"
v-model="state.table.databaseConnection"
type="remoteSelect"
:label-width="140"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
}"
/>
</div>
</div>
</transition>
<div @click="state.showHeaderSeniorConfig = !state.showHeaderSeniorConfig" class="header-senior-config">
<span>{{ t('crud.crud.Advanced Configuration') }}</span>
<Icon
class="senior-config-arrow-icon"
size="14"
color="var(--el-text-color-primary)"
:name="state.showHeaderSeniorConfig ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'"
/>
</div>
</div>
<el-row v-loading="state.loading.init" class="fields-box" :gutter="20">
<el-col :xs="24" :span="6">
<el-collapse class="field-collapse" v-model="state.fieldCollapseName">
<el-collapse-item :title="t('crud.crud.Common Fields')" name="common">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.common" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item :title="t('crud.crud.Base Fields')" name="base">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.base" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
<el-collapse-item :title="t('crud.crud.Advanced Fields')" name="senior">
<div class="field-box" :ref="tabsRefs.set">
<div v-for="(field, index) in fieldItem.senior" :key="index" class="field-item">
<span>{{ field.title }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-col>
<el-col :xs="24" :span="12">
<div ref="designWindowRef" class="design-window ba-scroll-style">
<div
v-for="(field, index) in state.fields"
:key="index"
:class="index === state.activateField ? 'activate' : ''"
@click="onActivateField(index)"
class="design-field-box"
:data-id="index"
>
<div class="design-field">
<span>{{ t('crud.crud.Field Name') }}</span>
<BaInput
@pointerdown.stop
class="design-field-name-input"
:model-value="field.name"
type="string"
:attr="{
size: 'small',
onInput: ($event: string) => onFieldNameChange($event, index),
}"
/>
</div>
<div class="design-field">
<span>{{ t('crud.crud.field comment') }}</span>
<BaInput
@pointerdown.stop
class="design-field-name-comment"
v-model="field.comment"
type="string"
:attr="{
size: 'small',
onChange: onFieldCommentChange,
}"
/>
</div>
<div class="design-field-right">
<el-button
v-if="['remoteSelect', 'remoteSelects'].includes(field.designType)"
@click.stop="onEditField(index, field)"
type="primary"
size="small"
v-blur
circle
>
<Icon color="var(--el-color-white)" size="15" name="fa fa-pencil icon" />
</el-button>
<el-button @click.stop="onDelField(index)" type="danger" size="small" v-blur circle>
<Icon color="var(--el-color-white)" size="15" name="fa fa-trash" />
</el-button>
</div>
</div>
<div class="design-field-empty" v-if="!state.fields.length && !state.draggingField">
{{ t('crud.crud.Drag the left element here to start designing CRUD') }}
</div>
</div>
</el-col>
<el-col :xs="24" :span="6">
<div class="field-config ba-scroll-style">
<div v-if="state.activateField === -1" class="design-field-empty">
{{ t('crud.crud.Please select a field from the left first') }}
</div>
<div v-else :key="'activate-field-' + state.activateField">
<el-form label-position="top">
<el-divider content-position="left">{{ t('crud.crud.Common') }}</el-divider>
<el-form-item :label="t('crud.crud.Generate type')">
<el-select
@change="onFieldDesignTypeChange($event)"
class="w100"
:model-value="state.fields[state.activateField].designType"
placement="bottom"
>
<el-option v-for="(item, idx) in designTypes" :key="idx" :label="item.name" :value="idx" />
</el-select>
</el-form-item>
<FormItem
:label="t('crud.crud.Field comments (CRUD dictionary)')"
type="textarea"
:input-attr="{
rows: 2,
onChange: onFieldCommentChange,
}"
:placeholder="
t(
'crud.crud.The field comment will be used as the CRUD dictionary, and will be identified as the field title before the colon, and as the data dictionary after the colon'
)
"
v-model="state.fields[state.activateField].comment"
/>
<el-divider content-position="left">{{ t('crud.crud.Field Properties') }}</el-divider>
<FormItem
:label="t('crud.crud.Field Name')"
type="string"
:model-value="state.fields[state.activateField].name"
:input-attr="{
onInput: ($event: string) => onFieldNameChange($event, state.activateField),
}"
/>
<template v-if="state.fields[state.activateField].dataType">
<FormItem
:label="t('crud.crud.Field Type')"
:input-attr="{
onChange: onFieldAttrChange,
}"
type="textarea"
v-model="state.fields[state.activateField].dataType"
/>
</template>
<template v-else>
<FormItem
:label="t('crud.crud.Field Type')"
:input-attr="{
onChange: onFieldAttrChange,
}"
type="string"
v-model="state.fields[state.activateField].type"
/>
<div class="field-inline">
<FormItem
:label="t('crud.crud.length')"
type="number"
v-model="state.fields[state.activateField].length"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
:label="t('crud.crud.decimal point')"
type="number"
v-model="state.fields[state.activateField].precision"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
</template>
<el-form-item :label="t('crud.crud.Field Defaults')">
<el-select v-model="state.fields[state.activateField].defaultType">
<el-option label="手动输入" value="INPUT" />
<el-option label="EMPTY STRING空字符串" value="EMPTY STRING" />
<el-option label="NULL" value="NULL" />
<el-option label="不设默认值" value="NONE" />
</el-select>
<el-input
v-if="state.fields[state.activateField].defaultType == 'INPUT'"
:placeholder="t('crud.crud.Please input the default value')"
type="text"
v-model="state.fields[state.activateField].default"
@change="onFieldAttrChange"
class="default-input"
/>
</el-form-item>
<div class="field-inline">
<FormItem
class="form-item-position-right"
:label="t('crud.state.Primary key')"
type="switch"
v-model="state.fields[state.activateField].primaryKey"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Auto increment')"
type="switch"
v-model="state.fields[state.activateField].autoIncrement"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
<div class="field-inline">
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Unsigned')"
type="switch"
v-model="state.fields[state.activateField].unsigned"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
<FormItem
class="form-item-position-right"
:label="t('crud.crud.Allow NULL')"
type="switch"
v-model="state.fields[state.activateField].null"
:input-attr="{
onChange: onFieldAttrChange,
}"
/>
</div>
<template v-if="!isEmpty(state.fields[state.activateField].table)">
<el-divider content-position="left">{{ t('crud.crud.Field Table Properties') }}</el-divider>
<template v-for="(item, idx) in state.fields[state.activateField].table" :key="idx">
<FormItem
:label="$t('crud.crud.' + idx)"
:type="item.type"
v-model="state.fields[state.activateField].table[idx].value"
:placeholder="state.fields[state.activateField].table[idx].placeholder ?? ''"
:input-attr="{
content: state.fields[state.activateField].table[idx].options ?? {},
...(state.fields[state.activateField].table[idx].attr ?? {}),
}"
/>
</template>
</template>
<template v-if="!isEmpty(state.fields[state.activateField].form)">
<el-divider content-position="left">{{ t('crud.crud.Field Form Properties') }}</el-divider>
<template v-for="(item, idx) in state.fields[state.activateField].form" :key="idx">
<FormItem
v-if="item.type != 'hidden'"
:label="$t('crud.crud.' + idx)"
:type="item.type"
v-model="state.fields[state.activateField].form[idx].value"
:placeholder="state.fields[state.activateField].form[idx].placeholder ?? ''"
:input-attr="{
content: state.fields[state.activateField].form[idx].options ?? {},
...(state.fields[state.activateField].form[idx].attr ?? {}),
}"
/>
</template>
</template>
</el-form>
</div>
</div>
</el-col>
</el-row>
<el-dialog
@close="onCancelRemoteSelect"
class="ba-operate-dialog"
:model-value="state.remoteSelectPre.show"
:title="t('crud.crud.Remote drop-down association information')"
:close-on-click-modal="false"
:destroy-on-close="true"
@keyup.enter="onSaveRemoteSelect"
>
<el-scrollbar max-height="60vh">
<div class="ba-operate-form" :style="'width: calc(100% - 80px)'">
<el-form
ref="formRef"
:model="state.remoteSelectPre.form"
:rules="remoteSelectPreFormRules"
v-loading="state.remoteSelectPre.loading"
label-position="right"
label-width="160px"
v-if="state.remoteSelectPre.index != -1 && state.fields[state.remoteSelectPre.index]"
>
<FormItem
:label="t('crud.crud.Associated Data Table')"
v-model="state.remoteSelectPre.form.table"
type="remoteSelect"
:key="state.table.databaseConnection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: state.table.databaseConnection,
samePrefix: 1,
excludeTable: [
'area',
'token',
'captcha',
'admin_group_access',
'config',
'admin_log',
'user_money_log',
'user_score_log',
],
},
remoteUrl: getTableListUrl,
onChange: onJoinTableChange,
}"
prop="table"
/>
<div v-loading="state.loading.remoteSelect">
<FormItem
prop="pk"
type="select"
:label="t('crud.crud.Drop down value field')"
v-model="state.remoteSelectPre.form.pk"
:placeholder="t('crud.crud.Please select the value field of the select component')"
:key="'select-value' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
prop="label"
type="select"
:label="t('crud.crud.Drop down label field')"
v-model="state.remoteSelectPre.form.label"
:placeholder="t('crud.crud.Please select the label field of the select component')"
:key="'select-label' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
v-if="state.fields[state.remoteSelectPre.index].designType == 'remoteSelect'"
prop="joinField"
type="selects"
:label="t('crud.crud.Fields displayed in the table')"
v-model="state.remoteSelectPre.form.joinField"
:placeholder="t('crud.crud.Please select the fields displayed in the table')"
:key="'join-field' + JSON.stringify(state.remoteSelectPre.fieldList)"
:input-attr="{
content: state.remoteSelectPre.fieldList,
}"
/>
<FormItem
:label="t('crud.crud.Data source configuration type')"
v-model="state.remoteSelectPre.form.sourceConfigType"
type="radio"
:input-attr="{
border: true,
content: {
crud: t('crud.crud.Fast configuration with generated controllers and models'),
custom: t('crud.crud.Custom configuration'),
},
}"
/>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'crud'"
prop="controllerFile"
type="select"
:label="t('crud.crud.Controller position')"
v-model="state.remoteSelectPre.form.controllerFile"
:placeholder="t('crud.crud.Please select the controller of the data table')"
:key="'controller-file' + JSON.stringify(state.remoteSelectPre.controllerFileList)"
:input-attr="{
content: state.remoteSelectPre.controllerFileList,
}"
:block-help="
t(
'crud.crud.The remote pull-down will request the corresponding controller to obtain data, so it is recommended that you create the CRUD of the associated table'
)
"
/>
<!-- 数据源配置类型为CRUD时模型位置必填 -->
<FormItem
:prop="state.remoteSelectPre.form.sourceConfigType == 'crud' ? 'modelFile' : ''"
type="select"
:label="t('crud.crud.Data Model Location')"
v-model="state.remoteSelectPre.form.modelFile"
:placeholder="t('crud.crud.Please select the data model location of the data table')"
:key="'model-file' + JSON.stringify(state.remoteSelectPre.modelFileList)"
:input-attr="{
content: state.remoteSelectPre.modelFileList,
}"
:block-help="
state.remoteSelectPre.form.sourceConfigType == 'crud'
? ''
: t(
'crud.crud.If it is left blank, the model of the associated table will be generated automatically If the table already has a model, it is recommended to select it to avoid repeated generation'
)
"
/>
<el-form-item
v-if="state.table.databaseConnection && state.remoteSelectPre.form.modelFile"
:label="t('Database connection')"
>
<el-text size="large" type="danger">{{ state.table.databaseConnection }}</el-text>
<div class="block-help">
<div>{{ t('crud.crud.Check model class', { connection: state.table.databaseConnection }) }}</div>
<div>{{ t('crud.crud.There is no connection attribute in model class') }}</div>
</div>
</el-form-item>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'custom'"
prop="remoteUrl"
:label="t('crud.crud.api url')"
type="string"
v-model="state.remoteSelectPre.form.remoteUrl"
:placeholder="t('crud.crud.api url example')"
/>
<FormItem
v-if="state.remoteSelectPre.form.sourceConfigType == 'custom'"
:label="t('crud.crud.remote-primary-table-alias')"
type="string"
v-model="state.remoteSelectPre.form.primaryTableAlias"
:block-help="
t(
'crud.crud.If the remote interface query involves associated query of multiple tables, enter the alias of the primary data table here'
)
"
>
<template #append>.{{ state.remoteSelectPre.form.pk }}</template>
</FormItem>
<el-form-item :label="t('Reminder')">
<div class="block-help">
{{ t('crud.crud.Design remote select tips') }}
</div>
</el-form-item>
</div>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - 88px)'">
<el-button @click="onCancelRemoteSelect">{{ $t('Cancel') }}</el-button>
<el-button v-blur @click="onSaveRemoteSelect" type="primary">
{{ $t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
@close="closeConfirmGenerate"
class="ba-operate-dialog confirm-generate-dialog"
:model-value="state.confirmGenerate.show"
:title="t('crud.crud.Confirm CRUD code generation')"
>
<div class="confirm-generate-dialog-body">
<el-alert
v-if="state.confirmGenerate.controller"
:title="t('crud.crud.The controller already exists Continuing to generate will automatically overwrite the existing code!')"
center
type="error"
/>
<el-alert
v-if="showTableConflictConfirmGenerate()"
:title="
t(
'crud.crud.The data table already exists Continuing to generate will automatically delete the original table and create a new one!'
)
"
class="mt-10"
center
type="error"
/>
<el-alert
v-if="state.confirmGenerate.menu"
:title="
t(
'crud.crud.The menu rule with the same name already exists The menu and permission node will not be created in this generation'
)
"
class="mt-10"
center
type="error"
/>
</div>
<template #footer>
<div class="confirm-generate-dialog-footer">
<el-button @click="closeConfirmGenerate">{{ $t('Cancel') }}</el-button>
<el-button :loading="state.loading.generate" v-blur @click="startGenerate" type="primary">
{{ t('crud.crud.Continue building') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog class="ba-operate-dialog design-change-log-dialog" width="20%" v-model="state.showDesignChangeLog">
<template #header>
<div v-drag="['.design-change-log-dialog', '.el-dialog__header']">
{{ t('crud.crud.Data table design changes preview') }}
</div>
</template>
<el-scrollbar max-height="400px">
<template v-if="state.table.designChange.length">
<el-timeline class="design-change-log-timeline">
<el-timeline-item
v-for="(item, idx) in state.table.designChange"
:key="idx"
:type="getTableDesignTimelineType(item.type)"
:hollow="true"
:hide-timestamp="true"
>
<div class="design-timeline-box">
<el-checkbox v-model="item.sync" :label="getTableDesignChangeContent(item)" size="small" />
</div>
</el-timeline-item>
</el-timeline>
<span class="design-change-tips">{{ t('crud.crud.designChangeTips') }}</span>
</template>
<div class="design-change-tips" v-else>暂无表设计变更</div>
<FormItem
:label="t('crud.crud.tableReBuild')"
class="rebuild-form-item"
v-model="state.table.rebuild"
type="radio"
:input-attr="{
border: true,
content: { No: t('crud.crud.No'), Yes: t('crud.crud.Yes') },
}"
:block-help="t('crud.crud.tableReBuildBlockHelp')"
/>
</el-scrollbar>
<template #footer>
<div class="confirm-generate-dialog-footer">
<el-button @click="state.showDesignChangeLog = false">
{{ t('Confirm') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { useTemplateRefsList } from '@vueuse/core'
import type { FormItemRule, MessageHandler, TimelineItemProps } from 'element-plus'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { cloneDeep, isEmpty, range } from 'lodash-es'
import type { SortableEvent } from 'sortablejs'
import Sortable from 'sortablejs'
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { generate, generateCheck, getFileData, parseFieldData, postLogStart, uploadCompleted, uploadLog } from '/@/api/backend/crud'
import { getDatabaseConnectionListUrl, getTableFieldList, getTableListUrl } from '/@/api/common'
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useTerminal } from '/@/stores/terminal'
import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { buildValidatorData, regularVarName } from '/@/utils/validate'
import { reloadServer } from '/@/utils/vite'
import type { FieldItem, TableDesignChange, TableDesignChangeType } from '/@/views/backend/crud/index'
import { changeStep, state as crudState, designTypes, fieldItem, getTableAttr, tableFieldsKey } from '/@/views/backend/crud/index'
let nameRepeatCount = 1
const { t } = useI18n()
const config = useConfig()
const terminal = useTerminal()
const formRef = useTemplateRef('formRef')
const tabsRefs = useTemplateRefsList<HTMLElement>()
const designWindowRef = useTemplateRef('designWindowRef')
const state: {
loading: {
init: boolean
generate: boolean
remoteSelect: boolean
}
sync: number
table: {
name: string
comment: string
quickSearchField: string[]
defaultSortField: string
formFields: string[]
columnFields: string[]
defaultSortType: string
generateRelativePath: string
isCommonModel: number
modelFile: string
controllerFile: string
validateFile: string
webViewsDir: string
databaseConnection: string
designChange: TableDesignChange[]
rebuild: string
}
fields: FieldItem[]
activateField: number
fieldCollapseName: string[]
remoteSelectPre: {
show: boolean
index: number
fieldList: anyObj
modelFileList: anyObj
controllerFileList: anyObj
loading: boolean
hideDelField: boolean
form: {
table: string
pk: string
label: string
joinField: string[]
sourceConfigType: 'crud' | 'custom'
remoteUrl: string
modelFile: string
controllerFile: string
primaryTableAlias: string
}
}
showHeaderSeniorConfig: boolean
confirmGenerate: {
show: boolean
menu: boolean
table: boolean
controller: boolean
}
draggingField: boolean
showDesignChangeLog: boolean
error: {
tableName: string
fieldName: MessageHandler | null
fieldNameDuplication: MessageHandler | null
}
} = reactive({
loading: {
init: false,
generate: false,
remoteSelect: false,
},
sync: 0,
table: {
name: '',
comment: '',
quickSearchField: [],
defaultSortField: '',
formFields: [],
columnFields: [],
defaultSortType: 'desc',
generateRelativePath: '',
isCommonModel: 0,
modelFile: '',
controllerFile: '',
validateFile: '',
webViewsDir: '',
databaseConnection: '',
designChange: [],
rebuild: 'No',
},
fields: [],
activateField: -1,
fieldCollapseName: ['common', 'base', 'senior'],
remoteSelectPre: {
show: false,
index: -1,
fieldList: [],
modelFileList: [],
controllerFileList: [],
loading: false,
hideDelField: false,
form: {
table: '',
pk: '',
label: '',
joinField: [],
sourceConfigType: 'crud',
remoteUrl: '',
modelFile: '',
controllerFile: '',
primaryTableAlias: '',
},
},
showHeaderSeniorConfig: false,
confirmGenerate: {
show: false,
menu: false,
table: false,
controller: false,
},
draggingField: false,
showDesignChangeLog: false,
error: {
tableName: '',
fieldName: null,
fieldNameDuplication: null,
},
})
type TableKey = keyof typeof state.table
const onActivateField = (idx: number) => {
state.activateField = idx
}
const onFieldDesignTypeChange = (designType: string) => {
// 获取新的类型的数据
let fieldDesignData: FieldItem | null = null
for (const key in fieldItem) {
const fieldItemIndex = getArrayKey(fieldItem[key as keyof typeof fieldItem], 'designType', designType)
if (fieldItemIndex !== false) {
fieldDesignData = cloneDeep(fieldItem[key as keyof typeof fieldItem][fieldItemIndex])
break
}
}
if (!fieldDesignData) return false
// 主键重复检查
if (!primaryKeyRepeatCheck(fieldDesignData, state.activateField)) {
return false
}
// 选中字段数据
const field = cloneDeep(state.fields[state.activateField])
// 赋值新类型
field.designType = designType
// 保留字段的 table 和 form 数据,此处额外处理以便交付给 handleFieldAttr 函数
for (const tKey in field.table) {
field.table[tKey] = field.table[tKey].value
}
for (const tKey in field.form) {
field.form[tKey] = field.form[tKey].value
}
state.fields[state.activateField] = handleFieldAttr(field)
// 保留字段的 uuid
state.fields[state.activateField].uuid = field.uuid
// 询问是否切换至预设方案(除了字段名的属性全部重置)
ElMessageBox.confirm(t('crud.crud.Reset generate type attr'), t('Reminder'), {
confirmButtonText: t('Confirm') + t('Reset'),
cancelButtonText: t('crud.crud.Design efficiency'),
type: 'warning',
closeOnClickModal: false,
})
.then(() => {
// 记录字段属性更新
onFieldAttrChange()
// 删除快速搜索和排序,根据新类型重新赋值
clearFieldTableData(state.fields[state.activateField].uuid!)
// 重置属性,除了 name
const oldName = state.fields[state.activateField].name
state.fields[state.activateField] = handleFieldAttr(fieldDesignData)
state.fields[state.activateField].name = oldName
if (fieldDesignData.primaryKey) {
// 设置为默认排序字段、快速搜索字段
state.table.quickSearchField.push(state.fields[state.activateField].uuid!)
if (!state.table.defaultSortField) {
state.table.defaultSortField = state.fields[state.activateField].uuid!
}
}
if (fieldDesignData.designType == 'weigh') {
state.table.defaultSortField = state.fields[state.activateField].uuid!
}
// 远程下拉参数预填
if (['remoteSelect', 'remoteSelects'].includes(fieldDesignData.designType)) {
showRemoteSelectPre(state.activateField, true)
}
// 表单表格字段预定义
if (!fieldDesignData.formBuildExclude) {
state.table.formFields.push(state.fields[state.activateField].uuid!)
}
if (!fieldDesignData.tableBuildExclude) {
state.table.columnFields.push(state.fields[state.activateField].uuid!)
}
})
.catch(() => {})
}
/**
* 字段名修改
*/
const onFieldNameChange = (val: string, index: number) => {
const oldName = state.fields[index].name
state.fields[index].name = val
logTableDesignChange({
type: 'change-field-name',
index: state.activateField,
oldName: oldName,
newName: val,
})
}
/**
* 主键字段重复检测
*/
const primaryKeyRepeatCheck = (field: FieldItem, excludeIndex: number = -1) => {
if (field.primaryKey === true) {
const primaryKeyField = state.fields.find((item, index) => {
if (excludeIndex > -1 && index == excludeIndex) {
return false
}
return item.primaryKey
})
if (primaryKeyField) {
ElNotification({
type: 'error',
message: t('crud.crud.There can only be one primary key field'),
})
return false
}
}
return true
}
/**
* 全部字段的名称命名规则检测
*/
const fieldNameCheck = (showErrorType: 'ElNotification' | 'ElMessage') => {
if (state.error.fieldName) {
state.error.fieldName.close()
state.error.fieldName = null
}
for (const key in state.fields) {
if (!regularVarName(state.fields[key].name)) {
let msg = t(
'crud.crud.Field name is invalid It starts with a letter or underscore and cannot contain any character other than letters, digits, or underscores',
{ field: state.fields[key].name }
)
if (showErrorType == 'ElMessage') {
state.error.fieldName = ElMessage({
message: msg,
type: 'error',
duration: 0,
})
} else {
ElNotification({
type: 'error',
message: msg,
})
}
return false
}
}
return true
}
/**
* 全部字段的名称重复检测
*/
const fieldNameDuplicationCheck = (showErrorType: 'ElNotification' | 'ElMessage') => {
if (state.error.fieldNameDuplication) {
state.error.fieldNameDuplication.close()
state.error.fieldNameDuplication = null
}
for (const key in state.fields) {
let count = 0
for (const checkKey in state.fields) {
if (state.fields[key].name == state.fields[checkKey].name) {
count++
}
if (count > 1) {
let msg = t('crud.crud.Field name duplication', { field: state.fields[key].name })
if (showErrorType == 'ElMessage') {
state.error.fieldNameDuplication = ElMessage({
message: msg,
type: 'error',
duration: 0,
})
} else {
ElNotification({
type: 'error',
message: msg,
})
}
return false
}
}
}
return true
}
const onFieldAttrChange = () => {
logTableDesignChange({
type: 'change-field-attr',
index: state.activateField,
oldName: state.fields[state.activateField].name,
newName: '',
})
}
/**
* 从 state.table.* 清理某个字段的数据
*/
const clearFieldTableData = (uuid: string) => {
if (uuid == state.table.defaultSortField) {
state.table.defaultSortField = ''
}
for (const key in tableFieldsKey) {
const delIdx = (state.table[tableFieldsKey[key] as TableKey] as string[]).findIndex((item) => {
return item == uuid
})
if (delIdx != -1) {
;(state.table[tableFieldsKey[key] as TableKey] as string[]).splice(delIdx, 1)
}
}
}
const onDelField = (index: number) => {
if (!state.fields[index]) return
state.activateField = -1
clearFieldTableData(state.fields[index].uuid!)
logTableDesignChange({
type: 'del-field',
oldName: state.fields[index].name,
newName: '',
})
// 删除权重字段时,重设默认排序字段
if (state.fields[index].designType == 'weigh') {
const pkField = state.fields.find((item) => {
return ['pk', 'spk'].includes(item.designType)
})
if (pkField) {
state.table.defaultSortField = pkField.uuid!
}
}
state.fields.splice(index, 1)
}
const showRemoteSelectPre = (index: number, hideDelField = false) => {
state.remoteSelectPre.show = true
state.remoteSelectPre.loading = true
state.remoteSelectPre.index = index
state.remoteSelectPre.hideDelField = hideDelField
if (state.fields[index] && state.fields[index].form['remote-table'].value) {
state.remoteSelectPre.form.table = state.fields[index].form['remote-table'].value
state.remoteSelectPre.form.pk = state.fields[index].form['remote-pk'].value
state.remoteSelectPre.form.label = state.fields[index].form['remote-field'].value
state.remoteSelectPre.form.controllerFile = state.fields[index].form['remote-controller'].value
state.remoteSelectPre.form.modelFile = state.fields[index].form['remote-model'].value
state.remoteSelectPre.form.remoteUrl = state.fields[index].form['remote-url'].value
state.remoteSelectPre.form.sourceConfigType = state.fields[index].form['remote-source-config-type'].value
state.remoteSelectPre.form.primaryTableAlias = state.fields[index].form['remote-primary-table-alias'].value
state.remoteSelectPre.form.joinField = state.fields[index].form['relation-fields'].value.split(',')
getTableFieldList(state.fields[index].form['remote-table'].value, true, state.table.databaseConnection).then((res) => {
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
state.remoteSelectPre.fieldList = fieldSelect
})
if (isEmpty(state.remoteSelectPre.modelFileList) || isEmpty(state.remoteSelectPre.controllerFileList)) {
getFileData(state.fields[index].form['remote-table'].value).then((res) => {
state.remoteSelectPre.modelFileList = res.data.modelFileList
state.remoteSelectPre.controllerFileList = res.data.controllerFileList
})
}
}
state.remoteSelectPre.loading = false
}
const onEditField = (index: number, field: FieldItem) => {
if (['remoteSelect', 'remoteSelects'].includes(field.designType)) return showRemoteSelectPre(index)
}
const closeConfirmGenerate = () => {
state.confirmGenerate.show = false
}
const startGenerate = () => {
state.loading.generate = true
// 简化设计字段数据
const fields = cloneDeep(state.fields)
for (const key in fields) {
for (const tKey in fields[key].table) {
fields[key].table[tKey] = fields[key].table[tKey].value
}
for (const tKey in fields[key].form) {
fields[key].form[tKey] = fields[key].form[tKey].value
}
}
// 通过 uuid 获取字段 name
const table = cloneDeep(state.table)
if (table.defaultSortField) {
const defaultSortFieldIndex = getArrayKey(state.fields, 'uuid', table.defaultSortField)
if (defaultSortFieldIndex !== false) {
table.defaultSortField = state.fields[defaultSortFieldIndex].name
}
}
for (const key in tableFieldsKey) {
const names: string[] = []
const uuids = table[tableFieldsKey[key] as TableKey] as string[]
for (const uKey in uuids) {
const uuidFieldIndex = getArrayKey(state.fields, 'uuid', uuids[uKey])
if (uuidFieldIndex !== false) {
names.push(state.fields[uuidFieldIndex].name)
}
}
;(table[tableFieldsKey[key] as TableKey] as string[]) = names
}
generate({
type: crudState.type,
table,
fields,
})
.then((res) => {
const callback = () => {
const webViewsDir = state.table.webViewsDir.replace(/^web/, '.')
terminal.toggle(true)
terminal.addTask('npx.prettier', false, webViewsDir, () => {
terminal.toggle(false)
terminal.toggleDot(true)
nextTick(() => {
// 要求 Vite 服务端重启
if (import.meta.hot) {
reloadServer('crud')
} else {
ElNotification({
type: 'error',
message: t('crud.crud.Vite hot warning'),
})
}
})
})
}
if ((state.sync > 0 && config.crud.syncedUpdate === 'yes') || (state.sync == 0 && config.crud.syncType == 'automatic')) {
uploadLog({
logs: [
{
...res.data.crudLog,
public: config.crud.syncAutoPublic === 'yes' ? 1 : 0,
newLog: 1,
},
],
save: 1,
})
.then((res) => {
uploadCompleted({ syncIds: res.data.syncIds }).finally(() => {
callback()
})
})
.catch(() => {
callback()
})
} else {
callback()
}
})
.finally(() => {
state.loading.generate = false
closeConfirmGenerate()
})
}
const onGenerate = () => {
// 字段名称检查
if (!fieldNameCheck('ElNotification')) return
if (!fieldNameDuplicationCheck('ElNotification')) return
let msg = ''
// 主键检查
const pkIndex = state.fields.findIndex((item) => {
return item.primaryKey
})
if (pkIndex === -1) {
msg = t('crud.crud.Please design the primary key field!')
}
// 表名检查
if (!state.table.name) msg = t('crud.crud.Please enter the data table name!')
if (state.error.tableName) msg = t('crud.crud.Please enter the correct table name!')
if (msg) {
ElNotification({
type: 'error',
message: msg,
})
return
}
state.loading.generate = true
generateCheck({
table: state.table.name,
connection: state.table.databaseConnection,
webViewsDir: state.table.webViewsDir,
controllerFile: state.table.controllerFile,
})
.then(() => {
startGenerate()
})
.catch((res) => {
state.loading.generate = false
if (res.code == -1) {
state.confirmGenerate.menu = res.data.menu
state.confirmGenerate.table = res.data.table
state.confirmGenerate.controller = res.data.controller
if (showTableConflictConfirmGenerate() || state.confirmGenerate.controller || state.confirmGenerate.menu) {
state.confirmGenerate.show = true
} else {
startGenerate()
}
} else {
ElNotification({
type: 'error',
message: res.msg,
})
}
})
}
const showTableConflictConfirmGenerate = () => state.confirmGenerate.table && (crudState.type == 'create' || state.table.rebuild == 'Yes')
const onAbandonDesign = () => {
if (!state.table.name && !state.table.comment && !state.fields.length) {
return changeStep('start')
}
ElMessageBox.confirm(t('crud.crud.It is irreversible to give up the design Are you sure you want to give up?'), t('Reminder'), {
confirmButtonText: t('crud.crud.give up'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
changeStep('start')
})
.catch(() => {})
}
interface SortableEvt extends SortableEvent {
originalEvent?: DragEvent
}
/**
* 处理字段的属性
*/
const handleFieldAttr = (field: FieldItem) => {
field = cloneDeep(field)
const designTypeAttr = cloneDeep(designTypes[field.designType])
for (const tKey in field.form) {
if (designTypeAttr.form[tKey]) designTypeAttr.form[tKey].value = field.form[tKey]
if (tKey == 'image-multi' && field.form[tKey]) {
designTypeAttr.table['render'] = getTableAttr('render', 'images')
}
}
for (const tKey in field.table) {
if (designTypeAttr.table[tKey]) designTypeAttr.table[tKey].value = field.table[tKey]
}
field.form = designTypeAttr.form
field.table = designTypeAttr.table
field.uuid = uuid()
return field
}
/**
* 根据字段字典重新生成字段的数据类型
*/
const onFieldCommentChange = (comment: string) => {
onFieldAttrChange()
if (['enum', 'set'].includes(state.fields[state.activateField].type)) {
if (!comment) {
state.fields[state.activateField].dataType = `${state.fields[state.activateField].type}()`
return
}
comment = comment.replaceAll('', ':')
comment = comment.replaceAll('', ',')
let comments = comment.split(':')
if (comments[1]) {
comments = comments[1].split(',')
comments = comments
.map((value) => {
if (!value) return ''
let temp = value.split('=')
if (temp[0] && temp[1]) {
return `'${temp[0]}'`
}
return ''
})
.filter((str: string) => str != '')
// 字段数据类型
state.fields[state.activateField].dataType = `${state.fields[state.activateField].type}(${comments.join(',')})`
}
}
}
const loadData = () => {
tableDesignChangeInit()
if (!['db', 'sql', 'log'].includes(crudState.type)) return
state.loading.init = true
// 从历史记录开始
if (crudState.type == 'log') {
postLogStart(crudState.startData.logId, crudState.startData.logType)
.then((res) => {
// 字段数据
const fields = res.data.fields
for (const key in fields) {
const field = handleFieldAttr(fields[key])
// 默认值和默认值类型分析
if (typeof field.defaultType == 'undefined') {
if (field.default && ['none', 'null', 'empty string'].includes(field.default)) {
field.defaultType = field.default.toUpperCase() as 'EMPTY STRING' | 'NULL' | 'NONE'
field.default = ''
} else {
field.defaultType = 'INPUT'
}
}
state.fields.push(field)
}
// 表数据
if (res.data.table.defaultSortField) {
const defaultSortFieldNameIndex = getArrayKey(state.fields, 'name', res.data.table.defaultSortField)
if (defaultSortFieldNameIndex !== false) {
res.data.table.defaultSortField = state.fields[defaultSortFieldNameIndex].uuid!
}
}
for (const key in tableFieldsKey) {
const uuids: string[] = []
const names = res.data.table[tableFieldsKey[key] as TableKey] as string[]
for (const nKey in names) {
const nameFieldIndex = getArrayKey(state.fields, 'name', names[nKey])
if (nameFieldIndex !== false) {
uuids.push(state.fields[nameFieldIndex].uuid!)
}
}
;(res.data.table[tableFieldsKey[key] as TableKey] as string[]) = uuids
}
state.sync = res.data.sync
state.table = res.data.table
tableDesignChangeInit()
if (res.data.table.empty) {
state.table.rebuild = 'Yes'
}
state.table.isCommonModel = parseInt(res.data.table.isCommonModel)
state.table.databaseConnection = res.data.table.databaseConnection ? res.data.table.databaseConnection : ''
})
.finally(() => {
state.loading.init = false
})
return
}
// 从数据表或sql开始
parseFieldData({
type: crudState.type,
table: crudState.startData.table,
sql: crudState.startData.sql,
connection: crudState.startData.databaseConnection,
})
.then((res) => {
let fields = []
for (const key in res.data.columns) {
const field = handleFieldAttr(res.data.columns[key])
if (!['id', 'update_time', 'create_time', 'updatetime', 'createtime'].includes(field.name)) {
state.table.formFields.push(field.uuid!)
}
if (!['textarea', 'file', 'files', 'editor', 'password', 'array'].includes(field.designType)) {
state.table.columnFields.push(field.uuid!)
}
if (field.designType == 'pk') {
state.table.defaultSortField = field.uuid!
state.table.quickSearchField.push(field.uuid!)
}
if (field.designType == 'weigh') {
state.table.defaultSortField = field.uuid!
}
fields.push(field)
}
state.fields = fields
state.table.comment = res.data.comment
state.table.databaseConnection = crudState.startData.databaseConnection
if (res.data.empty) {
state.table.rebuild = 'Yes'
}
if (crudState.type == 'db' && crudState.startData.table) {
state.table.name = crudState.startData.table
onTableChange(crudState.startData.table)
}
})
.finally(() => {
state.loading.init = false
})
}
/**
* 字段名称重复时自动重命名
*/
const autoRenameRepeatField = (fieldName: string) => {
const nameRepeatKey = getArrayKey(state.fields, 'name', fieldName)
if (nameRepeatKey !== false) {
fieldName += nameRepeatCount
nameRepeatCount++
return autoRenameRepeatField(fieldName)
} else {
return fieldName
}
}
onMounted(() => {
loadData()
const sortable = Sortable.create(designWindowRef.value!, {
group: 'design-field',
animation: 200,
filter: '.design-field-empty',
onAdd: (evt: SortableEvt) => {
const name = evt.originalEvent?.dataTransfer?.getData('name')
const field = fieldItem[name as keyof typeof fieldItem]
if (field && field[evt.oldIndex!]) {
const data = handleFieldAttr(field[evt.oldIndex!])
// 主键重复检测
if (data.primaryKey) {
if (primaryKeyRepeatCheck(data)) {
// 设置为默认排序字段、快速搜索字段
state.table.quickSearchField.push(data.uuid!)
if (!state.table.defaultSortField) {
state.table.defaultSortField = data.uuid!
}
} else {
return evt.item.remove()
}
}
// 出现权重字段则以其排序
if (data.designType == 'weigh') {
state.table.defaultSortField = data.uuid!
}
// name 重复时,自动重命名
data.name = autoRenameRepeatField(data.name)
// 插入字段
state.fields.splice(evt.newIndex!, 0, data)
logTableDesignChange({
type: 'add-field',
index: evt.newIndex!,
newName: data.name,
oldName: '',
after: evt.newIndex === 0 ? 'FIRST FIELD' : state.fields[evt.newIndex! - 1].name,
})
// 远程下拉参数预填
if (['remoteSelect', 'remoteSelects'].includes(data.designType)) {
showRemoteSelectPre(evt.newIndex!, true)
}
// 表单表格字段预定义
if (!data.formBuildExclude) {
state.table.formFields.push(data.uuid!)
}
if (!data.tableBuildExclude) {
state.table.columnFields.push(data.uuid!)
}
}
evt.item.remove()
nextTick(() => {
sortable.sort(range(state.fields.length).map((value) => value.toString()))
})
},
onEnd: (evt) => {
const temp = state.fields[evt.oldIndex!]
state.fields.splice(evt.oldIndex!, 1)
state.fields.splice(evt.newIndex!, 0, temp)
logTableDesignChange({
type: 'change-field-order',
index: evt.newIndex!,
newName: '',
oldName: temp.name,
after: evt.newIndex === 0 ? 'FIRST FIELD' : state.fields[evt.newIndex! - 1].name,
})
nextTick(() => {
sortable.sort(range(state.fields.length).map((value) => value.toString()))
})
},
})
tabsRefs.value.forEach((item, index) => {
Sortable.create(item, {
sort: false,
group: {
name: 'design-field',
pull: 'clone',
put: false,
},
animation: 200,
setData: (dataTransfer) => {
dataTransfer.setData('name', Object.keys(fieldItem)[index])
},
onStart: () => {
state.draggingField = true
},
onEnd: () => {
state.draggingField = false
},
})
})
})
/**
* 修改表名
* @param val 新表名
*/
const onTableNameChange = (val: string) => {
if (!val) return (state.error.tableName = '')
if (/^[a-z_][a-z0-9_]*$/.test(val)) {
state.error.tableName = ''
onTableChange(val)
} else {
state.error.tableName = t('crud.crud.Use lower case underlined for table names')
}
tableDesignChangeInit()
}
const tableDesignChangeInit = () => {
state.table.rebuild = 'No'
state.table.designChange = []
}
/**
* 预获取一个表的生成数据
* @param val 新表名
*/
const onTableChange = (val: string) => {
if (!val) return
getFileData(val, state.table.isCommonModel).then((res) => {
state.table.modelFile = res.data.modelFile
state.table.controllerFile = res.data.controllerFile
state.table.validateFile = res.data.validateFile
state.table.webViewsDir = res.data.webViewsDir
state.table.generateRelativePath = val.replaceAll('/', '\\')
})
}
const onChangeCommonModel = () => {
onTableChange(state.table.generateRelativePath)
}
const onJoinTableChange = () => {
if (!state.remoteSelectPre.form.table) return
// 重置远程下拉信息表单
resetRemoteSelectForm(['table'])
state.loading.remoteSelect = true
getTableFieldList(state.remoteSelectPre.form.table, true, state.table.databaseConnection)
.then((res) => {
state.remoteSelectPre.form.pk = res.data.pk
const preLabel = ['name', 'title', 'username', 'nickname']
for (const key in res.data.fieldList) {
if (preLabel.includes(key)) {
state.remoteSelectPre.form.label = key
state.remoteSelectPre.form.joinField.push(key)
break
}
}
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
state.remoteSelectPre.fieldList = fieldSelect
})
.finally(() => {
state.loading.remoteSelect = false
})
getFileData(state.remoteSelectPre.form.table).then((res) => {
state.remoteSelectPre.modelFileList = res.data.modelFileList
state.remoteSelectPre.controllerFileList = res.data.controllerFileList
if (Object.keys(res.data.modelFileList).includes(res.data.modelFile)) {
state.remoteSelectPre.form.modelFile = res.data.modelFile
}
if (Object.keys(res.data.controllerFileList).includes(res.data.controllerFile)) {
state.remoteSelectPre.form.controllerFile = res.data.controllerFile
}
})
}
const onSaveRemoteSelect = () => {
const submitCallback = () => {
// 修改字段名
if (state.fields[state.remoteSelectPre.index].name == 'remote_select') {
const newName =
state.remoteSelectPre.form.table + (state.fields[state.remoteSelectPre.index].designType == 'remoteSelect' ? '_id' : '_ids')
onFieldNameChange(newName, state.remoteSelectPre.index)
}
state.fields[state.remoteSelectPre.index].form['remote-table'].value = state.remoteSelectPre.form.table
state.fields[state.remoteSelectPre.index].form['remote-pk'].value = state.remoteSelectPre.form.pk
state.fields[state.remoteSelectPre.index].form['remote-field'].value = state.remoteSelectPre.form.label
state.fields[state.remoteSelectPre.index].form['remote-controller'].value = state.remoteSelectPre.form.controllerFile
state.fields[state.remoteSelectPre.index].form['remote-model'].value = state.remoteSelectPre.form.modelFile
state.fields[state.remoteSelectPre.index].form['remote-url'].value = state.remoteSelectPre.form.remoteUrl
state.fields[state.remoteSelectPre.index].form['remote-source-config-type'].value = state.remoteSelectPre.form.sourceConfigType
state.fields[state.remoteSelectPre.index].form['remote-primary-table-alias'].value = state.remoteSelectPre.form.primaryTableAlias
state.fields[state.remoteSelectPre.index].form['relation-fields'].value =
state.fields[state.remoteSelectPre.index].designType == 'remoteSelect'
? state.remoteSelectPre.form.joinField.join(',')
: state.remoteSelectPre.form.label
state.remoteSelectPre.index = -1
state.remoteSelectPre.show = false
resetRemoteSelectForm()
}
if (formRef.value) {
formRef.value.validate((valid) => {
if (valid) {
submitCallback()
}
})
}
}
const onCancelRemoteSelect = () => {
state.remoteSelectPre.show = false
resetRemoteSelectForm()
if (state.remoteSelectPre.index !== -1 && state.remoteSelectPre.hideDelField) {
onDelField(state.remoteSelectPre.index)
}
}
const resetRemoteSelectForm = (excludes: string[] = []) => {
for (const key in state.remoteSelectPre.form) {
if (excludes.includes(key)) continue
if (key == 'joinField') {
state.remoteSelectPre.form[key] = []
} else if (key == 'sourceConfigType') {
state.remoteSelectPre.form[key] = 'crud'
} else {
;(state.remoteSelectPre.form[key as keyof typeof state.remoteSelectPre.form] as string) = ''
}
}
}
const remoteSelectPreFormRules: Partial<Record<string, FormItemRule[]>> = reactive({
table: [buildValidatorData({ name: 'required', title: t('crud.crud.remote-table') })],
pk: [buildValidatorData({ name: 'required', title: t('crud.crud.Drop down value field') })],
label: [buildValidatorData({ name: 'required', title: t('crud.crud.Drop down label field') })],
joinField: [buildValidatorData({ name: 'required', title: t('crud.crud.Fields displayed in the table') })],
controllerFile: [buildValidatorData({ name: 'required', title: t('crud.crud.Controller position') })],
modelFile: [buildValidatorData({ name: 'required', title: t('crud.crud.Data Model Location') })],
remoteUrl: [buildValidatorData({ name: 'required', title: t('crud.crud.remote-url') })],
})
const logTableDesignChange = (data: TableDesignChange) => {
if (crudState.type == 'create') return
let push = true
if (data.type == 'change-field-name') {
for (const key in state.table.designChange) {
// 有属性修改记录的字段被改名-单独循环防止字段再次改名后造成找不到属性修改记录
if (state.table.designChange[key].type == 'change-field-attr' && data.oldName == state.table.designChange[key].oldName) {
state.table.designChange[key].oldName = data.newName
}
// 有排序记录的字段被改名
if (state.table.designChange[key].type == 'change-field-order' && data.oldName == state.table.designChange[key].oldName) {
state.table.designChange[key].oldName = data.newName
}
if (state.table.designChange[key].after == data.oldName) {
state.table.designChange[key].after = data.newName
}
}
for (const key in state.table.designChange) {
// 新增字段改名
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
state.table.designChange[key].newName = data.newName
push = false
// 同一字段不会有两条新增记录
break
}
// 字段再次改名
if (state.table.designChange[key].type == 'change-field-name' && state.table.designChange[key].newName == data.oldName) {
data.oldName = state.table.designChange[key].oldName
state.table.designChange[key] = data
// 取消字段改名
if (state.table.designChange[key].newName == state.table.designChange[key].oldName) {
state.table.designChange.splice(key as any, 1)
}
push = false
break
}
}
} else if (data.type == 'del-field') {
let add = false
state.table.designChange = state.table.designChange.filter((item) => {
// 新增的字段被删除
add = item.type == 'add-field' && item.newName == data.oldName
// 有属性修改记录的字段被删除
const attr = item.type == 'change-field-attr' && item.oldName == data.oldName
// 有排序记录的字段被删除
const order = item.type == 'change-field-order' && item.oldName == data.oldName
return !add && !attr && !order
})
// 有改名记录的字段被删除(延后单独处理避免先改名再改属性的情况)
state.table.designChange = state.table.designChange.filter((item) => {
const name = item.type == 'change-field-name' && item.newName == data.oldName
if (name) data.oldName = item.oldName
return !name
})
// 添加的字段需要过滤掉记录同时不记录删除操作
if (add) push = false
for (const key in state.table.designChange) {
// 同一字段名称多次删除(删除后添加再删除)
if (state.table.designChange[key].type == 'del-field' && state.table.designChange[key].oldName == data.oldName) {
push = false
break
}
}
} else if (data.type == 'change-field-attr') {
// 先改名再改属性无需处理
for (const key in state.table.designChange) {
// 重复修改属性只记录一次
if (state.table.designChange[key].type == 'change-field-attr' && state.table.designChange[key].oldName == data.oldName) {
push = false
break
}
// 新增的字段无需记录属性修改
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
push = false
break
}
}
} else if (data.type == 'change-field-order') {
for (const key in state.table.designChange) {
// 新增的字段
if (state.table.designChange[key].type == 'add-field' && state.table.designChange[key].newName == data.oldName) {
// 更新排序设定
state.table.designChange[key].after = data.after
push = false
break
}
// 重复的排序记录
if (state.table.designChange[key].type == 'change-field-order' && state.table.designChange[key].oldName == data.oldName) {
state.table.designChange[key] = data
push = false
break
}
}
}
data.sync = true
if (push) state.table.designChange.push(data)
}
const getTableDesignChangeContent = (data: TableDesignChange): string => {
switch (data.type) {
case 'add-field':
return t('crud.crud.Add field') + ' ' + data.newName
case 'change-field-attr':
return t('crud.crud.Modify field properties') + ' ' + data.oldName
case 'change-field-name':
return t('crud.crud.Modify field name') + ' ' + data.oldName + ' => ' + data.newName
case 'del-field':
return t('crud.crud.Delete field') + ' ' + data.oldName
case 'change-field-order':
return (
t('crud.crud.Modify field order') +
' ' +
data.oldName +
' => ' +
(data.after == 'FIRST FIELD' ? t('crud.crud.First field') : data.after + ' ' + t('crud.crud.After'))
)
default:
return t('Unknown')
}
}
const getTableDesignTimelineType = (type: TableDesignChangeType): TimelineItemProps['type'] => {
let timeline = ''
switch (type) {
case 'change-field-name':
timeline = 'warning'
break
case 'del-field':
timeline = 'danger'
break
case 'add-field':
timeline = 'primary'
break
case 'change-field-attr':
timeline = 'success'
break
case 'change-field-order':
timeline = 'info'
break
default:
timeline = 'success'
break
}
return timeline as TimelineItemProps['type']
}
</script>
<style scoped lang="scss">
.form-item-position-right {
display: flex !important;
align-items: center;
:deep(.el-form-item__label) {
margin-bottom: 0 !important;
}
}
.default-main {
margin-bottom: 0;
}
.mt-10 {
margin-top: 10px;
}
.mr-20 {
margin-right: 20px;
}
.field-collapse :deep(.el-collapse-item__header) {
padding-left: 10px;
user-select: none;
}
.field-box {
padding: 10px;
}
.field-item {
display: inline-block;
padding: 3px 16px;
border: 1px dashed var(--el-border-color);
border-radius: var(--el-border-radius-base);
margin: 6px;
cursor: pointer;
user-select: none;
&:hover {
border-color: var(--el-color-primary);
}
}
.header-config-box {
position: relative;
.header-senior-config {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
height: 24px;
bottom: -24px;
padding: 4px 20px;
padding-top: 0;
left: calc(50% - 10px);
font-size: var(--el-font-size-small);
border-bottom-left-radius: 50px;
border-bottom-right-radius: 50px;
background-color: var(--ba-bg-color-overlay);
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
.senior-config-arrow-icon {
margin-left: 4px;
}
}
}
.header-senior-config-box {
width: 100%;
padding: 10px;
background-color: var(--ba-bg-color-overlay);
}
.header-senior-config-form {
width: 50%;
:deep(.el-form-item__label) {
justify-content: flex-start;
}
}
.header-box {
display: flex;
align-items: center;
height: v-bind("state.error.tableName ? '70px':'60px'");
padding: 10px;
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
transition: 0.1s;
.header,
.header-item-box {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
white-space: nowrap;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.header-item-box {
width: 50%;
}
.table-name-item {
flex: 3;
}
.table-comment-item {
flex: 4;
}
.header-right {
margin-left: auto;
.design-change-log {
margin-right: 10px;
}
}
}
.default-sort-field-box {
display: flex;
.default-sort-field {
flex: 6;
}
.default-sort-field-type {
flex: 3;
}
}
.fields-box {
margin-top: 36px;
}
.design-field-empty {
display: flex;
height: 100%;
color: var(--el-color-info);
font-size: var(--el-font-size-medium);
align-items: center;
justify-content: center;
}
.design-window {
overflow-x: auto;
height: calc(100vh - 200px);
border-radius: var(--el-border-radius-base);
background-color: var(--ba-bg-color-overlay);
border: v-bind('state.draggingField ? "1px dashed var(--el-color-primary)":(state.fields.length ? "none":"1px dashed var(--el-border-color)")');
.design-field-box {
display: flex;
padding: 10px;
align-items: center;
border: 1px dashed var(--el-border-color);
border-radius: var(--el-border-radius-base);
margin-bottom: 2px;
cursor: pointer;
user-select: none;
.design-field {
padding-right: 10px;
}
.design-field-name-input {
width: 200px;
}
.design-field-name-comment {
width: 100px;
}
.design-field-right {
margin-left: auto;
}
&:hover {
border-color: var(--el-color-primary);
}
}
.design-field-box.activate {
border-color: var(--el-color-primary);
}
}
.field-inline {
display: flex;
:deep(.el-form-item) {
width: 46%;
margin-right: 2%;
}
}
.default-input {
margin-top: 10px;
}
.field-config {
overflow-x: auto;
height: calc(100vh - 200px);
padding: 20px;
background-color: var(--ba-bg-color-overlay);
}
:deep(.confirm-generate-dialog) .el-dialog__body {
height: unset;
}
.confirm-generate-dialog-body {
padding: 30px;
}
.confirm-generate-dialog-footer {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.design-change-log-dialog) .el-dialog__body {
height: unset;
padding-top: 20px;
.design-change-log-timeline {
padding-left: 10px;
.el-timeline-item .el-timeline-item__node {
top: 3px;
}
}
.design-change-tips {
display: block;
margin-bottom: 20px;
color: var(--el-color-info);
font-size: var(--el-font-size-small);
}
.rebuild-form-item {
padding-top: 20px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>

View File

@@ -0,0 +1,970 @@
import { reactive } from 'vue'
import { fieldData, npuaFalse } from '/@/components/baInput/helper'
import { i18n } from '/@/lang/index'
import { validatorType } from '/@/utils/validate'
/**
* 字段修改类型标识
* 改排序需要在表结构变更完成之后再单独处理所以标识独立
*/
export type TableDesignChangeType = 'change-field-name' | 'del-field' | 'add-field' | 'change-field-attr' | 'change-field-order'
export interface TableDesignChange {
type: TableDesignChangeType
// 字段在设计器中的数组 index
index?: number
// 字段旧名称(重命名、修改属性、删除)
oldName: string
// 字段新名称(重命名、添加)
newName: string
// 是否同步到数据表
sync?: boolean
// 此字段在 after 字段之后,值为`FIRST FIELD`表示它是第一个字段
after?: string
}
export const state: {
step: 'Start' | 'Design'
type: string
startData: {
sql: string
table: string
logId: string
logType: string
databaseConnection: string
}
} = reactive({
step: 'Start',
type: '',
startData: {
sql: '',
table: '',
logId: '',
logType: '',
databaseConnection: '',
},
})
export const changeStep = (type: string) => {
state.type = type
if (type == 'start') {
state.step = 'Start'
for (const key in state.startData) {
state.startData[key as keyof typeof state.startData] = ''
}
} else {
state.step = 'Design'
}
}
export interface FieldItem {
index?: number
title: string
name: string
type: string
dataType?: string
length: number
precision: number
default?: string
defaultType: 'INPUT' | 'EMPTY STRING' | 'NULL' | 'NONE'
null: boolean
primaryKey: boolean
unsigned: boolean
autoIncrement: boolean
comment: string
designType: string
formBuildExclude?: boolean
tableBuildExclude?: boolean
table: anyObj
form: anyObj
uuid?: string
}
export const fieldItem: {
common: FieldItem[]
base: FieldItem[]
senior: FieldItem[]
} = {
common: [
{
title: i18n.global.t('crud.state.Primary key'),
name: 'id',
comment: 'ID',
designType: 'pk',
formBuildExclude: true,
table: {},
form: {},
...fieldData.number,
defaultType: 'NONE',
null: false,
primaryKey: true,
unsigned: true,
autoIncrement: true,
},
{
title: i18n.global.t('crud.state.Primary key (Snowflake ID)'),
name: 'id',
comment: 'ID',
designType: 'spk',
formBuildExclude: true,
table: {},
form: {},
...fieldData.number,
type: 'bigint',
length: 20,
defaultType: 'NONE',
null: false,
primaryKey: true,
unsigned: true,
},
{
title: i18n.global.t('State'),
name: 'status',
comment: i18n.global.t('crud.state.Status:0=Disabled,1=Enabled'),
designType: 'switch',
table: {},
form: {},
...fieldData.switch,
default: '1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('crud.state.remarks'),
name: 'remark',
comment: i18n.global.t('crud.state.remarks'),
designType: 'textarea',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.textarea,
},
{
title: i18n.global.t('crud.state.Weight (drag and drop sorting)'),
name: 'weigh',
comment: i18n.global.t('Weigh'),
designType: 'weigh',
table: {},
form: {},
...fieldData.number,
},
{
title: i18n.global.t('Update time'),
name: 'update_time',
comment: i18n.global.t('Update time'),
designType: 'timestamp',
formBuildExclude: true,
table: {},
form: {},
...fieldData.datetime,
},
{
title: i18n.global.t('Create time'),
name: 'create_time',
comment: i18n.global.t('Create time'),
designType: 'timestamp',
formBuildExclude: true,
table: {},
form: {},
...fieldData.datetime,
},
{
title: i18n.global.t('crud.state.Remote Select (association table)'),
name: 'remote_select',
comment: i18n.global.t('utils.remote select'),
designType: 'remoteSelect',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.remoteSelect,
},
],
base: [
{
title: i18n.global.t('utils.string'),
name: 'string',
comment: i18n.global.t('utils.string'),
designType: 'string',
table: {},
form: {},
...fieldData.string,
},
{
title: i18n.global.t('utils.image'),
name: 'image',
comment: i18n.global.t('utils.image'),
designType: 'image',
table: {},
form: {},
...fieldData.image,
},
{
title: i18n.global.t('utils.file'),
name: 'file',
comment: i18n.global.t('utils.file'),
designType: 'file',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.file,
},
{
title: i18n.global.t('utils.radio'),
name: 'radio',
dataType: "enum('opt0','opt1')",
comment: i18n.global.t('crud.state.Radio:opt0=Option1,opt1=Option2'),
designType: 'radio',
table: {},
form: {},
...fieldData.radio,
default: 'opt0',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.checkbox'),
name: 'checkbox',
dataType: "set('opt0','opt1')",
comment: i18n.global.t('crud.state.Checkbox:opt0=Option1,opt1=Option2'),
designType: 'checkbox',
table: {},
form: {},
...fieldData.checkbox,
default: 'opt0,opt1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.select'),
name: 'select',
dataType: "enum('opt0','opt1')",
comment: i18n.global.t('crud.state.Select:opt0=Option1,opt1=Option2'),
designType: 'select',
table: {},
form: {},
...fieldData.select,
default: 'opt0',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.switch'),
name: 'switch',
comment: i18n.global.t('crud.state.Switch:0=off,1=on'),
designType: 'switch',
table: {},
form: {},
...fieldData.switch,
default: '1',
defaultType: 'INPUT',
},
{
title: i18n.global.t('utils.rich Text'),
name: 'editor',
comment: i18n.global.t('utils.rich Text'),
designType: 'editor',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.editor,
},
{
title: i18n.global.t('utils.textarea'),
name: 'textarea',
comment: i18n.global.t('utils.textarea'),
designType: 'textarea',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.textarea,
},
{
title: i18n.global.t('utils.number'),
name: 'number',
comment: i18n.global.t('utils.number'),
designType: 'number',
table: {},
form: {},
...fieldData.number,
},
{
title: i18n.global.t('utils.float'),
name: 'float',
type: 'decimal',
length: 5,
precision: 2,
defaultType: 'NULL',
...npuaFalse(),
null: true,
comment: i18n.global.t('utils.float'),
designType: 'float',
table: {},
form: {},
},
{
title: i18n.global.t('utils.password'),
name: 'password',
comment: i18n.global.t('utils.password'),
designType: 'password',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.password,
},
{
title: i18n.global.t('utils.date'),
name: 'date',
comment: i18n.global.t('utils.date'),
designType: 'date',
table: {},
form: {},
...fieldData.date,
},
{
title: i18n.global.t('utils.time'),
name: 'time',
comment: i18n.global.t('utils.time'),
designType: 'time',
table: {},
form: {},
...fieldData.time,
},
{
title: i18n.global.t('utils.time date'),
name: 'datetime',
type: 'datetime',
length: 0,
precision: 0,
defaultType: 'NULL',
...npuaFalse(),
null: true,
comment: i18n.global.t('utils.time date'),
designType: 'datetime',
table: {},
form: {},
},
{
title: i18n.global.t('utils.year'),
name: 'year',
comment: i18n.global.t('utils.year'),
designType: 'year',
table: {},
form: {},
...fieldData.year,
},
{
title: i18n.global.t('crud.state.Time date (timestamp storage)'),
name: 'timestamp',
comment: i18n.global.t('utils.time date'),
designType: 'timestamp',
table: {},
form: {},
...fieldData.datetime,
},
],
senior: [
{
title: i18n.global.t('utils.array'),
name: 'array',
comment: i18n.global.t('utils.array'),
designType: 'array',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.array,
},
{
title: i18n.global.t('utils.city select'),
name: 'city',
comment: i18n.global.t('utils.city select'),
designType: 'city',
table: {},
form: {},
...fieldData.city,
},
{
title: i18n.global.t('utils.icon select'),
name: 'icon',
comment: i18n.global.t('utils.icon select'),
designType: 'icon',
table: {},
form: {},
...fieldData.icon,
},
{
title: i18n.global.t('utils.color picker'),
name: 'color',
comment: i18n.global.t('utils.color picker'),
designType: 'color',
table: {},
form: {},
...fieldData.color,
},
{
title: i18n.global.t('utils.image') + i18n.global.t('crud.state.Multi'),
name: 'images',
comment: i18n.global.t('utils.image'),
designType: 'images',
table: {},
form: {},
...fieldData.images,
},
{
title: i18n.global.t('utils.file') + i18n.global.t('crud.state.Multi'),
name: 'files',
comment: i18n.global.t('utils.file'),
designType: 'files',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.files,
},
{
title: i18n.global.t('utils.select') + i18n.global.t('crud.state.Multi'),
name: 'selects',
comment: i18n.global.t('crud.state.Select:opt0=Option1,opt1=Option2'),
designType: 'selects',
table: {},
form: {},
...fieldData.selects,
},
{
title: i18n.global.t('crud.state.Remote Select (Multi)'),
name: 'remote_select',
comment: i18n.global.t('utils.remote select'),
designType: 'remoteSelects',
tableBuildExclude: true,
table: {},
form: {},
...fieldData.remoteSelects,
},
],
}
const tableBaseAttr = {
render: {
type: 'select',
value: 'none',
options: {
none: i18n.global.t('None'),
icon: 'Icon',
switch: i18n.global.t('utils.switch'),
image: i18n.global.t('utils.image'),
images: i18n.global.t('utils.multi image'),
tag: 'Tag',
tags: 'Tags',
url: 'URL',
datetime: i18n.global.t('utils.time date'),
color: i18n.global.t('utils.color'),
},
},
operator: {
type: 'select',
value: 'eq',
options: {
false: i18n.global.t('crud.state.Disable Search'),
eq: 'eq =',
ne: 'ne !=',
gt: 'gt >',
egt: 'egt >=',
lt: 'lt <',
elt: 'elt <=',
LIKE: 'LIKE',
'NOT LIKE': 'NOT LIKE',
IN: 'IN',
'NOT IN': 'NOT IN',
RANGE: 'RANGE',
'NOT RANGE': 'NOT RANGE',
NULL: 'NULL',
'NOT NULL': 'NOT NULL',
FIND_IN_SET: 'FIND_IN_SET',
},
},
comSearchRender: {
type: 'select',
value: 'string',
options: {
string: i18n.global.t('utils.string'),
select: i18n.global.t('utils.select'),
remoteSelect: i18n.global.t('utils.remote select'),
time: i18n.global.t('utils.time') + i18n.global.t('utils.choice'),
date: i18n.global.t('utils.date') + i18n.global.t('utils.choice'),
datetime: i18n.global.t('utils.time date') + i18n.global.t('utils.choice'),
},
},
comSearchInputAttr: {
type: 'textarea',
value: '',
placeholder: i18n.global.t('crud.crud.comSearchInputAttrTip'),
attr: {
rows: 3,
},
},
sortable: {
type: 'select',
value: 'false',
options: {
false: i18n.global.t('Disable'),
custom: i18n.global.t('Enable'),
},
},
}
const formBaseAttr = {
validator: {
type: 'selects',
value: [],
options: validatorType,
},
validatorMsg: {
type: 'textarea',
value: '',
placeholder: i18n.global.t('crud.state.If left blank, the verifier title attribute will be filled in automatically'),
attr: {
rows: 3,
},
},
}
export const getTableAttr = (type: keyof typeof tableBaseAttr, val: string) => {
return {
...tableBaseAttr[type],
value: val,
}
}
const getFormAttr = (type: keyof typeof formBaseAttr, val: string[]) => {
return {
...formBaseAttr[type],
value: val,
}
}
export const designTypes: anyObj = {
pk: {
name: i18n.global.t('crud.state.Primary key'),
table: {
width: {
type: 'number',
value: 70,
},
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {},
},
spk: {
name: i18n.global.t('crud.state.Primary key (Snowflake ID)'),
table: {
width: {
type: 'number',
value: 180,
},
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {},
},
weigh: {
name: i18n.global.t('crud.state.Weight (automatically generate drag sort button)'),
table: {
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: formBaseAttr,
},
timestamp: {
name: i18n.global.t('crud.state.Time date (timestamp storage)'),
table: {
render: getTableAttr('render', 'datetime'),
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'datetime'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
width: {
type: 'number',
value: 160,
},
timeFormat: {
type: 'string',
value: 'yyyy-mm-dd hh:MM:ss',
},
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
string: {
name: i18n.global.t('utils.string'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'LIKE'),
},
form: formBaseAttr,
},
password: {
name: i18n.global.t('utils.password'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['password']),
},
},
number: {
name: i18n.global.t('utils.number'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'RANGE'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['number']),
step: {
type: 'number',
value: 1,
},
},
},
float: {
name: i18n.global.t('utils.float'),
table: {
render: getTableAttr('render', 'none'),
sortable: getTableAttr('sortable', 'false'),
operator: getTableAttr('operator', 'RANGE'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['float']),
step: {
type: 'number',
value: 1,
},
},
},
radio: {
name: i18n.global.t('utils.radio'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tag'),
},
form: formBaseAttr,
},
checkbox: {
name: i18n.global.t('utils.checkbox'),
table: {
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
},
form: formBaseAttr,
},
switch: {
name: i18n.global.t('utils.switch'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'switch'),
},
form: formBaseAttr,
},
textarea: {
name: i18n.global.t('utils.textarea'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
rows: {
type: 'number',
value: 3,
},
},
},
array: {
name: i18n.global.t('utils.array'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
datetime: {
name: i18n.global.t('utils.time date') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'datetime'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
width: {
type: 'number',
value: 160,
},
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
year: {
name: i18n.global.t('utils.year') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
sortable: getTableAttr('sortable', 'custom'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
date: {
name: i18n.global.t('utils.date') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'date'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['date']),
},
},
time: {
name: i18n.global.t('utils.time') + i18n.global.t('utils.choice'),
table: {
operator: getTableAttr('operator', 'RANGE'),
comSearchRender: getTableAttr('comSearchRender', 'time'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
sortable: getTableAttr('sortable', 'custom'),
},
form: formBaseAttr,
},
select: {
name: i18n.global.t('utils.select'),
table: {
operator: getTableAttr('operator', 'eq'),
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tag'),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: false,
},
},
},
selects: {
name: i18n.global.t('utils.select') + i18n.global.t('crud.state.Multi'),
table: {
sortable: getTableAttr('sortable', 'false'),
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: true,
},
},
},
remoteSelect: {
name: i18n.global.t('utils.remote select') + i18n.global.t('utils.choice'),
table: {
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'LIKE'),
comSearchRender: getTableAttr('comSearchRender', 'string'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: false,
},
'remote-pk': {
type: 'string',
value: 'id',
},
'remote-field': {
type: 'string',
value: 'name',
},
'remote-table': {
type: 'string',
value: '',
},
'remote-controller': {
type: 'string',
value: '',
},
'remote-model': {
type: 'string',
value: '',
},
'relation-fields': {
type: 'string',
value: '',
},
'remote-url': {
type: 'string',
value: '',
placeholder: i18n.global.t('crud.state.If it is not input, it will be automatically analyzed by the controller'),
},
'remote-primary-table-alias': {
type: 'string',
value: '',
},
'remote-source-config-type': {
type: 'hidden',
value: '',
},
},
},
remoteSelects: {
name: i18n.global.t('utils.remote select') + i18n.global.t('utils.choice') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'tags'),
operator: getTableAttr('operator', 'FIND_IN_SET'),
comSearchRender: getTableAttr('comSearchRender', 'remoteSelect'),
comSearchInputAttr: getTableAttr('comSearchInputAttr', ''),
},
form: {
...formBaseAttr,
'select-multi': {
type: 'switch',
value: true,
},
'remote-pk': {
type: 'string',
value: 'id',
},
'remote-field': {
type: 'string',
value: 'name',
},
'remote-table': {
type: 'string',
value: '',
},
'remote-controller': {
type: 'string',
value: '',
},
'remote-model': {
type: 'string',
value: '',
},
'relation-fields': {
type: 'string',
value: '',
},
'remote-url': {
type: 'string',
value: '',
placeholder: i18n.global.t('crud.state.If it is not input, it will be automatically analyzed by the controller'),
},
'remote-primary-table-alias': {
type: 'string',
value: '',
},
'remote-source-config-type': {
type: 'hidden',
value: '',
},
},
},
editor: {
name: i18n.global.t('utils.rich Text'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
validator: getFormAttr('validator', ['editorRequired']),
},
},
city: {
name: i18n.global.t('utils.city select'),
table: {
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
image: {
name: i18n.global.t('utils.image') + i18n.global.t('Upload'),
table: {
render: getTableAttr('render', 'image'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'image-multi': {
type: 'switch',
value: false,
},
},
},
images: {
name: i18n.global.t('utils.image') + i18n.global.t('Upload') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'images'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'image-multi': {
type: 'switch',
value: true,
},
},
},
file: {
name: i18n.global.t('utils.file') + i18n.global.t('Upload'),
table: {
render: getTableAttr('render', 'none'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'file-multi': {
type: 'switch',
value: false,
},
},
},
files: {
name: i18n.global.t('utils.file') + i18n.global.t('Upload') + i18n.global.t('crud.state.Multi'),
table: {
render: getTableAttr('render', 'none'),
operator: getTableAttr('operator', 'false'),
},
form: {
...formBaseAttr,
'file-multi': {
type: 'switch',
value: true,
},
},
},
icon: {
name: i18n.global.t('utils.icon select'),
table: {
render: getTableAttr('render', 'icon'),
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
color: {
name: i18n.global.t('utils.color picker'),
table: {
render: getTableAttr('render', 'color'),
operator: getTableAttr('operator', 'false'),
},
form: formBaseAttr,
},
}
export const tableFieldsKey = ['quickSearchField', 'formFields', 'columnFields']

View File

@@ -0,0 +1,36 @@
<template>
<div>
<component :is="state.step"></component>
</div>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onUnmounted, onMounted } from 'vue'
import Start from '/@/views/backend/crud/start.vue'
import Design from '/@/views/backend/crud/design.vue'
import { state } from '/@/views/backend/crud/index'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'crud/crud',
components: { Start, Design },
})
onMounted(() => {
closeHotUpdate('crud')
})
onActivated(() => {
closeHotUpdate('crud')
})
onDeactivated(() => {
openHotUpdate('crud')
})
onUnmounted(() => {
openHotUpdate('crud')
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,578 @@
<template>
<div>
<el-dialog
@close="emits('update:modelValue', false)"
width="70%"
:model-value="modelValue"
class="ba-crud-log-dialog"
:title="t('crud.crud.CRUD record')"
:append-to-body="true"
:destroy-on-close="true"
>
<TableHeader :buttons="['refresh', 'quickSearch', 'columnDisplay']" :quick-search-placeholder="t('crud.log.quick Search Fields')">
<template v-if="baAccount.token">
<el-tooltip :content="t('crud.log.Upload the selected design records to the cloud for cross-device use')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
@click="onUpload"
class="table-header-operate"
type="success"
>
<Icon color="#ffffff" name="fa fa-cloud-upload" />
<span class="table-header-operate-text">{{ t('Upload') }}</span>
</el-button>
</el-tooltip>
<el-tooltip :content="t('crud.log.Design records that have been synchronized to the cloud')" placement="top">
<el-button v-blur class="table-header-operate" @click="onLoadLogs" type="success">
<Icon color="#ffffff" name="fa fa-cloud-download" />
<span class="table-header-operate-text">{{ t('crud.log.Cloud record') }}</span>
</el-button>
</el-tooltip>
<el-button v-blur @click="toggleShowConfig(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-gear" />
<span class="table-header-operate-text">{{ t('crud.log.Settings') }}</span>
</el-button>
<el-button v-blur @click="toggleShowBaAccount(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-user-o" />
<span class="table-header-operate-text">{{ t('layouts.Member information') }}</span>
</el-button>
</template>
<template v-else>
<el-button v-blur @click="toggleShowBaAccount(true)" class="table-header-operate" type="primary">
<Icon name="fa fa-chain" />
<span class="table-header-operate-text">{{ t('crud.log.Login for backup design') }}</span>
</el-button>
</template>
</TableHeader>
<Table ref="tableRef">
<template #tableName>
<el-table-column :show-overflow-tooltip="true" prop="table_name" align="center" :label="t('crud.log.table_name')">
<template #default="scope">
{{ (scope.row.table.databaseConnection ? scope.row.table.databaseConnection + '.' : '') + scope.row.table.name }}
</template>
</el-table-column>
</template>
<template #sync>
<el-table-column prop="sync" align="center" :label="t('crud.log.sync')">
<template #default="scope">
<el-tag :type="scope.row.sync > 0 ? 'primary' : 'info'">
{{ scope.row.sync > 0 ? t('crud.log.sync yes') : t('crud.log.sync no') }}
</el-tag>
</template>
</el-table-column>
</template>
</Table>
</el-dialog>
<el-dialog v-model="state.showConfig" :title="t('crud.log.Settings')">
<div class="ba-operate-form" :style="config.layout.shrink ? '' : 'width: calc(100% - 90px)'">
<el-form @keyup.enter="onConfigSubmit" :model="state.configForm" label-position="top">
<FormItem
:label="t('crud.log.CRUD design record synchronization scheme')"
v-model="state.configForm.syncType"
type="radio"
:input-attr="{
border: true,
content: { manual: t('crud.log.Manual'), automatic: t('crud.log.automatic') },
}"
:block-help="t('crud.log.You can use the synchronized design records across devices')"
/>
<FormItem
:key="state.configForm.syncType"
v-if="state.configForm.syncType == 'automatic'"
:label="t('crud.log.When automatically synchronizing records, share them to the open source community')"
v-model="state.configForm.syncAutoPublic"
type="radio"
:input-attr="{
border: true,
content: { no: t('crud.log.Not to share'), yes: t('crud.log.Share') },
}"
:block-help="t('crud.log.Enabling sharing can automatically earn community points during development')"
/>
<FormItem
:label="t('crud.log.The synchronized CRUD records are automatically resynchronized when they are updated')"
v-model="state.configForm.syncedUpdate"
type="radio"
:input-attr="{
border: true,
content: { no: t('crud.log.Do not resynchronize'), yes: t('crud.log.Automatic resynchronization') },
}"
/>
</el-form>
</div>
<template #footer>
<div :style="'width: calc(100% - 90px)'">
<el-button @click="toggleShowConfig(false)">{{ t('Cancel') }}</el-button>
<el-button v-blur @click="onConfigSubmit" type="primary"> {{ t('Save') }} </el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="state.showUpload" :title="t('Upload')" width="60%">
<div class="ba-operate-form" v-loading="state.uploadValidLoading">
<el-table :empty-text="t('crud.log.No effective design')" :data="state.uploadValidData" stripe class="w100">
<el-table-column prop="table_name" :label="t('crud.log.table_name')" align="center" />
<el-table-column prop="comment" :label="t('crud.log.comment')" align="center" show-overflow-tooltip />
<el-table-column prop="fieldCount" :label="t('crud.log.Number of fields')" align="center" />
<el-table-column :label="t('crud.log.Upload type')" align="center">
<template #default="scope">
<el-tag :type="scope.row.id > 0 ? 'primary' : 'success'">
{{ scope.row.id > 0 ? t('crud.log.Update') : t('crud.log.New added') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" :label="t('crud.log.Share to earn points')" align="center">
<template #default="scope">
<el-text :type="scope.row.score <= 0 ? 'info' : 'success'">{{ scope.row.score }}</el-text>
</template>
</el-table-column>
<el-table-column :label="t('crud.log.Share to the open source community')" align="center">
<template #default="scope">
<el-switch v-model="scope.row.public" />
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div :style="'width: calc(100% - 90px)'">
<el-button @click="toggleShowUpload(false)">{{ t('Cancel') }}</el-button>
<el-button v-blur @click="onSaveUpload" type="primary"> {{ t('Upload') }} </el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="state.showDownload" :title="t('crud.log.Cloud record')">
<div class="download-table-header">
<el-button v-blur @click="onLoadLogs" color="#40485b" class="download-table-header-operate" type="info">
<Icon color="#fff" size="14" name="fa fa-refresh" />
</el-button>
<el-popconfirm
@confirm="onBatchDelLog"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('Are you sure to delete the selected record?')"
:disabled="state.downloadSelection.length > 0 ? false : true"
>
<template #reference>
<el-button
v-blur
:disabled="state.downloadSelection.length > 0 ? false : true"
class="download-table-header-operate"
type="danger"
>
<Icon color="#fff" size="14" name="fa fa-trash" />
<span class="download-table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</template>
</el-popconfirm>
<div class="download-table-search">
<el-input
v-model="state.downloadQuickSearch"
class="xs-hidden download-quick-search"
@input="onSearchDownloadInput"
:placeholder="t('Search')"
clearable
/>
</div>
</div>
<el-table
v-loading="state.downloadLoading"
@selection-change="onSelectionChange"
:empty-text="t('crud.log.No design record')"
:data="state.downloadData"
stripe
class="w100"
>
<el-table-column type="selection" align="center" />
<el-table-column :show-overflow-tooltip="true" align="center" :label="t('crud.log.table_name')">
<template #default="scope">
{{ (scope.row.table.databaseConnection ? scope.row.table.databaseConnection + '.' : '') + scope.row.table.name }}
</template>
</el-table-column>
<el-table-column prop="comment" :label="t('crud.log.comment')" align="center" show-overflow-tooltip />
<el-table-column :label="t('crud.log.Field')" align="center">
<template #default="scope">
<el-popover :width="460" class="box-item" :title="t('crud.log.Field information')" placement="left">
<template #reference>
<el-text class="cp" type="primary">{{ scope.row.fieldCount }}</el-text>
</template>
<el-table :empty-text="t('crud.log.No field')" :data="scope.row.fields" stripe class="w100">
<el-table-column prop="name" :label="t('crud.log.Field name')" align="center" />
<el-table-column prop="comment" :label="t('crud.log.Note')" align="center" show-overflow-tooltip />
<el-table-column :label="t('crud.log.Type')" align="center" show-overflow-tooltip>
<template #default="typeScope">
<el-text>{{ typeScope.row.dataType ?? typeScope.row.type }}</el-text>
</template>
</el-table-column>
</el-table>
</el-popover>
</template>
</el-table-column>
<el-table-column :label="t('Operate')" align="center">
<template #default="scope">
<el-popconfirm :title="t('crud.crud.Start CRUD design with this record?')" @confirm="onLoadLog(scope.row.id)">
<template #reference>
<el-button type="primary" link>
<div>{{ t('crud.log.Load') }}</div>
</el-button>
</template>
</el-popconfirm>
<el-popconfirm :title="t('crud.log.Delete cloud records?')" @confirm="onDelLog([scope.row.id])">
<template #reference>
<el-button type="danger" link>
<div>{{ t('Delete') }}</div>
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div class="log-pagination">
<el-pagination
:currentPage="state.downloadPage"
:page-size="10"
background
:layout="config.layout.shrink ? 'prev, next, jumper' : 'total, ->, prev, pager, next, jumper'"
:total="state.downloadTotal"
@current-change="onDownloadCurrentChange"
></el-pagination>
</div>
</el-dialog>
<BaAccountDialog v-model="state.showBaAccount" :login-callback="onBaAccountLoginSuccess" />
</div>
</template>
<script setup lang="ts">
import { ElNotification } from 'element-plus'
import { debounce } from 'lodash-es'
import { nextTick, onMounted, provide, reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { delLog, logs, postDel, uploadCompleted, uploadLog } from '/@/api/backend/crud'
import { baTableApi } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import BaAccountDialog from '/@/layouts/backend/components/baAccount.vue'
import { useBaAccount } from '/@/stores/baAccount'
import { useConfig } from '/@/stores/config'
import baTableClass from '/@/utils/baTable'
import { auth, getArrayKey } from '/@/utils/common'
import { changeStep, state as crudState } from '/@/views/backend/crud/index'
interface Props {
modelValue: boolean
}
const config = useConfig()
const baAccount = useBaAccount()
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const state = reactive({
ready: false,
configForm: {
syncType: config.crud.syncType,
syncedUpdate: config.crud.syncedUpdate,
syncAutoPublic: config.crud.syncAutoPublic,
},
showUpload: false,
showConfig: false,
showDownload: false,
showBaAccount: false,
uploadScoreSum: 0,
uploadValidData: [] as anyObj[],
uploadValidLoading: false,
downloadPage: 1,
downloadData: [] as anyObj[],
downloadTotal: 0,
downloadLoading: false,
downloadSelection: [] as anyObj[],
downloadQuickSearch: '',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = [
{
render: 'confirmButton',
name: 'copy',
title: 'crud.crud.copy',
text: '',
type: 'primary',
icon: 'fa fa-copy',
class: 'table-row-copy',
popconfirm: {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'primary',
title: t('crud.crud.Start CRUD design with this record?'),
width: '220px',
},
disabledTip: false,
click: (row) => {
crudState.startData.logId = row[baTable.table.pk!]
changeStep('log')
emits('update:modelValue', false)
},
},
{
render: 'confirmButton',
name: 'del',
title: 'crud.log.delete',
text: '',
type: 'danger',
icon: 'fa fa-trash',
class: 'table-row-delete',
popconfirm: {
confirmButtonText: t('crud.crud.Delete Code'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'danger',
title: t('crud.crud.Are you sure to delete the generated CRUD code?'),
width: '248px',
},
disabledTip: false,
click: (row) => {
postDel(row[baTable.table.pk!]).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
},
display: (row) => {
return row['status'] != 'delete' && auth('delete')
},
},
]
const baTable = new baTableClass(
new baTableApi('/admin/crud.Log/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('crud.log.id'), prop: 'id', align: 'center', width: 70, operator: '=', sortable: 'custom' },
{
label: t('crud.log.table_name'),
operator: 'LIKE',
render: 'slot',
slotName: 'tableName',
},
{
label: t('crud.log.comment'),
prop: 'comment',
align: 'center',
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('crud.log.sync'),
prop: 'sync',
align: 'center',
render: 'slot',
slotName: 'sync',
},
{
label: t('crud.log.status'),
prop: 'status',
align: 'center',
render: 'tag',
sortable: false,
replaceValue: {
delete: t('crud.log.status delete'),
success: t('crud.log.status success'),
error: t('crud.log.status error'),
start: t('crud.log.status start'),
},
custom: { delete: 'danger', success: 'success', error: 'warning', start: '' },
},
{
label: t('crud.log.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { status: 'start' },
}
)
provide('baTable', baTable)
const getData = () => {
baTable.getData()?.then(() => {
state.ready = true
})
}
const toggleShowConfig = (status: boolean) => {
state.showConfig = status
}
const toggleShowBaAccount = (status: boolean) => {
state.showBaAccount = status
}
const toggleShowUpload = (status: boolean) => {
state.showUpload = status
}
const toggleShowDownload = (status: boolean) => {
state.showDownload = status
}
const onLoadLog = (id: string) => {
crudState.startData.logId = id
crudState.startData.logType = 'Cloud history'
changeStep('log')
emits('update:modelValue', false)
}
const onBatchDelLog = () => {
let ids: number[] = []
for (const key in state.downloadSelection) {
ids.push(state.downloadSelection[key].id)
}
onDelLog(ids)
}
const onDelLog = (ids: number[]) => {
delLog({ ids }).then((res) => {
uploadCompleted({ syncIds: res.data.syncs, cancelSync: 1 }).finally(() => {
onLoadLogs()
baTable.onTableHeaderAction('refresh', {})
})
})
}
const onConfigSubmit = () => {
toggleShowConfig(false)
config.setCrud('syncType', state.configForm.syncType)
config.setCrud('syncedUpdate', state.configForm.syncedUpdate)
config.setCrud('syncAutoPublic', state.configForm.syncAutoPublic)
ElNotification({
type: 'success',
message: t('axios.Operation successful'),
})
}
const onUpload = () => {
toggleShowUpload(true)
state.uploadValidLoading = true
uploadLog({ logs: baTable.table.selection, save: 0 })
.then((res) => {
state.uploadScoreSum = res.data.scoreSum
state.uploadValidData = res.data.validData
})
.finally(() => {
state.uploadValidLoading = false
})
}
const onSaveUpload = () => {
state.uploadValidLoading = true
const selection = baTable.table.selection
for (const key in selection) {
const s = selection[key as keyof typeof selection] as any
const validDataKey = getArrayKey(state.uploadValidData, 'sync', s.id.toString())
if (validDataKey !== false) {
s['public'] = state.uploadValidData[validDataKey].public ? 1 : 0
}
}
uploadLog({ logs: selection, save: 1 }).then((res) => {
uploadCompleted({ syncIds: res.data.syncIds }).finally(() => {
baTable.onTableHeaderAction('refresh', {})
toggleShowUpload(false)
ElNotification({
type: 'success',
message: res.msg,
})
state.uploadValidLoading = false
})
})
}
const onBaAccountLoginSuccess = () => {
toggleShowBaAccount(false)
}
const onDownloadCurrentChange = (val: number) => {
state.downloadPage = val
onLoadLogs()
}
const onSelectionChange = (newSelection: any[]) => {
state.downloadSelection = newSelection
}
const onSearchDownloadInput = debounce(() => onLoadLogs(), 500)
const onLoadLogs = () => {
toggleShowDownload(true)
state.downloadLoading = true
logs({ page: state.downloadPage, quickSearch: state.downloadQuickSearch })
.then((res) => {
state.downloadData = res.data.list
state.downloadTotal = res.data.total
})
.finally(() => {
state.downloadLoading = false
})
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
})
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !state.ready) {
nextTick(() => {
getData()
})
}
}
)
</script>
<style lang="scss">
.ba-crud-log-dialog .el-dialog__body {
padding: 10px 20px;
}
.log-pagination {
padding: 13px 15px;
}
.cp {
cursor: pointer;
}
.download-table-header {
display: flex;
padding: 10px;
padding-top: 0;
.download-table-header-operate-text {
margin-left: 6px;
font-size: 14px;
}
.download-table-search {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,319 @@
<template>
<div class="default-main">
<div class="crud-title">{{ t('crud.crud.start') }}</div>
<div class="start-opt">
<el-row :gutter="20">
<el-col :xs="24" :span="8">
<div @click="changeStep('create')" class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.create') }}</div>
<div class="start-item-remark">{{ t('crud.crud.New background CRUD from zero') }}</div>
</div>
</el-col>
<el-col @click="onShowDialog('db')" :xs="24" :span="8">
<div class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.Select Data Table') }}</div>
<div class="start-item-remark">{{ t('crud.crud.Select a designed data table from the database') }}</div>
</div>
</el-col>
<el-col @click="state.showLog = true" :xs="24" :span="8">
<div class="start-item suspension">
<div class="start-item-title">{{ t('crud.crud.CRUD record') }}</div>
<div class="start-item-remark">{{ t('crud.crud.Start with previously generated CRUD code') }}</div>
</div>
</el-col>
</el-row>
<el-row justify="center">
<el-col :span="20" class="ba-markdown crud-tips suspension">
<b>{{ t('crud.crud.Fast experience') }}</b>
<ol>
<li>
{{ t('crud.crud.experience 1 1') }}
<a target="_blank" href="https://doc.buildadmin.com/guide/other/developerMustSee.html" rel="noopener noreferrer">
{{ t('crud.crud.experience 1 2') }}
</a>
{{ t('crud.crud.experience 1 3') }}
</li>
<li>
{{ t('crud.crud.experience 2 1') }}
<code>{{ t('crud.crud.create') }}</code>
{{ t('crud.crud.or') }}
<code> {{ t('crud.crud.experience 2 2') }}{{ t('crud.crud.experience 2 3') }} </code>
</li>
<li>
{{ t('crud.crud.experience 3 1') }} <code>{{ t('crud.crud.experience 3 2') }}</code>
{{ t('crud.crud.experience 3 3') }}
<code>{{ t('crud.crud.experience 3 4') }}</code>
</li>
</ol>
<el-alert v-if="!isDev()" class="no-dev" type="warning" :show-icon="true" :closable="false">
<template #title>
<span>{{ t('crud.crud.experience 4 1') }}</span>
<a target="_blank" href="https://doc.buildadmin.com/guide/other/developerMustSee.html" rel="noopener noreferrer">
{{ t('crud.crud.experience 4 2') }}
</a>
<span>
{{ t('crud.crud.experience 4 3') }}<code>{{ t('crud.crud.experience 4 4') }}</code>
</span>
</template>
</el-alert>
</el-col>
</el-row>
<el-dialog
class="ba-operate-dialog select-table-dialog"
v-model="state.dialog.visible"
:title="state.dialog.type == 'sql' ? t('crud.crud.Please enter SQL') : t('crud.crud.Please select a data table')"
:destroy-on-close="true"
>
<el-form
:label-width="140"
@keyup.enter="onSubmit()"
class="select-table-form"
ref="formRef"
:model="crudState.startData"
:rules="rules"
>
<template v-if="state.dialog.type == 'sql'">
<el-input
class="sql-input"
prop="sql"
ref="sqlInputRef"
v-model="crudState.startData.sql"
type="textarea"
:placeholder="t('crud.crud.table create SQL')"
:rows="10"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
/>
</template>
<template v-else-if="state.dialog.type == 'db'">
<FormItem
:label="t('Database connection')"
v-model="crudState.startData.databaseConnection"
type="remoteSelect"
:label-width="140"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
onChange: onDatabaseChange,
}"
:placeholder="t('Please select field', { field: t('Database connection') })"
/>
<FormItem
:label="t('crud.crud.data sheet')"
v-model="crudState.startData.table"
type="remoteSelect"
:key="crudState.startData.databaseConnection"
:placeholder="t('crud.crud.Please select a data table')"
:label-width="140"
:block-help="t('crud.crud.data sheet help')"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: crudState.startData.databaseConnection,
samePrefix: 1,
excludeTable: [
'area',
'token',
'captcha',
'admin_group_access',
'config',
'admin_log',
'user_money_log',
'user_score_log',
],
},
remoteUrl: getTableListUrl,
onRow: onTableStartChange,
}"
prop="table"
/>
<el-alert
v-if="state.successRecord"
class="success-record-alert"
:title="t('crud.crud.The selected table has already generated records You are advised to start with historical records')"
:show-icon="true"
:closable="false"
type="warning"
/>
</template>
</el-form>
<template #footer>
<div :style="{ width: 'calc(100% * 0.9)' }">
<el-button @click="state.dialog.visible = false">{{ $t('Cancel') }}</el-button>
<el-button :loading="state.loading" @click="onSubmit()" v-blur type="primary">{{ t('Confirm') }}</el-button>
<el-button v-if="state.successRecord" @click="onLogStart" v-blur type="success">
{{ t('crud.crud.Start with the historical record') }}
</el-button>
</div>
</template>
</el-dialog>
<CrudLog v-model="state.showLog" />
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { checkCrudLog } from '/@/api/backend/crud'
import FormItem from '/@/components/formItem/index.vue'
import { changeStep, state as crudState } from '/@/views/backend/crud/index'
import { ElNotification } from 'element-plus'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import CrudLog from '/@/views/backend/crud/log.vue'
import { useI18n } from 'vue-i18n'
import { getDatabaseConnectionListUrl, getTableListUrl } from '/@/api/common'
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const sqlInputRef = useTemplateRef('sqlInputRef')
const state = reactive({
dialog: {
type: '',
visible: false,
},
showLog: false,
loading: false,
successRecord: 0,
})
const onShowDialog = (type: string) => {
state.dialog.type = type
state.dialog.visible = true
if (type == 'sql') {
setTimeout(() => {
sqlInputRef.value?.focus()
}, 200)
} else if (type == 'db') {
state.successRecord = 0
crudState.startData.table = ''
}
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
table: [buildValidatorData({ name: 'required', message: t('crud.crud.Please select a data table') })],
})
const onSubmit = () => {
if (state.dialog.type == 'sql' && !crudState.startData.sql) {
ElNotification({
type: 'error',
message: t('crud.crud.Please enter the table creation SQL'),
})
return
}
formRef.value?.validate((valid) => {
if (valid) {
changeStep(state.dialog.type)
}
})
}
const onDatabaseChange = () => {
state.successRecord = 0
crudState.startData.table = ''
}
const onTableStartChange = () => {
if (crudState.startData.table) {
// 检查是否有CRUD记录
state.loading = true
checkCrudLog(crudState.startData.table, crudState.startData.databaseConnection)
.then((res) => {
state.successRecord = res.data.id
})
.finally(() => {
state.loading = false
})
}
}
const onLogStart = () => {
if (state.successRecord) {
crudState.startData.logId = state.successRecord.toString()
changeStep('log')
}
}
const isDev = () => {
return import.meta.env.DEV
}
</script>
<style scoped lang="scss">
:deep(.select-table-dialog) .el-dialog__body {
height: unset;
.select-table-form {
width: 88%;
padding: 40px 0;
}
.success-record-alert {
width: calc(100% - 140px);
margin-left: 140px;
margin-bottom: 30px;
margin-top: -10px;
}
}
.crud-title {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--el-font-size-extra-large);
font-weight: bold;
padding-top: 120px;
}
.start-opt {
display: block;
width: 60%;
margin: 40px auto;
}
.start-item {
background-color: #e1eaf9;
border-radius: var(--el-border-radius-base);
padding: 25px;
margin-bottom: 20px;
cursor: pointer;
}
.start-item-title {
font-size: var(--el-font-size-large);
color: var(--ba-color-primary-light);
}
.start-item-remark {
display: block;
line-height: 18px;
min-height: 48px;
padding-top: 12px;
color: #92969a;
}
.sql-input {
margin: 20px 0;
}
.crud-tips {
margin-top: 60px;
padding: 20px;
background-color: rgba($color: #ffffff, $alpha: 0.6);
border-radius: var(--el-border-radius-base);
color: var(--el-color-info);
b {
font-size: 15px;
padding-left: 10px;
}
.no-dev {
margin-top: 10px;
}
}
@at-root .dark {
.start-item {
background-color: #1d1e1f;
}
.crud-tips {
background-color: rgba($color: #1d1e1f, $alpha: 0.4);
}
}
</style>

View File

@@ -0,0 +1,826 @@
<template>
<div class="default-main">
<div class="banner">
<el-row :gutter="10">
<el-col :md="24" :lg="18">
<div class="welcome suspension">
<img class="welcome-img" :src="headerSvg" alt="" />
<div class="welcome-text">
<div class="welcome-title">{{ adminInfo.nickname + t('utils.comma') + getGreet() }}</div>
<div class="welcome-note">{{ state.remark }}</div>
</div>
</div>
</el-col>
<el-col :lg="6" class="hidden-md-and-down">
<div class="working">
<img class="working-coffee" :src="coffeeSvg" alt="" />
<div class="working-text">
{{ t('dashboard.You have worked today') }}<span class="time">{{ state.workingTimeFormat }}</span>
</div>
<div @click="onChangeWorkState()" class="working-opt working-rest">
{{ state.pauseWork ? t('dashboard.Continue to work') : t('dashboard.have a bit of rest') }}
</div>
</div>
</el-col>
</el-row>
</div>
<div class="small-panel-box">
<el-row :gutter="20">
<el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension">
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+14%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel file suspension">
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+50%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel users suspension">
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-users" />
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+28%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel addons suspension">
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-object-group" />
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+88%</div>
</div>
</div>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
<div class="user-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
<div class="file-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="6">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
<div class="new-user-growth">
<el-scrollbar>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">妙码生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">码上生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">Admin</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="new-user-base">
<div class="new-user-name">纯属虚构</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
</el-scrollbar>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member source')">
<div class="user-source-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member last name')">
<div class="user-surname-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
import * as echarts from 'echarts'
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard'
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
import headerSvg from '/@/assets/dashboard/header-1.svg'
import { useAdminInfo } from '/@/stores/adminInfo'
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
import { useNavTabs } from '/@/stores/navTabs'
import { fullUrl, getGreet } from '/@/utils/common'
import { Local } from '/@/utils/storage'
let workTimer: number
defineOptions({
name: 'dashboard',
})
const d = new Date()
const { t } = useI18n()
const navTabs = useNavTabs()
const adminInfo = useAdminInfo()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const state: {
charts: any[]
remark: string
workingTimeFormat: string
pauseWork: boolean
} = reactive({
charts: [],
remark: 'dashboard.Loading',
workingTimeFormat: '',
pauseWork: false,
})
/**
* 带有数字向上变化特效的数据
*/
const countUp = reactive({
userRegNumber: 0,
fileNumber: 0,
usersNumber: 0,
addonsNumber: 0,
})
const countUpRefs = toRefs(countUp)
const userRegNumberOutput = useTransition(countUpRefs.userRegNumber, { duration: 1500 })
const fileNumberOutput = useTransition(countUpRefs.fileNumber, { duration: 1500 })
const usersNumberOutput = useTransition(countUpRefs.usersNumber, { duration: 1500 })
const addonsNumberOutput = useTransition(countUpRefs.addonsNumber, { duration: 1500 })
const statisticValueStyle: CSSProperties = {
fontSize: '28px',
}
index().then((res) => {
state.remark = res.data.remark
})
const initCountUp = () => {
// 虚拟数据
countUpRefs.userRegNumber.value = 5456
countUpRefs.fileNumber.value = 1234
countUpRefs.usersNumber.value = 9486
countUpRefs.addonsNumber.value = 875
}
const initUserGrowthChart = () => {
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
const option = {
grid: {
top: 40,
right: 0,
bottom: 20,
left: 40,
},
xAxis: {
data: [
t('dashboard.Monday'),
t('dashboard.Tuesday'),
t('dashboard.Wednesday'),
t('dashboard.Thursday'),
t('dashboard.Friday'),
t('dashboard.Saturday'),
t('dashboard.Sunday'),
],
},
yAxis: {},
legend: {
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
textStyle: {
color: '#73767a',
},
top: 0,
},
series: [
{
name: t('dashboard.Visits'),
data: [100, 160, 280, 230, 190, 200, 480],
type: 'line',
smooth: true,
areaStyle: {
color: '#8595F4',
},
},
{
name: t('dashboard.Registration volume'),
data: [45, 180, 146, 99, 210, 127, 288],
type: 'line',
smooth: true,
areaStyle: {
color: '#F48595',
opacity: 0.5,
},
},
],
}
userGrowthChart.setOption(option)
state.charts.push(userGrowthChart)
}
const initFileGrowthChart = () => {
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
const option = {
grid: {
top: 30,
right: 0,
bottom: 20,
left: 0,
},
tooltip: {
trigger: 'item',
},
legend: {
type: 'scroll',
bottom: 0,
data: (function () {
var list = []
for (var i = 1; i <= 28; i++) {
list.push(i + 2000 + '')
}
return list
})(),
textStyle: {
color: '#73767a',
},
},
visualMap: {
top: 'middle',
right: 10,
color: ['red', 'yellow'],
calculable: true,
},
radar: {
indicator: [
{ name: t('dashboard.picture') },
{ name: t('dashboard.file') },
{ name: t('dashboard.table') },
{ name: t('dashboard.Compressed package') },
{ name: t('dashboard.other') },
],
},
series: (function () {
var series = []
for (var i = 1; i <= 28; i++) {
series.push({
type: 'radar',
symbol: 'none',
lineStyle: {
width: 1,
},
emphasis: {
areaStyle: {
color: 'rgba(0,250,0,0.3)',
},
},
data: [
{
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
name: i + 2000 + '',
},
],
})
}
return series
})(),
}
fileGrowthChart.setOption(option)
state.charts.push(fileGrowthChart)
}
const initUserSourceChart = () => {
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
const pathSymbols = {
reindeer:
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none',
},
formatter: function (params: any) {
return params[0].name + ': ' + params[0].value
},
},
xAxis: {
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#e54035',
},
},
yAxis: {
splitLine: { show: false },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { show: false },
},
color: ['#e54035'],
series: [
{
name: 'hill',
type: 'pictorialBar',
barCategoryGap: '-130%',
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
itemStyle: {
opacity: 0.5,
},
emphasis: {
itemStyle: {
opacity: 1,
},
},
data: [123, 60, 25, 80],
z: 10,
},
{
name: 'glyph',
type: 'pictorialBar',
barGap: '-100%',
symbolPosition: 'end',
symbolSize: 50,
symbolOffset: [0, '-120%'],
data: [
{
value: 123,
symbol: pathSymbols.reindeer,
symbolSize: [60, 60],
},
{
value: 60,
symbol: pathSymbols.rocket,
symbolSize: [50, 60],
},
{
value: 25,
symbol: pathSymbols.plane,
symbolSize: [65, 35],
},
{
value: 80,
symbol: pathSymbols.train,
symbolSize: [50, 30],
},
],
},
],
}
UserSourceChart.setOption(option)
state.charts.push(UserSourceChart)
}
const initUserSurnameChart = () => {
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
const data = genData(20)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
data: data.legendData,
textStyle: {
color: '#73767a',
},
},
series: [
{
name: t('dashboard.full name'),
type: 'pie',
radius: '55%',
center: ['40%', '50%'],
data: data.seriesData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
function genData(count: any) {
// prettier-ignore
const nameList = [
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
];
const legendData = []
const seriesData = []
for (var i = 0; i < count; i++) {
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
legendData.push(name)
seriesData.push({
name: name,
value: Math.round(Math.random() * 100000),
})
}
return {
legendData: legendData,
seriesData: seriesData,
}
function makeWord(max: any, min: any) {
const nameLen = Math.ceil(Math.random() * max + min)
const name = []
for (var i = 0; i < nameLen; i++) {
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
}
return name.join('')
}
}
userSurnameChart.setOption(option)
state.charts.push(userSurnameChart)
}
const echartsResize = () => {
nextTick(() => {
for (const key in state.charts) {
state.charts[key].resize()
}
})
}
const onChangeWorkState = () => {
const time = parseInt((new Date().getTime() / 1000).toString())
const workingTime = Local.get(WORKING_TIME)
if (state.pauseWork) {
// 继续工作
workingTime.pauseTime += time - workingTime.startPauseTime
workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
state.pauseWork = false
startWork()
} else {
// 暂停工作
workingTime.startPauseTime = time
Local.set(WORKING_TIME, workingTime)
clearInterval(workTimer)
state.pauseWork = true
}
}
const startWork = () => {
const workingTime = Local.get(WORKING_TIME) || { date: '', startTime: 0, pauseTime: 0, startPauseTime: 0 }
const currentDate = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
const time = parseInt((new Date().getTime() / 1000).toString())
if (workingTime.date != currentDate) {
workingTime.date = currentDate
workingTime.startTime = time
workingTime.pauseTime = workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
}
let startPauseTime = 0
if (workingTime.startPauseTime <= 0) {
state.pauseWork = false
startPauseTime = 0
} else {
state.pauseWork = true
startPauseTime = time - workingTime.startPauseTime // 已暂停时间
}
let workingSeconds = time - workingTime.startTime - workingTime.pauseTime - startPauseTime
state.workingTimeFormat = formatSeconds(workingSeconds)
if (!state.pauseWork) {
workTimer = window.setInterval(() => {
workingSeconds++
state.workingTimeFormat = formatSeconds(workingSeconds)
}, 1000)
}
}
const formatSeconds = (seconds: number) => {
var secondTime = 0 // 秒
var minuteTime = 0 // 分
var hourTime = 0 // 小时
var dayTime = 0 // 天
var result = ''
if (seconds < 60) {
secondTime = seconds
} else {
// 获取分钟除以60取整数得到整数分钟
minuteTime = Math.floor(seconds / 60)
// 获取秒数,秒数取佘,得到整数秒数
secondTime = Math.floor(seconds % 60)
// 如果分钟大于60将分钟转换成小时
if (minuteTime >= 60) {
// 获取小时获取分钟除以60得到整数小时
hourTime = Math.floor(minuteTime / 60)
// 获取小时后取佘的分获取分钟除以60取佘的分
minuteTime = Math.floor(minuteTime % 60)
if (hourTime >= 24) {
// 获取天数, 获取小时除以24得到整数天
dayTime = Math.floor(hourTime / 24)
// 获取小时后取余小时获取分钟除以24取余的分
hourTime = Math.floor(hourTime % 24)
}
}
}
result =
hourTime +
t('dashboard.hour') +
((minuteTime >= 10 ? minuteTime : '0' + minuteTime) + t('dashboard.minute')) +
((secondTime >= 10 ? secondTime : '0' + secondTime) + t('dashboard.second'))
if (dayTime > 0) {
result = dayTime + t('dashboard.day') + result
}
return result
}
onActivated(() => {
echartsResize()
})
onMounted(() => {
startWork()
initCountUp()
initUserGrowthChart()
initFileGrowthChart()
initUserSourceChart()
initUserSurnameChart()
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
})
onUnmounted(() => {
clearInterval(workTimer)
})
watch(
() => navTabs.state.tabFullScreen,
() => {
echartsResize()
}
)
</script>
<style scoped lang="scss">
.welcome {
background: #e1eaf9;
border-radius: 6px;
display: flex;
align-items: center;
padding: 15px 20px !important;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
.welcome-img {
height: 100px;
margin-right: 10px;
user-select: none;
}
.welcome-title {
font-size: 1.5rem;
line-height: 30px;
color: var(--ba-color-primary-light);
}
.welcome-note {
padding-top: 6px;
font-size: 15px;
color: var(--el-text-color-primary);
}
}
.working {
height: 130px;
display: flex;
justify-content: center;
flex-wrap: wrap;
height: 100%;
position: relative;
&:hover {
.working-coffee {
-webkit-transform: translateY(-4px) scale(1.02);
-moz-transform: translateY(-4px) scale(1.02);
-ms-transform: translateY(-4px) scale(1.02);
-o-transform: translateY(-4px) scale(1.02);
transform: translateY(-4px) scale(1.02);
z-index: 999;
}
}
.working-coffee {
transition: all 0.3s ease;
width: 80px;
}
.working-text {
display: block;
width: 100%;
font-size: 15px;
text-align: center;
color: var(--el-text-color-primary);
}
.working-opt {
position: absolute;
top: -40px;
right: 10px;
background-color: rgba($color: #000000, $alpha: 0.3);
padding: 10px 20px;
border-radius: 20px;
color: var(--ba-bg-color-overlay);
transition: all 0.3s ease;
cursor: pointer;
opacity: 0;
z-index: 999;
&:active {
background-color: rgba($color: #000000, $alpha: 0.6);
}
}
&:hover {
.working-opt {
opacity: 1;
top: 0;
}
.working-done {
opacity: 1;
top: 50px;
}
}
}
.small-panel-box {
margin-top: 20px;
}
.small-panel {
background-color: #e9edf2;
border-radius: var(--el-border-radius-base);
padding: 25px;
margin-bottom: 20px;
.small-panel-title {
color: #92969a;
font-size: 15px;
}
.small-panel-content {
display: flex;
align-items: flex-end;
margin-top: 20px;
color: #2c3f5d;
.content-left {
display: flex;
align-items: center;
font-size: 24px;
.icon {
margin-right: 10px;
}
}
.content-right {
font-size: 18px;
margin-left: auto;
}
.color-success {
color: var(--el-color-success);
}
.color-warning {
color: var(--el-color-warning);
}
.color-danger {
color: var(--el-color-danger);
}
.color-info {
color: var(--el-text-color-secondary);
}
}
}
.growth-chart {
margin-bottom: 20px;
}
.user-growth-chart,
.file-growth-chart {
height: 260px;
}
.new-user-growth {
height: 300px;
}
.user-source-chart,
.user-surname-chart {
height: 400px;
}
.new-user-item {
display: flex;
align-items: center;
padding: 20px;
margin: 10px 15px;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
background-color: var(--ba-bg-color-overlay);
.new-user-avatar {
height: 48px;
width: 48px;
border-radius: 50%;
}
.new-user-base {
margin-left: 10px;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
}
.new-user-time {
font-size: 13px;
}
}
.new-user-arrow {
margin-left: auto;
}
}
.new-user-card :deep(.el-card__body) {
padding: 0;
}
@media screen and (max-width: 425px) {
.welcome-img {
display: none;
}
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
html.dark {
.welcome {
background-color: var(--ba-bg-color-overlay);
}
.working-opt {
color: var(--el-text-color-primary);
background-color: var(--ba-border-color);
}
.small-panel {
background-color: var(--ba-bg-color-overlay);
.small-panel-content {
color: var(--el-text-color-regular);
}
}
.new-user-item {
.new-user-base {
color: var(--el-text-color-regular);
}
}
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div>
<div class="switch-language">
<el-dropdown size="large" :hide-timeout="50" placement="bottom-end" :hide-on-click="true">
<Icon name="fa fa-globe" color="var(--el-text-color-secondary)" size="28" />
<template #dropdown>
<el-dropdown-menu class="chang-lang">
<el-dropdown-item v-for="item in config.lang.langArray" :key="item.name" @click="editDefaultLang(item.name)">
{{ item.value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div @contextmenu.stop="" id="bubble" class="bubble">
<canvas id="bubble-canvas" class="bubble-canvas"></canvas>
</div>
<div class="login">
<div class="login-box">
<div class="head">
<img src="~assets/login-header.png" alt="" />
</div>
<div class="form">
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="content">
<el-form @keyup.enter="onSubmitPre()" ref="formRef" :rules="rules" size="large" :model="form">
<el-form-item prop="username">
<el-input
ref="usernameRef"
type="text"
clearable
v-model="form.username"
:placeholder="t('login.Please enter an account')"
>
<template #prefix>
<Icon name="fa fa-user" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
ref="passwordRef"
v-model="form.password"
type="password"
:placeholder="t('login.Please input a password')"
show-password
>
<template #prefix>
<Icon name="fa fa-unlock-alt" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-checkbox v-model="form.keep" :label="t('login.Hold session')" size="default"></el-checkbox>
<el-form-item>
<el-button
:loading="state.submitLoading"
class="submit-button"
round
type="primary"
size="large"
@click="onSubmitPre()"
>
{{ t('login.Sign in') }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, reactive, nextTick, useTemplateRef } from 'vue'
import * as pageBubble from '/@/utils/pageBubble'
import { useI18n } from 'vue-i18n'
import { editDefaultLang } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import { login } from '/@/api/backend'
import { uuid } from '/@/utils/random'
import { buildValidatorData } from '/@/utils/validate'
import router from '/@/router'
import clickCaptcha from '/@/components/clickCaptcha'
import toggleDark from '/@/utils/useDark'
import { fullUrl } from '/@/utils/common'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
let timer: number
const config = useConfig()
const adminInfo = useAdminInfo()
toggleDark(config.layout.isDark)
const formRef = useTemplateRef('formRef')
const usernameRef = useTemplateRef('usernameRef')
const passwordRef = useTemplateRef('passwordRef')
const state = reactive({
showCaptcha: false,
submitLoading: false,
})
const form = reactive({
username: '',
password: '',
keep: false,
captchaId: uuid(),
captchaInfo: '',
})
const { t } = useI18n()
// 表单验证规则
const rules = reactive({
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
})
const focusInput = () => {
if (form.username === '') {
usernameRef.value?.focus()
} else if (form.password === '') {
passwordRef.value?.focus()
}
}
onMounted(() => {
timer = window.setTimeout(() => {
pageBubble.init()
}, 1000)
login('get')
.then((res) => {
state.showCaptcha = res.data.captcha
nextTick(() => focusInput())
})
.catch((err) => {
console.log(err)
})
})
onBeforeUnmount(() => {
clearTimeout(timer)
pageBubble.removeListeners()
})
const onSubmitPre = () => {
formRef.value?.validate((valid) => {
if (valid) {
if (state.showCaptcha) {
clickCaptcha(form.captchaId, (captchaInfo: string) => onSubmit(captchaInfo))
} else {
onSubmit()
}
}
})
}
const onSubmit = (captchaInfo = '') => {
state.submitLoading = true
form.captchaInfo = captchaInfo
login('post', form)
.then((res) => {
adminInfo.dataFill(res.data.userInfo, false)
router.push({ path: adminBaseRoutePath })
})
.finally(() => {
state.submitLoading = false
})
}
</script>
<style scoped lang="scss">
.switch-language {
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
}
.bubble {
overflow: hidden;
background: url(/@/assets/bg.jpg) repeat;
}
.form-item-icon {
height: auto;
}
.login {
position: absolute;
top: 0;
display: flex;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
.login-box {
overflow: hidden;
width: 430px;
padding: 0;
background: var(--ba-bg-color-overlay);
margin-bottom: 80px;
}
.head {
background: #ccccff;
img {
display: block;
width: 430px;
margin: 0 auto;
user-select: none;
}
}
.form {
position: relative;
.profile-avatar {
display: block;
position: absolute;
height: 100px;
width: 100px;
border-radius: 50%;
border: 4px solid var(--ba-bg-color-overlay);
top: -50px;
right: calc(50% - 50px);
z-index: 2;
user-select: none;
}
.content {
padding: 100px 40px 40px 40px;
}
.submit-button {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 15px;
--el-button-bg-color: var(--el-color-primary);
}
}
}
@media screen and (max-width: 720px) {
.login {
display: flex;
align-items: center;
justify-content: center;
.login-box {
width: 340px;
margin-top: 0;
}
}
}
.chang-lang :deep(.el-dropdown-menu__item) {
justify-content: center;
}
.content :deep(.el-input__prefix) {
display: flex;
align-items: center;
}
// 暗黑样式
@at-root .dark {
.bubble {
background: url(/@/assets/bg-dark.jpg) repeat;
}
.login {
.login-box {
background: #161b22;
}
.head {
img {
filter: brightness(61%);
}
}
.form {
.submit-button {
--el-button-bg-color: var(--el-color-primary-light-5);
--el-button-border-color: rgba(240, 252, 241, 0.1);
}
}
}
}
@media screen and (max-height: 800px) {
.login .login-box {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div>
<el-dialog v-model="state.dialog.buy" class="buy-dialog" :title="t('module.Confirm order info')" top="20vh" width="28%">
<div v-loading="state.loading.buy">
<el-alert :title="t('module.Module installation warning')" type="error" :center="true" :closable="false" />
<div v-if="!isEmpty(state.buy.info)" class="order-info">
<div class="order-info-item">{{ t('module.Order title') }}{{ state.buy.info.title }}</div>
<div class="order-info-item">{{ t('module.Order No') }}{{ state.buy.info.sn }}</div>
<div class="order-info-item">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-item">
{{ t('module.Order price') }}
<span v-if="!state.buy.info.purchased" class="order-price">
{{ currency(state.buy.info.amount, state.buy.info.pay.money ? 1 : 0) }}
</span>
<span v-else class="order-price">{{ t('module.Purchased, can be installed directly') }}</span>
</div>
<div class="order-footer">
<div class="order-agreement">
<el-checkbox v-model="state.buy.agreement" size="small" label="" />
<span>
{{ t('module.Understand and agree') }}
<a
href="https://doc.buildadmin.com/guide/other/appendix/templateAgreement.html"
target="_blank"
rel="noopener noreferrer"
>
{{ t('module.Module purchase and use agreement') }}
</a>
</span>
</div>
<div class="order-info-buttons">
<template v-if="!state.buy.info.purchased">
<el-button
v-if="state.buy.info.pay.score"
:loading="state.loading.common"
@click="onPay('score')"
v-blur
type="warning"
>
{{ t('module.Point payment') }}
</el-button>
<template v-if="state.buy.info.pay.money">
<el-button :loading="state.loading.common" @click="onPay('balance')" v-blur type="warning">
{{ t('module.Balance payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('wx')" v-blur type="success">
{{ t('module.Wechat payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('zfb')" v-blur type="primary">
{{ t('module.Alipay payment') }}
</el-button>
</template>
</template>
<el-button
v-else
:loading="state.loading.common"
@click="onPreInstallModule(state.buy.info.uid, state.buy.info.id, true)"
v-blur
type="warning"
>
{{ t('module.Install now') }}
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onPay, onPreInstallModule, specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
.order-info {
padding: 10px 0;
.order-info-item {
padding-top: 6px;
}
.order-footer {
padding-top: 20px;
.order-agreement {
display: flex;
align-items: center;
font-size: 12px;
span {
padding-left: 4px;
}
a {
text-decoration: none;
color: var(--el-color-primary);
}
}
.order-info-buttons {
padding-top: 15px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
@media screen and (max-width: 1440px) {
:deep(.buy-dialog) {
--el-dialog-width: 26% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.buy-dialog) {
--el-dialog-width: 32% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.buy-dialog) {
--el-dialog-width: 70% !important;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div>
<el-dialog
:close-on-press-escape="state.common.quickClose"
:title="state.common.dialogTitle"
:close-on-click-modal="state.common.quickClose"
v-model="state.dialog.common"
class="common-dialog"
>
<el-scrollbar :height="500">
<!-- 公共dialog形式的loading -->
<div
v-if="state.common.type == 'loading'"
v-loading="true"
:element-loading-text="state.common.loadingTitle ? $t('module.stateTitle ' + state.common.loadingTitle) : ''"
:key="state.common.loadingComponentKey"
class="common-loading"
></div>
<!-- 选择安装版本 -->
<SelectVersion v-if="state.common.type == 'selectVersion'" />
<!-- 安装冲突 -->
<InstallConflict v-if="state.common.type == 'installConflict'" />
<!-- 禁用冲突 -->
<ConfirmFileConflict v-if="state.common.type == 'disableConfirmConflict'" />
<!-- 安装/禁用结束 -->
<CommonDone v-if="state.common.type == 'done'" />
<!-- 上传安装 -->
<UploadInstall v-if="state.common.type == 'uploadInstall'" />
</el-scrollbar>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import CommonDone from './commonDone.vue'
import SelectVersion from './commonSelectVersion.vue'
import ConfirmFileConflict from './confirmFileConflict.vue'
import InstallConflict from './installConflict.vue'
import UploadInstall from './uploadInstall.vue'
</script>
<style scoped lang="scss">
:deep(.common-dialog) .el-dialog__body {
padding: 10px 20px;
}
.common-dialog {
height: 500px;
}
.common-loading {
height: 400px;
}
@media screen and (max-width: 1440px) {
:deep(.common-dialog) {
--el-dialog-width: 60% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.common-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.common-dialog) {
--el-dialog-width: 92% !important;
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="install-done">
<div class="install-done-title">
<span v-if="state.common.moduleState == moduleInstallState.INSTALLED">
{{ t('module.Congratulations, module installation is complete') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DISABLE">{{ t('module.Module is disabled') }}</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.Congratulations, the code of the module is ready') }}
</span>
<span v-else>{{ t('module.Unknown state') }}</span>
</div>
<div class="install-tis-box">
<div v-if="state.common.dependInstallState != 'none'" class="depend-box">
<div class="depend-loading" v-if="state.common.dependInstallState == 'executing'" v-loading="true"></div>
<div class="depend-tis">
<div v-if="state.common.dependInstallState == 'executing'">
<span class="color-red">{{ t('module.Do not refresh the page!') }}</span>
<span v-if="state.common.moduleState == moduleInstallState.DISABLE">
{{ t('module.New adjustment of dependency detected') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.This module adds new dependencies') }}
</span>
<span></span>
<span>
{{ t('module.The built-in terminal of the system is automatically installing these dependencies, please wait~') }}
</span>
<span class="span-a" @click="showTerminal">{{ t('module.View progress') }}</span>
</div>
<div v-if="state.common.dependInstallState == 'success'" class="color-green">
{{ t('module.Dependency installation completed~') }}
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="exec-fail color-red">
{{ t('module.Dependency installation fail 1') }}
<span class="span-a" @click="showTerminal">{{ t('module.Dependency installation fail 2') }}</span>
{{ t('module.Dependency installation fail 3') }}
<el-link target="_blank" type="primary" href="https://doc.buildadmin.com/guide/install/manualOperation.html">
{{ t('module.Dependency installation fail 4') }}
</el-link>
</div>
</div>
</div>
<div v-else-if="state.common.moduleState == moduleInstallState.INSTALLED" class="depend-tis">
{{ t('module.This module does not add new dependencies') }}
</div>
<div v-else>{{ t('module.There is no adjustment for system dependency') }}</div>
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="install-tis-box text-align-center">
<div class="install-tis">
{{ t('module.Dependency installation fail 5') }}
<span class="span-a" @click="onConfirmDepend">
{{ t('module.Dependency installation fail 6') }}
</span>
{{ t('module.Dependency installation fail 7') }}
<span class="dependency-installation-fail-tips">
{{ t('module.dependency-installation-fail-tips') }}
</span>
</div>
</div>
<div class="install-tis-box">
<div class="install-tis">
{{ t('module.please') }}
{{ state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 1') }}
{{ t('module.Manually clean up the system and browser cache') }}
</div>
</div>
<div class="install-tis-box">
<div class="install-form">
<FormItem
:label="
(state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 2')) +
t('module.Automatically execute reissue command?')
"
v-model="form.rebuild"
type="radio"
:input-attr="{
border: true,
content: { 0: t('module.no'), 1: t('module.yes') },
}"
/>
</div>
</div>
<div class="install-tis-box" v-if="hotUpdateState.dirtyFile && state.common.moduleState != moduleInstallState.DISABLE">
<div class="install-form">
<el-form-item :label="t('module.After installation 2') + t('module.Restart Vite hot server')">
<BaInput
v-model="form.reloadHotServer"
type="radio"
:attr="{
class: 'hot-server-input',
border: true,
content: {
0: t('vite.Later') + t('module.Manual restart'),
1: t('module.Restart Now'),
},
}"
/>
<el-popover :width="360" placement="top">
<div>
<div class="el-popover__title">{{ t('vite.Reload hot server title') }}</div>
<div class="reload-hot-server-content">
<p>
<span>{{ t('vite.Reload hot server tips 1') }}</span>
<span>{{ t(`vite.Close type ${hotUpdateState.closeType}`) }}</span>
<span>{{ t('vite.Reload hot server tips 2') }}</span>
</p>
<p>{{ t('vite.Reload hot server tips 3') }}</p>
<p>{{ t('module.Restart Vite hot server tips') }}</p>
</div>
</div>
<template #reference>
<div class="block-help hot-server-tips">{{ t('module.detailed information') }}</div>
</template>
</el-popover>
</el-form-item>
</div>
</div>
<div class="install-done-button-box">
<el-button
v-blur
:disabled="state.common.dependInstallState != 'executing' || state.common.moduleState == moduleInstallState.INSTALLED ? false : true"
size="large"
class="install-done-button"
type="primary"
:loading="state.loading.common"
@click="onSubmitInstallDone"
>
{{ state.common.moduleState == moduleInstallState.DISABLE ? t('Complete') : t('module.End of installation') }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { onRefreshTableData } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { dependentInstallComplete } from '/@/api/backend/module'
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { hotUpdateState, reloadServer } from '/@/utils/vite'
const { t } = useI18n()
const terminal = useTerminal()
const form = reactive({
rebuild: 0,
reloadHotServer: 0,
})
const showTerminal = () => {
terminal.toggle(true)
}
const onSubmitInstallDone = () => {
state.dialog.common = false
if (form.rebuild == 1) {
terminal.toggle(true)
terminal.addTaskPM('web-build', false, '', (res: number) => {
if (res == taskStatus.Success) {
terminal.toggle(false)
if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
})
} else if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
const onConfirmDepend = () => {
ElMessageBox.confirm(t('module.Is the command that failed on the WEB terminal executed manually or in other ways successfully?'), t('Reminder'), {
confirmButtonText: t('module.yes'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
state.loading.common = true
dependentInstallComplete(state.common.uid).then(() => {
onRefreshTableData()
state.loading.common = false
state.common.dependInstallState = 'success'
})
})
}
</script>
<style scoped lang="scss">
.install-done-title {
font-size: var(--el-font-size-extra-large);
color: var(--el-color-success);
text-align: center;
}
.text-align-center {
text-align: center;
}
.install-tis-box {
padding: 20px;
margin: 20px auto;
width: 70%;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
display: flex;
align-items: center;
justify-content: center;
.dependency-installation-fail-tips {
display: block;
font-size: var(--el-font-size-extra-small);
text-align: center;
padding-top: 5px;
color: var(--el-text-color-regular);
}
}
.depend-box {
display: flex;
align-items: center;
justify-content: center;
}
.install-tis {
color: var(--el-color-warning);
}
.depend-loading {
width: 30px;
height: 30px;
margin-right: 36px;
}
.span-a {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-5);
}
}
.install-form :deep(.ba-input-item-radio) {
margin-bottom: 0;
}
.exec-fail {
display: flex;
}
.color-red {
color: var(--el-color-danger);
}
.color-green {
color: var(--el-color-success);
}
.install-done-button-box {
display: flex;
align-items: center;
justify-content: center;
.install-done-button {
width: 120px;
}
}
.reload-hot-server-content {
font-size: var(--el-font-size-small);
p {
margin-bottom: 6px;
}
}
.hot-server-input {
width: 100%;
}
.hot-server-tips {
width: auto;
cursor: pointer;
}
@media screen and (max-width: 1600px) {
:deep(.install-tis-box) {
width: 76%;
}
}
@media screen and (max-width: 1280px) {
:deep(.install-tis-box) {
width: 80%;
}
}
@media screen and (max-width: 900px) {
:deep(.install-tis-box) {
width: 96%;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div>
<el-table :data="state.common.versions" class="w100" stripe>
<el-table-column property="version" align="center" :label="t('module.Version')" />
<el-table-column property="short_describe" :show-overflow-tooltip="true" align="center" :label="t('module.Description')" />
<el-table-column property="available_system_version_text" align="center" :label="t('module.Available system version')">
<template #default="scope">
<div v-if="scope.row.available_system_version && state.sysVersion">
<div class="available-system-version">
<Icon
v-if="compareVersion(scope.row.available_system_version)"
name="el-icon-CircleCheckFilled"
color="var(--el-color-success)"
size="14"
/>
<Icon v-else name="el-icon-CircleCloseFilled" size="14" color="var(--el-color-danger)" />
<div class="available-system-version-text">{{ scope.row.available_system_version_text }}</div>
</div>
</div>
<div v-else>-</div>
</template>
</el-table-column>
<el-table-column property="createtime_text" align="center" :label="t('Create time')" />
<el-table-column :label="t('module.Install')" align="center" :min-width="140">
<template #default="scope">
<div v-if="scope.row.downloadable">
<div v-if="isLocalModuleVersion(scope.row.version)" class="renewal-text">{{ t('module.Current installed version') }}</div>
<div v-else-if="!compareVersion(scope.row.available_system_version)">{{ t('module.Insufficient system version') }}</div>
<div v-else>
<el-button type="primary" @click="onInstall(scope.row.uid, scope.row.order_id, scope.row.version)">
{{ t('module.Click to install') }}
</el-button>
</div>
</div>
<el-tooltip
v-else
effect="dark"
:content="
t('module.Order expiration time', {
expiration_time: timeFormat(scope.row.order_expiration_time),
create_time: timeFormat(scope.row.createtime),
})
"
placement="top"
>
<div class="renewal">
<div class="renewal-text">{{ t('module.Versions released beyond the authorization period') }}</div>
<el-button @click="onBuy(true)" type="danger">{{ t('module.Renewal') }}</el-button>
</div>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { memoize } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { execInstall, onBuy, showCommonLoading } from '../index'
import { state } from '../store'
import { timeFormat } from '/@/utils/common'
const { t } = useI18n()
const formatSysVersion = memoize((sysVersion: string) => {
// 去掉 sysVersion 开头的 v
sysVersion = sysVersion.replace(/^v/, '')
// 以 . 分割,不足两位的补 0
sysVersion = sysVersion
.split('.')
.map((item) => {
return item.padStart(2, '0')
})
.join('')
return parseInt(sysVersion)
})
const isLocalModuleVersion = (version: string) => {
const localModule = state.installedModule.find((item) => {
return item.uid == state.common.uid
})
if (localModule) {
version = version.replace(/^v/, '')
localModule.version = localModule.version.replace(/^v/, '')
if (version == localModule.version) {
return true
}
}
return false
}
const compareVersion = memoize((version: string): boolean => {
const sysVersion = formatSysVersion(state.sysVersion)
return sysVersion > parseInt(version)
})
const onInstall = (uid: string, id: number, version: string) => {
state.dialog.common = true
state.common.dialogTitle = t('module.Install')
showCommonLoading('download')
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
execInstall(uid, id, version, state.common.update)
}
</script>
<style scoped lang="scss">
.renewal {
display: flex;
align-items: center;
justify-content: center;
.renewal-text {
font-size: 12px;
margin-right: 6px;
}
}
.available-system-version {
display: flex;
align-items: center;
justify-content: center;
.available-system-version-text {
margin-left: 4px;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div>
<div class="confirm-file-conflict">
<template v-if="state.common.disableConflictFile.length">
<div class="conflict-title">{{ $t('module.File conflict') }}</div>
<el-alert :closable="false" :center="true" :title="$t('module.Update warning')" class="alert-warning" type="warning"></el-alert>
<el-table :data="state.common.disableConflictFile" stripe border :style="{ width: '100%', marginBottom: '20px' }">
<el-table-column prop="file" :label="$t('module.Conflict file')" />
</el-table>
</template>
<template v-if="state.common.disableDependConflict.length > 0">
<div class="conflict-title">{{ $t('module.The module declares the added dependencies') }}</div>
<el-table :data="state.common.disableDependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="dependTitle" :label="$t('module.Dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('Delete')" value="delete"></el-option>
<el-option :label="$t('module.retain')" value="retain"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div class="center-buttons">
<el-button
v-blur
class="center-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onDisable(true)"
>
{{ $t('module.Confirm to disable the module') }}
</el-button>
<el-button v-blur class="center-button" size="large" @click="cancelDisable()"> {{ $t('Cancel') }} </el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { onDisable } from '../index'
const cancelDisable = () => {
state.dialog.common = false
state.goodsInfo.enable = true
}
</script>
<style scoped lang="scss">
.confirm-file-conflict {
min-height: 400px;
}
.conflict-alert {
width: 500px;
margin: 0 auto;
}
.alert-warning {
margin: 20px auto;
width: 500px;
}
.depend-conflict-tips {
text-align: center;
}
.text-bold {
font-weight: bold;
}
.conflict-title {
font-size: var(--el-font-size-large);
text-align: center;
margin-bottom: 20px;
}
.center-buttons {
display: flex;
justify-content: center;
margin: 20px auto;
}
.center-button {
width: 120px;
}
</style>

View File

@@ -0,0 +1,610 @@
<template>
<div>
<el-dialog v-model="state.dialog.goodsInfo" class="goods-info-dialog" :title="t('module.detailed information')" width="60%">
<el-scrollbar v-loading="state.loading.goodsInfo" :key="state.goodsInfo.uid" :height="500">
<div class="goods-info">
<div class="goods-images">
<el-carousel height="300" v-if="state.goodsInfo.images" indicator-position="outside">
<el-carousel-item class="goods-image-item" v-for="(image, idx) in state.goodsInfo.images" :key="idx">
<el-image fit="contain" :preview-src-list="state.goodsInfo.images" :preview-teleported="true" :src="image"></el-image>
</el-carousel-item>
</el-carousel>
</div>
<div class="goods-basic">
<h4 class="goods-basic-title">{{ state.goodsInfo.title }}</h4>
<div class="goods-tag">
<el-tag v-for="(tag, idx) in state.goodsInfo.tags" :key="idx" :type="tag.type ? tag.type : 'primary'">
{{ tag.name }}
</el-tag>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Price') }}</div>
<div class="basic-item-price">
{{
typeof state.goodsInfo.currency_select != 'undefined'
? currency(state.goodsInfo.present_price, state.goodsInfo.currency_select)
: '-'
}}
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Last updated') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.updatetime ? timeFormat(state.goodsInfo.updatetime) : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Published on') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.createtime ? timeFormat(state.goodsInfo.createtime) : '-' }}</div>
</div>
<div v-if="!installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.amount of downloads') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.downloads ? state.goodsInfo.downloads : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module classification') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.category ? state.goodsInfo.category.name : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module documentation') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.docs"
target="_blank"
:href="`https://doc.buildadmin.com/md/${state.goodsInfo.docs.name ? state.goodsInfo.docs.name : state.goodsInfo.docs.id}`"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Developer Homepage') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.author_url"
target="_blank"
:href="state.goodsInfo.author_url"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.Module status') }}</div>
<div class="basic-item-content">
<el-switch
@change="onChangeState"
:loading="state.loading.common"
:disabled="state.loading.common"
v-model="state.goodsInfo.enable"
/>
</div>
</div>
<div class="basic-buttons">
<el-dropdown
v-if="
(!state.goodsInfo.purchased || installButtonState.InstallNow.includes(state.goodsInfo.state)) &&
state.goodsInfo.demo &&
state.goodsInfo.demo.length > 0
"
>
<el-button class="basic-button-demo" type="primary">
<span class="basic-button-dropdown-span">{{ t('module.View demo') }}</span>
<Icon color="#ffffff" size="16" name="el-icon-ArrowDown" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(demo, idx) in state.goodsInfo.demo"
:key="idx"
@click="openDemo(demo.link, demo.image ? false : true)"
class="basic-button-dropdown-item"
>
<el-popover
placement="right"
:title="t('module.Code scanning Preview')"
trigger="hover"
:disabled="demo.image ? false : true"
:width="174"
>
<template #reference>
<div class="demo-item-title">
<Icon :name="demo.icon" size="14" color="var(--el-color-primary)" />{{ demo.title }}
</div>
</template>
<div class="demo-image">
<img :src="demo.image" alt="" />
</div>
</el-popover>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
!state.goodsInfo.purchased &&
installButtonState.buy.includes(state.goodsInfo.state) &&
state.goodsInfo.type == 'online'
"
@click="onBuy(false)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.Buy now') }}
</el-button>
<el-button
v-if="
(state.goodsInfo.state == moduleInstallState.UNINSTALLED && state.goodsInfo.purchased) ||
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL
"
@click="
onPreInstallModule(
state.goodsInfo.uid,
state.goodsInfo.purchased,
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL ? false : true
)
"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.Install now') }}
</el-button>
<el-button
v-if="installButtonState.continueInstallation.includes(state.goodsInfo.state)"
@click="onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, false)"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.continue installation') }}
</el-button>
<el-button
v-if="installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.installed') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.type == 'local' && !installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.Local module') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.new_version && installButtonState.updateButton.includes(state.goodsInfo.state)"
@click="onUpdate(state.goodsInfo.uid, state.goodsInfo.purchased)"
v-loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.to update') }}
</el-button>
<el-button
v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)"
v-loading="state.loading.common"
@click="unInstall(state.goodsInfo.uid)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.uninstall') }}
</el-button>
</div>
</div>
<div v-if="!isEmpty(state.goodsInfo.developer)" class="goods-developer">
<div class="developer-header">
<el-avatar :size="60" :src="state.goodsInfo.developer.avatar" />
<div class="developer-name">
<h3 class="developer-nickname">{{ state.goodsInfo.developer.nickname }}</h3>
<div class="developer-group">
{{ state.goodsInfo.developer.group ? state.goodsInfo.developer.group : '-' }}
</div>
</div>
</div>
<div v-if="state.goodsInfo.qq" class="developer-contact">
<h4 class="developer-info-title">{{ t('module.Contact developer') }}</h4>
<div class="contact-item">
<a
rel="noopener noreferrer"
target="_blank"
:href="'http://wpa.qq.com/msgrd?v=3&uin=' + state.goodsInfo.qq + '&site=qq&menu=yes'"
>
<span>QQ{{ state.goodsInfo.qq }}</span>
</a>
</div>
</div>
<div class="developer-recommend">
<h4 class="developer-info-title">{{ t('module.Other works of developers') }}</h4>
<div v-if="state.goodsInfo.developer.goods.length > 0" class="recommend-goods">
<div
v-for="(goods_item, idx) in state.goodsInfo.developer.goods"
:key="idx"
@click="showInfo(goods_item.uid)"
class="recommend-goods-item"
>
<el-image fit="contain" class="recommend-goods-logo" :src="goods_item.logo"> </el-image>
<div class="recommend-goods-title">{{ goods_item.title }}</div>
</div>
</div>
<div v-else class="data-empty">{{ t('module.There are no more works') }}</div>
</div>
</div>
</div>
<div class="goods-detail ba-markdown" v-html="state.goodsInfo.detail_editor"></div>
<div class="goods-version">
<h1>{{ t('module.Update Log') }}</h1>
<div class="version-timeline" v-if="state.goodsInfo.version_log">
<el-timeline>
<el-timeline-item
v-for="(version, idx) in state.goodsInfo.version_log"
:key="idx"
:timestamp="timeFormat(version.createtime)"
placement="top"
:color="idx == 0 ? 'var(--el-color-success)' : ''"
>
<el-card class="version-card" shadow="hover">
<template #header>
<div class="version-card-header">
<h2>{{ version.title }}</h2>
<span class="version-short-describe">{{ version.short_describe }}</span>
</div>
</template>
<div
class="version-detail ba-markdown"
v-html="version.describe ? version.describe : t('module.No detailed update log')"
></div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<div v-else class="empty-update-log">{{ $t('module.No detailed update log') }}</div>
</div>
</el-scrollbar>
</el-dialog>
<Buy />
<Pay />
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onBuy, onDisable, onEnable, onPreInstallModule, onRefreshTableData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import Buy from './buy.vue'
import Pay from './pay.vue'
import { getInstallState, postUninstall } from '/@/api/backend/module'
import { useBaAccount } from '/@/stores/baAccount'
import { timeFormat } from '/@/utils/common'
const installButtonState = {
InstallNow: [moduleInstallState.UNINSTALLED, moduleInstallState.WAIT_INSTALL],
continueInstallation: [moduleInstallState.CONFLICT_PENDING, moduleInstallState.DEPENDENT_WAIT_INSTALL],
alreadyInstalled: [moduleInstallState.INSTALLED],
stateSwitch: [
moduleInstallState.INSTALLED,
moduleInstallState.CONFLICT_PENDING,
moduleInstallState.DEPENDENT_WAIT_INSTALL,
moduleInstallState.DISABLE,
],
updateButton: [moduleInstallState.WAIT_INSTALL, moduleInstallState.INSTALLED, moduleInstallState.DISABLE],
buy: [moduleInstallState.UNINSTALLED],
}
const { t } = useI18n()
const openDemo = (url: string, open: boolean) => {
if (!open || !url) return
window.open(url)
}
const onChangeState = () => {
if (state.goodsInfo.enable) {
onEnable(state.goodsInfo.uid)
} else {
state.common.disableParams = {
uid: state.goodsInfo.uid,
state: 0,
}
onDisable()
}
}
const unInstall = (uid: string) => {
state.loading.common = true
postUninstall(uid)
.then(() => {
onRefreshTableData()
state.dialog.goodsInfo = false
})
.finally(() => {
state.loading.common = false
})
}
const onUpdate = (uid: string, order: number) => {
// 无有效订单
if (!order) {
ElMessageBox.confirm(t('module.No module purchase order was found'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
onBuy(true)
})
.catch(() => {})
return
}
// 未登录
const baAccount = useBaAccount()
if (!baAccount.token) {
state.dialog.baAccount = true
return
}
state.loading.common = true
getInstallState(uid)
.then((res) => {
if (res.data.state == moduleInstallState.DISABLE) {
onPreInstallModule(uid, order, true, true)
} else {
ElMessageBox.confirm(t('module.You need to disable this module before updating Do you want to disable it now?'), t('Reminder'), {
confirmButtonText: t('module.Disable and update'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
state.common.disableParams = {
uid: uid,
state: 0,
update: 1,
}
onDisable()
})
.catch(() => {})
}
})
.finally(() => {
state.loading.common = false
})
}
</script>
<style scoped lang="scss">
:deep(.goods-info-dialog) .el-dialog__body {
padding: 0px 20px;
}
.demo-image,
.demo-image img {
width: 150px;
height: 150px;
}
.demo-item-title {
display: flex;
align-items: center;
.icon {
margin-right: 6px;
}
}
.goods-info {
display: flex;
position: relative;
.goods-images {
max-width: 41%;
width: 300px;
.goods-image-item {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.el-carousel__indicators) {
line-height: 10px;
.el-carousel__indicator {
padding: 0 var(--el-carousel-indicator-padding-horizontal);
}
}
}
.goods-basic {
position: relative;
.goods-basic-title {
padding-bottom: 20px;
}
flex: 1;
padding: 0 10px;
.basic-item {
display: flex;
align-items: center;
padding: 4px 0;
.basic-item-title {
font-size: var(--el-font-size-base);
color: var(--el-text-color-secondary);
width: 80px;
}
.basic-item-price {
font-size: 16px;
color: var(--el-color-danger);
}
.basic-item-content {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
}
}
.basic-button-dropdown-span {
padding-right: 6px;
}
.basic-buttons {
padding-top: 6px;
}
.basic-button-demo {
margin-right: 10px;
}
}
.goods-developer {
width: 20%;
border-left: 1px solid var(--ba-bg-color);
padding: 10px;
position: absolute;
right: 0;
.developer-header {
display: flex;
align-items: center;
justify-content: center;
.developer-name {
padding-left: 10px;
flex: 1;
.developer-group {
padding-top: 5px;
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
}
}
}
.developer-info-title {
color: var(--el-text-color-secondary);
padding-top: 15px;
line-height: 20px;
text-align: center;
}
.contact-item {
cursor: pointer;
padding-left: 10px;
line-height: 30px;
text-align: center;
a {
color: var(--el-color-primary);
text-decoration: none;
}
}
.recommend-goods-item {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
padding: 6px;
&:hover {
background-color: var(--ba-bg-color);
}
.recommend-goods-logo {
width: 42px;
border-radius: var(--el-border-radius-base);
}
.recommend-goods-title {
flex: 1;
margin-left: 6px;
font-size: var(--el-font-size-small);
display: block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 15px;
height: 28px;
}
}
.developer-recommend {
.data-empty {
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
text-align: center;
padding: 6px;
}
}
}
.el-carousel__item:nth-child(2n) {
background-color: #99a9bf;
}
.basic-item-link {
font-size: var(--el-font-size-small);
}
}
.basic-button-item {
--el-loading-spinner-size: 22px;
}
.goods-detail {
width: 80%;
}
.goods-version {
width: 80%;
h1 {
margin: 1.4em 0 0.8em;
font-weight: 700;
font-size: var(--el-font-size-large);
text-transform: uppercase;
color: var(--el-color-primary);
}
.version-timeline {
padding-left: 2px;
:deep(.el-card__body) {
padding: 10px 20px 20px 20px;
}
}
.version-card {
border: 1px solid var(--el-color-info-light-9);
}
.version-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.empty-update-log {
display: flex;
justify-content: center;
color: var(--el-color-info);
}
/* 商品详情弹窗-s */
@media screen and (max-width: 1440px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 65% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 92% !important;
}
}
/* 商品详情弹窗-e */
@media screen and (max-width: 865px) {
.goods-info .goods-developer {
display: none;
}
}
@media screen and (max-width: 540px) {
.goods-info {
flex-wrap: wrap;
.goods-images {
max-width: 100%;
width: 100%;
}
}
.goods-detail {
padding-top: 15px;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div>
<div class="install-conflict">
<template v-if="state.common.fileConflict.length > 0">
<div class="install-title">{{ $t('module.File conflict') }}</div>
<el-table :data="state.common.fileConflict" stripe border :style="{ width: '100%' }">
<el-table-column prop="newFile" :label="$t('module.new file')" />
<el-table-column prop="oldFile" :label="$t('module.Existing files')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Backup and overwrite existing files')" value="cover"></el-option>
<el-option :label="$t('module.Discard new file')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="state.common.dependConflict.length > 0">
<div class="install-title">{{ $t('module.Dependency conflict') }}</div>
<el-table :data="state.common.dependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="newDepend" :label="$t('module.New dependency')" />
<el-table-column prop="oldDepend" :label="$t('module.Existing dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Overwrite existing dependencies')" value="cover"></el-option>
<el-option :label="$t('module.Do not use new dependencies')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-button
v-blur
class="install-done-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onSubmitConflictHandle"
>
{{ $t('Confirm') }}
</el-button>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { execInstall } from '../index'
const onSubmitConflictHandle = () => {
state.loading.common = true
let fileConflict: anyObj = {},
dependConflict: anyObj = {}
for (const key in state.common.fileConflict) {
fileConflict[state.common.fileConflict[key].oldFile] = state.common.fileConflict[key]['solution']
}
for (const key in state.common.dependConflict) {
if (typeof dependConflict[state.common.dependConflict[key].env] == 'undefined') {
dependConflict[state.common.dependConflict[key].env] = {}
}
dependConflict[state.common.dependConflict[key].env][state.common.dependConflict[key].depend] = state.common.dependConflict[key]['solution']
}
execInstall(state.common.uid, 0, '', false, {
dependConflict: dependConflict,
fileConflict: fileConflict,
conflictHandle: true,
})
}
</script>
<style scoped lang="scss">
.install-conflict {
min-height: 400px;
}
.install-title {
font-size: var(--el-font-size-large);
text-align: center;
padding: 20px;
}
.install-done-button {
display: block;
margin: 20px auto;
width: 120px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div>
<el-dialog
v-model="state.dialog.pay"
:close-on-press-escape="false"
:close-on-click-modal="false"
:destroy-on-close="true"
class="pay-dialog"
top="20vh"
width="680px"
>
<div>
<div class="header-box">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/' + (state.common.payType == 'wx' ? 'wechat-pay.png' : 'alipay.png')"
alt=""
/>
</div>
<div class="pay-box">
<div class="left">
<div class="order-info">
<div class="order-info-items">{{ t('module.Order title') }}{{ state.payInfo.info.title }}</div>
<div class="order-info-items">{{ t('module.Order No') }}{{ state.payInfo.info.sn }}</div>
<div class="order-info-items">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-items">
<span>{{ t('module.Order price') }}</span>
<span class="rmb-symbol">
<span class="amount">{{ state.payInfo.info.amount }}</span>
</span>
</div>
</div>
<div class="pay_qr">
<QrcodeVue v-if="state.common.payType == 'wx'" :value="state.payInfo.pay.code_url" :size="220" :margin="0" level="H" />
<iframe
v-if="state.common.payType == 'zfb'"
:srcdoc="state.payInfo.pay.code_url"
frameborder="no"
border="0"
marginwidth="0"
marginheight="0"
scrolling="no"
width="220"
height="220"
style="overflow: hidden"
>
</iframe>
<div v-if="state.payInfo.pay.status == 'success'" class="pay-success">
<Icon name="fa fa-check" color="var(--el-color-success)" size="30" />
</div>
</div>
<el-alert class="qr-tips" :closable="false" type="success" center>
<div class="qr-tips-content">
<Icon color="var(--el-color-success)" :name="state.common.payType == 'wx' ? 'fa fa-wechat' : 'fa fa-buysellads'" />
<span v-if="state.common.payType == 'wx'">{{ t('module.Use WeChat to scan QR code for payment') }}</span>
<span v-if="state.common.payType == 'zfb'">{{ t('module.Use Alipay to scan QR code for payment') }}</span>
</div>
</el-alert>
</div>
<div class="right">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/screenshot-' + (state.common.payType == 'wx' ? 'wechat.png' : 'alipay.png')"
alt=""
/>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import QrcodeVue from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
import { specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
:deep(.pay-dialog) .el-dialog__body {
padding: var(--el-dialog-padding-primary);
padding-top: 0;
}
.header-box {
.pay-logo {
height: 30px;
user-select: none;
}
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.pay-box {
display: flex;
.right {
margin-left: auto;
}
}
.order-info {
padding: 15px 0;
.order-info-items {
line-height: 24px;
.rmb-symbol {
color: var(--el-color-danger);
font-size: 13px;
}
.amount {
color: var(--el-color-danger);
font-size: 16px;
}
}
}
.pay_qr {
display: flex;
margin-bottom: 25px;
justify-content: center;
position: relative;
.pay-success {
border-radius: 50%;
border: 3px solid rgba($color: #67c23a, $alpha: 0.8);
padding: 5px;
position: absolute;
left: calc(50% - 15px);
top: calc(50% - 15px);
}
}
.qr-tips {
margin-top: 15px;
.qr-tips-content {
.icon {
margin-right: 5px;
}
display: flex;
align-items: center;
justify-content: center;
}
}
@media screen and (max-width: 700px) {
:deep(.pay-dialog) {
--el-dialog-width: 96% !important;
}
.pay-box .right {
display: none;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div>
<el-alert class="ba-table-alert" v-if="state.table.remark" :title="state.table.remark" type="info" show-icon />
<div class="modules-header">
<div class="table-header-buttons">
<el-button :title="$t('Refresh')" @click="onRefreshTableData" v-blur color="#40485b" type="info">
<Icon name="fa fa-refresh" color="#ffffff" size="14" />
</el-button>
<el-button-group class="ml10">
<el-button @click="uploadInstall" :title="t('module.Upload zip package for installation')" v-blur type="primary">
<Icon name="fa fa-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Upload installation') }}</span>
</el-button>
<el-button
@click="localModules"
:class="state.table.onlyLocal ? 'local-active' : ''"
:title="t('module.Uploaded / installed modules')"
v-blur
type="primary"
>
<Icon name="fa fa-desktop" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Local module') }}</span>
</el-button>
</el-button-group>
<el-button-group class="ml10 publish-module-button-group">
<el-button @click="navigateTo('https://doc.buildadmin.com/senior/module/start.html')" v-blur type="success">
<Icon name="fa fa-cloud-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Publishing module') }}</span>
</el-button>
<el-button @click="navigateTo('https://doc.buildadmin.com/guide/other/appendix/getPoints.html')" v-blur type="success">
<Icon name="fa fa-rocket" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Get points') }}</span>
</el-button>
</el-button-group>
<el-button v-blur class="ml10 ba-account-button" @click="onShowBaAccount" type="success">
<Icon name="fa fa-user-o" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('layouts.Member information') }}</span>
</el-button>
</div>
<div class="table-search">
<el-input
v-model="state.table.params.quickSearch"
class="xs-hidden"
@input="onSearchInput"
:placeholder="t('module.Search is actually very simple')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { loadData, onRefreshTableData } from '../index'
import { state } from '../store'
const { t } = useI18n()
const localModules = () => {
state.table.onlyLocal = !state.table.onlyLocal
loadData()
}
const onShowBaAccount = () => {
state.dialog.baAccount = true
}
const onSearchInput = debounce(() => {
state.table.modulesEbak[state.table.params.activeTab] = undefined
loadData()
}, 500)
const navigateTo = (url: string) => {
window.open(url, '_blank')
}
const uploadInstall = () => {
state.dialog.common = true
state.common.quickClose = true
state.common.dialogTitle = t('module.Upload installation')
state.common.type = 'uploadInstall'
}
</script>
<style scoped lang="scss">
.ml10 {
margin-left: 10px;
}
.ba-table-alert {
border: none;
}
.modules-header {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 10px;
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.table-header-operate-text {
padding-left: 6px;
}
.table-search {
margin-left: auto;
}
.local-active {
border-color: var(--el-button-active-border-color);
background-color: var(--el-button-active-bg-color);
}
@media screen and (max-width: 1300px) {
.ba-account-button {
display: block;
margin: 10px 0 0 0;
}
}
@media screen and (max-width: 1100px) {
.publish-module-button-group {
display: block;
margin: 10px 0 0 0;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div>
<el-tabs
v-loading="state.loading.table"
:element-loading-text="$t('module.Loading')"
v-model="state.table.params.activeTab"
type="border-card"
class="store-tabs"
@tab-change="onTabChange"
>
<el-tab-pane v-for="cat in state.table.category" :name="cat.id.toString()" :key="cat.id" :label="cat.name" class="store-tab-pane">
<template v-if="state.table.modules[state.table.params.activeTab] && state.table.modules[state.table.params.activeTab].length > 0">
<el-row :gutter="15" class="goods">
<el-col
:xs="12"
:sm="8"
:md="8"
:lg="6"
:xl="4"
v-for="item in state.table.modules[state.table.params.activeTab]"
:key="item.uid"
class="goods-col"
>
<div @click="showInfo(item.uid)" class="goods-item suspension">
<el-image
loading="lazy"
fit="cover"
class="goods-img"
:src="item.logo ? item.logo : fullUrl('/static/images/local-module-logo.png')"
/>
<div class="goods-footer">
<div class="goods-tag" v-if="item.tags && item.tags.length > 0">
<el-tag v-for="(tag, idx) in item.tags" :type="tag.type ? tag.type : 'primary'" :key="idx">
{{ tag.name }}
</el-tag>
</div>
<div class="goods-title">
{{ item.title }}
</div>
<div class="goods-data">
<span class="download-count">
<Icon name="fa fa-download" color="#c0c4cc" size="13" /> {{ item.downloads ? item.downloads : '-' }}
</span>
<span v-if="item.state === moduleInstallState.UNINSTALLED" class="goods-price">
<span class="original-price">{{ currency(item.original_price, item.currency_select) }}</span>
<span class="current-price">{{ currency(item.present_price, item.currency_select) }}</span>
</span>
<div v-else class="goods-price">
<el-tag effect="dark" :type="item.stateTag.type ? item.stateTag.type : 'primary'">
{{ item.stateTag.text }}
</el-tag>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</template>
<el-empty v-else class="modules-empty" :description="$t('module.No more')" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { currency, loadData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { fullUrl } from '/@/utils/common'
const onTabChange = () => {
loadData()
}
</script>
<style scoped lang="scss">
.suspension:hover {
z-index: 1;
}
.goods-item {
display: block;
margin-bottom: 15px;
padding-bottom: 40px;
position: relative;
border-radius: var(--el-border-radius-base);
background-color: var(--el-fill-color-extra-light);
box-shadow: var(--el-box-shadow-light);
cursor: pointer;
}
.goods-img {
display: block;
border-radius: var(--el-border-radius-base);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modules-empty {
width: 100%;
}
.goods-footer {
display: block;
overflow: hidden;
padding: 10px 10px 0 10px;
.goods-tag {
min-height: 60px;
}
.goods-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 6px;
font-size: 14px;
line-height: 18px;
}
.goods-data {
display: flex;
width: calc(100% - 20px);
position: absolute;
bottom: 0;
align-items: baseline;
padding: 10px 0;
.download-count {
color: var(--el-text-color-placeholder);
}
.goods-price {
margin-left: auto;
}
.original-price {
font-size: 13px;
color: var(--el-text-color-placeholder);
text-decoration: line-through;
}
.current-price {
font-size: 16px;
color: var(--el-color-danger);
padding-left: 6px;
}
}
}
.el-tabs--border-card {
border: none;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__header) {
background-color: var(--ba-bg-color);
border-bottom: none;
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
border: 1px solid transparent;
}
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
border-radius: var(--el-border-radius-base);
}
:deep(.store-tabs) .el-tabs__content {
padding: 15px 15px 0 15px;
min-height: 350px;
}
@media screen and (max-width: 520px) {
.goods {
.goods-col {
max-width: 100%;
flex-basis: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="upload-install">
<div class="tips">
<div class="title">{{ $t('module.Local upload warning') }}</div>
<div class="tip-item">1. {{ $t('module.The module can modify and add system files') }}</div>
<div class="tip-item">2. {{ $t('module.The module can execute sql commands and codes') }}</div>
<div class="tip-item">3. {{ $t('module.The module can install new front and rear dependencies') }}</div>
</div>
<el-upload class="upload-module" :show-file-list="false" accept=".zip" drag :auto-upload="false" @change="uploadModule">
<template v-if="state.uploadState == 'wait-file'">
<Icon size="50px" color="#909399" name="el-icon-UploadFilled" />
<div class="el-upload__text">
{{ $t('module.Drag the module package file here') }} <em>{{ $t('module.Click me to upload') }}</em>
</div>
</template>
<el-result v-else icon="success" :sub-title="$t('module.Uploaded, installation is about to start, please wait')"></el-result>
</el-upload>
</div>
</template>
<script setup lang="ts">
import type { UploadFile } from 'element-plus'
import { reactive } from 'vue'
import { onPreInstallModule } from '../index'
import { upload } from '/@/api/backend/module'
import { fileUpload } from '/@/api/common'
const state = reactive({
uploadState: 'wait-file',
})
const uploadModule = (file: UploadFile) => {
if (!file || !file.raw) return
let fd = new FormData()
fd.append('file', file.raw!)
fileUpload(fd, {}, true).then((res) => {
if (res.code == 1) {
upload(res.data.file.url)
.then((res) => {
state.uploadState = 'success'
onPreInstallModule(res.data.info.uid, 0, false, res.data.info.update ? true : false)
})
.catch(() => {
state.uploadState = 'wait-file'
})
}
})
}
</script>
<style scoped lang="scss">
.tips {
padding: 20px;
background-color: var(--el-bg-color-page);
border-radius: var(--el-border-radius-base);
max-width: 400px;
margin: 0 auto;
color: var(--el-color-danger);
.title {
font-size: var(--el-font-size-medium);
padding-bottom: 6px;
}
.tip-item {
font-size: var(--el-font-size-base);
}
}
.upload-module {
max-width: 460px;
margin: 40px auto;
}
</style>

View File

@@ -0,0 +1,604 @@
import { ElNotification } from 'element-plus'
import { isArray } from 'lodash-es'
import { state } from './store'
import { moduleInstallState, type moduleState } from './types'
import {
changeState,
createOrder,
getInstallState,
index,
info,
modules,
payCheck,
payOrder,
postInstallModule,
preDownload,
} from '/@/api/backend/module'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
import { useBaAccount } from '/@/stores/baAccount'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { UserInfo } from '/@/stores/interface'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { changeListenDirtyFileSwitch, closeHotUpdate } from '/@/utils/vite'
export const loadData = () => {
state.loading.table = true
if (!state.table.indexLoaded) {
loadIndex().then(() => {
getModules()
})
} else {
getModules()
}
}
export const onRefreshTableData = () => {
state.table.indexLoaded = false
for (const key in state.table.modulesEbak) {
state.table.modulesEbak[key] = undefined
}
loadData()
}
const loadIndex = () => {
return index().then((res) => {
state.table.indexLoaded = true
state.sysVersion = res.data.sysVersion
state.nuxtVersion = res.data.nuxtVersion
state.installedModule = res.data.installed
const installedModuleUids: string[] = []
const installedModuleVersions: { uid: string; version: string }[] = []
if (res.data.installed) {
state.installedModule.forEach((item) => {
installedModuleUids.push(item.uid)
installedModuleVersions.push({
uid: item.uid,
version: item.version,
})
})
state.installedModuleUids = installedModuleUids
state.installedModuleVersions = installedModuleVersions
}
})
}
const getModules = () => {
if (typeof state.table.modulesEbak[state.table.params.activeTab] != 'undefined') {
state.table.modules[state.table.params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[state.table.params.activeTab])
state.loading.table = false
return
}
const params: anyObj = {}
for (const key in state.table.params) {
if (state.table.params[key] != '') {
params[key] = state.table.params[key]
}
}
const moduleUids: string[] = []
params['installed'] = state.installedModuleVersions
params['sysVersion'] = state.sysVersion
modules(params)
.then((res) => {
if (params.activeTab == 'all') {
res.data.rows.forEach((item: anyObj) => {
moduleUids.push(item.uid)
})
state.installedModule.forEach((item) => {
if (moduleUids.indexOf(item.uid) === -1) {
if (state.table.params.quickSearch) {
if (item.title.includes(state.table.params.quickSearch)) res.data.rows.push(item)
} else {
res.data.rows.push(item)
}
}
})
}
state.table.remark = res.data.remark
state.table.modulesEbak[params.activeTab] = res.data.rows.map((item: anyObj) => {
const idx = state.installedModuleUids.indexOf(item.uid)
if (idx !== -1) {
item.state = state.installedModule[idx].state
item.title = state.installedModule[idx].title
item.version = state.installedModule[idx].version
item.website = state.installedModule[idx].website
item.stateTag = moduleStatus(item.state)
if (!isArray(item.tags)) item.tags = []
item.tags.push({
name: `${i18n.global.t('module.installed')} v${state.installedModule[idx].version}`,
type: 'primary',
})
} else {
item.state = 0
}
if (item.new_version && item.tags) {
item.tags.push({
name: i18n.global.t('module.New version'),
type: 'danger',
})
}
return item
})
state.table.modules[params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[params.activeTab])
state.table.category = res.data.category
})
.finally(() => {
state.loading.table = false
})
}
export const showInfo = (uid: string) => {
state.dialog.goodsInfo = true
state.loading.goodsInfo = true
const localItem = state.installedModule.find((item) => {
return item.uid == uid
})
info({
uid: uid,
localVersion: localItem?.version,
sysVersion: state.sysVersion,
})
.then((res) => {
if (localItem) {
if (res.data.info.type == 'local') {
res.data.info = localItem
res.data.info.images = [fullUrl('/static/images/local-module-logo.png')]
res.data.info.type = 'local' // 纯本地模块
} else {
res.data.info.type = 'online'
res.data.info.state = localItem.state
res.data.info.version = localItem.version
}
res.data.info.enable = localItem.state === moduleInstallState.DISABLE ? false : true
} else {
res.data.info.state = 0
res.data.info.type = 'online'
}
state.goodsInfo = res.data.info
})
.catch((err) => {
if (loginExpired(err)) {
state.dialog.goodsInfo = false
}
})
.finally(() => {
state.loading.goodsInfo = false
})
}
/**
* 支付订单
* @param renew 是否是续费订单
*/
export const onBuy = (renew = false) => {
state.dialog.buy = true
state.loading.buy = true
createOrder({
goods_id: state.goodsInfo.id,
})
.then((res) => {
state.loading.buy = false
state.buy.renew = renew
state.buy.info = res.data.info
})
.catch((err) => {
state.dialog.buy = false
state.loading.buy = false
loginExpired(err)
})
}
export const onPay = (payType: 'score' | 'wx' | 'balance' | 'zfb') => {
state.common.payType = payType
state.loading.common = true
payOrder(state.buy.info.id, payType)
.then((res) => {
// 关闭其他弹窗
state.dialog.buy = false
state.dialog.goodsInfo = false
if (payType == 'wx' || payType == 'zfb') {
// 显示支付二维码
state.dialog.pay = true
state.payInfo = res.data
// 轮询获取支付状态
const timer = setInterval(() => {
payCheck(state.payInfo.info.sn)
.then(() => {
state.payInfo.pay.status = 'success'
clearInterval(timer)
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
state.dialog.pay = false
})
.catch(() => {})
}, 3000)
} else {
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
}
})
.catch((err) => {
loginExpired(err)
})
.finally(() => {
state.loading.common = false
})
}
export const showCommonLoading = (loadingTitle: moduleState['common']['loadingTitle']) => {
state.common.type = 'loading'
state.common.loadingTitle = loadingTitle
state.common.loadingComponentKey = uuid()
}
/**
* 模块预安装
*/
export const onPreInstallModule = (uid: string, id: number, needGetInstallableVersion: boolean, update: boolean = false) => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('module.Install')
const nextStep = (moduleState: number) => {
if (needGetInstallableVersion) {
// 获取模块版本列表
showCommonLoading('getInstallableVersion')
preDownload({
uid,
orderId: id,
sysVersion: state.sysVersion,
nuxtVersion: state.nuxtVersion,
installed: state.installedModuleUids,
})
.then((res) => {
state.common.uid = uid
state.common.update = update
state.common.type = 'selectVersion'
state.common.dialogTitle = i18n.global.t('module.Select Version')
state.common.versions = res.data.versions
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
})
.catch((res) => {
if (loginExpired(res)) return
state.dialog.common = false
})
} else {
// 立即安装(上传安装、继续安装)
showCommonLoading(moduleState === moduleInstallState.UNINSTALLED ? 'download' : 'install')
execInstall(uid, id, '', update)
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
}
}
if (update) {
nextStep(moduleInstallState.DISABLE)
} else {
// 获取安装状态
getInstallState(uid).then((res) => {
if (
res.data.state === moduleInstallState.INSTALLED ||
res.data.state === moduleInstallState.DISABLE ||
res.data.state === moduleInstallState.DIRECTORY_OCCUPIED
) {
ElNotification({
type: 'error',
message:
res.data.state === moduleInstallState.INSTALLED || res.data.state === moduleInstallState.DISABLE
? i18n.global.t('module.Installation cancelled because module already exists!')
: i18n.global.t('module.Installation cancelled because the directory required by the module is occupied!'),
})
state.dialog.common = false
return
}
nextStep(res.data.state)
})
}
}
/**
* 执行安装请求,还包含启用、安装时的冲突处理
*/
export const execInstall = (uid: string, id: number, version: string = '', update: boolean = false, extend: anyObj = {}) => {
postInstallModule(uid, id, version, update, extend)
.then(() => {
state.common.dialogTitle = i18n.global.t('module.Installation complete')
state.common.moduleState = moduleInstallState.INSTALLED
state.common.type = 'done'
onRefreshTableData()
})
.catch((res) => {
if (loginExpired(res)) return
if (res.code == -1) {
state.common.uid = res.data.uid
state.common.type = 'installConflict'
state.common.dialogTitle = i18n.global.t('module.A conflict is found Please handle it manually')
state.common.fileConflict = res.data.fileConflict
state.common.dependConflict = res.data.dependConflict
} else if (res.code == -2) {
state.common.type = 'done'
state.common.uid = res.data.uid
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DEPENDENT_WAIT_INSTALL
state.common.waitInstallDepend = res.data.wait_install
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
if (res.data.wait_install.includes('npm_dependent_wait_install')) {
terminal.addTaskPM('web-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('nuxt_npm_dependent_wait_install')) {
terminal.addTaskPM('nuxt-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'nuxt_npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('composer_dependent_wait_install')) {
terminal.addTask('composer.update', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'composer_dependent_wait_install')
})
}
} else if (res.code == 0) {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
}
})
.finally(() => {
state.loading.common = false
})
}
const terminalTaskExecComplete = (res: number, type: string) => {
if (res == taskStatus.Success) {
state.common.waitInstallDepend = state.common.waitInstallDepend.filter((depend: string) => {
return depend != type
})
if (state.common.waitInstallDepend.length == 0) {
state.common.dependInstallState = 'success'
// 仅在命令全部执行完毕才刷新数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
} else {
const terminal = useTerminal()
terminal.toggle(true)
state.common.dependInstallState = 'fail'
// 有命令执行失败了,刷新一次数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
// 连续安装模块的情况中,首个模块的命令执行完毕时,自动启动了热更新
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
closeHotUpdate('modules')
}
}
export const onDisable = (confirmConflict = false) => {
state.loading.common = true
// 拼装依赖处理方案
if (confirmConflict) {
const dependConflict: anyObj = {}
for (const key in state.common.disableDependConflict) {
if (state.common.disableDependConflict[key]['solution'] != 'delete') {
continue
}
if (typeof dependConflict[state.common.disableDependConflict[key].env] == 'undefined') {
dependConflict[state.common.disableDependConflict[key].env] = []
}
dependConflict[state.common.disableDependConflict[key].env].push(state.common.disableDependConflict[key].depend)
}
state.common.disableParams['confirmConflict'] = 1
state.common.disableParams['dependConflictSolution'] = dependConflict
}
changeState(state.common.disableParams)
.then(() => {
ElNotification({
type: 'success',
message: i18n.global.t('module.The operation succeeds Please clear the system cache and refresh the browser ~'),
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
})
.catch((res) => {
if (res.code == -1) {
state.dialog.common = true
state.common.dialogTitle = i18n.global.t('module.Deal with conflict')
state.common.type = 'disableConfirmConflict'
state.common.disableDependConflict = res.data.dependConflict
if (res.data.conflictFile && res.data.conflictFile.length) {
const conflictFile = []
for (const key in res.data.conflictFile) {
conflictFile.push({
file: res.data.conflictFile[key],
})
}
state.common.disableConflictFile = conflictFile
}
} else if (res.code == -2) {
state.dialog.common = true
const commandsData = {
type: 'disable',
commands: res.data.wait_install,
}
state.common.uid = state.goodsInfo.uid
execCommand(commandsData)
} else if (res.code == -3) {
// 更新
onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, true, true)
} else {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
if (state.common.disableParams && state.common.disableParams.uid) {
showInfo(state.common.disableParams.uid)
} else {
onRefreshTableData()
}
}
})
.finally(() => {
state.loading.common = false
})
}
export const onEnable = (uid: string) => {
state.loading.common = true
changeState({
uid: uid,
state: 1,
})
.then(() => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('Enable')
execInstall(uid, 0)
state.dialog.goodsInfo = false
})
.catch((res) => {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.loading.common = false
})
}
export const loginExpired = (res: ApiResponse) => {
const baAccount = useBaAccount()
if (res.code == 301 || res.code == 408) {
baAccount.removeToken()
state.dialog.baAccount = true
return true
}
return false
}
const modulesOnlyLocalHandle = (modules: anyObj) => {
if (!state.table.onlyLocal) return modules
return modules.filter((item: anyObj) => {
return item.installed
})
}
export const execCommand = (data: anyObj) => {
if (data.type == 'disable') {
state.dialog.common = true
state.common.type = 'done'
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DISABLE
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
data.commands.forEach((item: anyObj) => {
state.common.waitInstallDepend.push(item.type)
if (item.pm) {
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(false)
}
terminal.addTaskPM(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(true)
}
})
} else {
terminal.addTask(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
})
}
})
}
}
export const specificUserName = (userInfo: Partial<UserInfo>) => {
return userInfo.nickname + '' + (userInfo.email || userInfo.mobile || 'ID:' + userInfo.id) + ''
}
export const currency = (price: number, val: number) => {
if (typeof price == 'undefined' || typeof val == 'undefined') {
return '-'
}
if (val == 0) {
return parseInt(price.toString()) + i18n.global.t('Integral')
} else {
return '¥' + price
}
}
export const moduleStatus = (state: number) => {
switch (state) {
case moduleInstallState.INSTALLED:
return {
type: '',
text: i18n.global.t('module.installed'),
}
case moduleInstallState.WAIT_INSTALL:
return {
type: 'success',
text: i18n.global.t('module.Wait for installation'),
}
case moduleInstallState.CONFLICT_PENDING:
return {
type: 'danger',
text: i18n.global.t('module.Conflict pending'),
}
case moduleInstallState.DEPENDENT_WAIT_INSTALL:
return {
type: 'warning',
text: i18n.global.t('module.Dependency to be installed'),
}
case moduleInstallState.DISABLE:
return {
type: 'warning',
text: i18n.global.t('Disable'),
}
default:
return {
type: 'info',
text: i18n.global.t('Unknown'),
}
}
}

View File

@@ -0,0 +1,45 @@
<template>
<div class="default-main ba-table-box">
<TableHeader />
<Tabs />
<GoodsInfo />
<CommonDialog />
<BaAccountDialog v-model="state.dialog.baAccount" :login-callback="() => (state.dialog.baAccount = false)" />
</div>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import CommonDialog from './components/commonDialog.vue'
import GoodsInfo from './components/goodsInfo.vue'
import TableHeader from './components/tableHeader.vue'
import Tabs from './components/tabs.vue'
import { loadData } from './index'
import { state } from './store'
import BaAccountDialog from '/@/layouts/backend/components/baAccount.vue'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'moduleStore/moduleStore',
})
onMounted(() => {
loadData()
closeHotUpdate('modules')
})
onActivated(() => {
closeHotUpdate('modules')
})
onDeactivated(() => {
openHotUpdate('modules')
})
onUnmounted(() => {
openHotUpdate('modules')
})
</script>
<style scoped lang="scss">
:deep(.goods-tag) .el-tag {
margin: 0 6px 6px 0;
}
</style>

View File

@@ -0,0 +1,63 @@
import { reactive } from 'vue'
import { uuid } from '/@/utils/random'
import type { moduleState } from './types'
export const state = reactive<moduleState>({
loading: {
buy: false,
table: true,
common: false,
install: false,
goodsInfo: false,
},
dialog: {
buy: false,
pay: false,
common: false,
goodsInfo: false,
baAccount: false,
},
table: {
remark: '',
modules: [],
modulesEbak: [],
category: [],
onlyLocal: false,
indexLoaded: false,
params: {
quickSearch: '',
activeTab: 'all',
},
},
payInfo: {},
goodsInfo: {},
buy: {
info: {},
renew: false,
agreement: true,
},
common: {
uid: '',
moduleState: 0,
quickClose: false,
type: 'loading',
dialogTitle: '',
fileConflict: [],
dependConflict: [],
loadingTitle: 'init',
loadingComponentKey: uuid(),
waitInstallDepend: [],
dependInstallState: 'none',
disableConflictFile: [],
disableDependConflict: [],
disableParams: {},
payType: 'wx',
update: false,
versions: [],
},
sysVersion: '',
nuxtVersion: '',
installedModule: [],
installedModuleUids: [],
installedModuleVersions: [],
})

View File

@@ -0,0 +1,78 @@
export enum moduleInstallState {
UNINSTALLED,
INSTALLED,
WAIT_INSTALL,
CONFLICT_PENDING,
DEPENDENT_WAIT_INSTALL,
DIRECTORY_OCCUPIED,
DISABLE,
}
export interface moduleInfo {
uid: string
title: string
version: string
state: number
website: string
stateTag: {
type: string
text: string
}
}
export interface moduleState {
loading: {
buy: boolean
table: boolean
common: boolean
install: boolean
goodsInfo: boolean
}
dialog: {
buy: boolean
pay: boolean
common: boolean
goodsInfo: boolean
baAccount: boolean
}
table: {
remark: string
modules: anyObj
modulesEbak: anyObj
category: anyObj
onlyLocal: boolean
indexLoaded: boolean
params: anyObj
}
payInfo: anyObj
goodsInfo: anyObj
buy: {
info: anyObj
renew: boolean
agreement: boolean
}
common: {
uid: string
moduleState: number
quickClose: boolean
type: 'loading' | 'installConflict' | 'done' | 'disableConfirmConflict' | 'uploadInstall' | 'selectVersion'
dialogTitle: string
fileConflict: anyObj[]
dependConflict: anyObj[]
loadingTitle: 'init' | 'download' | 'install' | 'getInstallableVersion'
loadingComponentKey: string
waitInstallDepend: string[]
dependInstallState: 'none' | 'executing' | 'success' | 'fail'
disableConflictFile: { file: string }[]
disableDependConflict: anyObj[]
disableParams: anyObj
payType: 'score' | 'wx' | 'balance' | 'zfb'
update: boolean
versions: anyObj[]
}
sysVersion: string
nuxtVersion: string
installedModule: moduleInfo[]
installedModuleUids: string[]
installedModuleVersions: { uid: string; version: string }[]
}

View File

@@ -0,0 +1,297 @@
<template>
<div class="default-main">
<el-row :gutter="30">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="10">
<div class="admin-info">
<el-upload
class="avatar-uploader"
action=""
:show-file-list="false"
@change="onAvatarBeforeUpload"
:auto-upload="false"
accept="image/gif, image/jpg, image/jpeg, image/bmp, image/png, image/webp"
v-if="!isEmpty(state.adminInfo)"
>
<el-image fit="cover" :src="fullUrl(state.adminInfo.avatar)" class="avatar">
<template #error>
<div class="image-slot">
<Icon size="30" color="#c0c4cc" name="el-icon-Picture" />
</div>
</template>
</el-image>
</el-upload>
<div class="admin-info-base">
<div class="admin-nickname">{{ state.adminInfo.nickname }}</div>
<div class="admin-other">
<div>{{ t('routine.adminInfo.Last logged in on') }} {{ timeFormat(state.adminInfo.last_login_time) }}</div>
</div>
</div>
<div class="admin-info-form">
<el-form
@keyup.enter="onSubmit()"
:key="state.formKey"
label-position="top"
:rules="rules"
ref="formRef"
:model="state.adminInfo"
>
<el-form-item :label="t('routine.adminInfo.user name')">
<el-input disabled v-model="state.adminInfo.username"></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.e-mail address')" prop="email">
<el-input
:placeholder="t('Please input field', { field: t('routine.adminInfo.e-mail address') })"
v-model="state.adminInfo.email"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.phone number')" prop="mobile">
<el-input
:placeholder="t('Please input field', { field: t('routine.adminInfo.phone number') })"
v-model="state.adminInfo.mobile"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.autograph')" prop="motto">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
:placeholder="t('routine.adminInfo.This guy is lazy and doesn write anything')"
type="textarea"
v-model="state.adminInfo.motto"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.New password')" prop="password">
<el-input
type="password"
:placeholder="t('routine.adminInfo.Please leave blank if not modified')"
v-model="state.adminInfo.password"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="state.buttonLoading" @click="onSubmit()">
{{ t('routine.adminInfo.Save changes') }}
</el-button>
<el-button @click="onResetForm(formRef)">{{ t('Reset') }}</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-col>
<el-col v-loading="state.logLoading" :xs="24" :sm="24" :md="24" :lg="12">
<el-card :header="t('routine.adminInfo.Operation log')" shadow="never">
<el-timeline>
<el-timeline-item v-for="(item, idx) in state.log" :key="idx" size="large" :timestamp="timeFormat(item.create_time)">
{{ item.title }}
</el-timeline-item>
</el-timeline>
<el-pagination
:currentPage="state.logCurrentPage"
:page-size="state.logPageSize"
:page-sizes="[12, 22, 52, 100]"
background
layout="prev, next, jumper"
:total="state.logTotal"
@size-change="onLogSizeChange"
@current-change="onLogCurrentChange"
></el-pagination>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { index, log, postData } from '/@/api/backend/routine/AdminInfo'
import type { FormItemRule } from 'element-plus'
import { fullUrl, onResetForm, timeFormat } from '/@/utils/common'
import { uuid } from '../../../utils/random'
import { buildValidatorData } from '/@/utils/validate'
import { fileUpload } from '/@/api/common'
import { useAdminInfo } from '/@/stores/adminInfo'
import { isEmpty } from 'lodash-es'
defineOptions({
name: 'routine/adminInfo',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const adminInfoStore = useAdminInfo()
const state: {
adminInfo: anyObj
formKey: string
buttonLoading: boolean
log: {
title: string
create_time: string
url: string
}[]
logFilter: anyObj
logCurrentPage: number
logPageSize: number
logTotal: number
logLoading: boolean
} = reactive({
adminInfo: {},
formKey: uuid(),
buttonLoading: false,
log: [],
logFilter: {
limit: 12,
},
logCurrentPage: 1,
logPageSize: 12,
logTotal: 100,
logLoading: true,
})
index().then((res) => {
state.adminInfo = res.data.info
// 重新渲染表单以记录初始值
state.formKey = uuid()
// 管理员日志加载,加筛选防止超管读取到其他管理员的日志记录
state.logFilter.search = [
{
field: 'admin_id',
val: res.data.info.id,
operator: 'eq',
},
]
getLog()
})
const getLog = () => {
log(state.logFilter)
.then((res) => {
state.log = res.data.list
state.logTotal = res.data.total
state.logLoading = false
})
.catch(() => {
state.logLoading = false
})
}
const onLogSizeChange = (limit: number) => {
state.logPageSize = limit
state.logFilter.limit = limit
getLog()
}
const onLogCurrentChange = (page: number) => {
state.logCurrentPage = page
state.logFilter.page = page
getLog()
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
nickname: [buildValidatorData({ name: 'required', title: t('routine.adminInfo.User nickname') })],
email: [buildValidatorData({ name: 'email', title: t('routine.adminInfo.e-mail address') })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('routine.adminInfo.phone number') }) })],
password: [buildValidatorData({ name: 'password' })],
})
const onAvatarBeforeUpload = (file: any) => {
let fd = new FormData()
fd.append('file', file.raw)
fileUpload(fd).then((res) => {
if (res.code == 1) {
postData({
id: state.adminInfo.id,
avatar: res.data.file.url,
}).then(() => {
adminInfoStore.dataFill({ avatar: res.data.file.full_url })
state.adminInfo.avatar = res.data.file.full_url
})
}
})
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
let data = { ...state.adminInfo }
delete data.last_login_time
delete data.username
delete data.avatar
state.buttonLoading = true
postData(data)
.then(() => {
adminInfoStore.dataFill({ nickname: state.adminInfo.nickname })
state.buttonLoading = false
})
.catch(() => {
state.buttonLoading = false
})
}
})
}
</script>
<style scoped lang="scss">
.admin-info {
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
border-top: 3px solid #409eff;
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 60px auto 10px auto;
border-radius: 50%;
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.admin-info-base {
.admin-nickname {
font-size: 22px;
color: var(--el-text-color-primary);
text-align: center;
padding: 8px 0;
}
.admin-other {
color: var(--el-text-color-regular);
font-size: 14px;
text-align: center;
line-height: 20px;
}
}
.admin-info-form {
padding: 30px;
}
}
.el-card :deep(.el-timeline-item__icon) {
font-size: 10px;
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
import { buildSuffixSvgUrl } from '/@/api/common'
/**
* 表格和表单中文件预览图的生成
*/
export const previewRenderFormatter = (row: TableRow, column: TableColumn, cellValue: string) => {
const imgSuffix = ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp']
if (imgSuffix.includes(cellValue)) {
return row.full_url
}
return buildSuffixSvgUrl(cellValue)
}

View File

@@ -0,0 +1,184 @@
<template>
<div class="default-main">
<div class="ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'edit', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('utils.Original name') })"
>
<el-popconfirm
v-if="auth('del')"
@confirm="baTable.onTableHeaderAction('delete', {})"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('routine.attachment.Files and records will be deleted at the same time Are you sure?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('Delete selected row')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="danger"
>
<Icon color="#ffffff" name="fa fa-trash" />
<span class="table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 编辑和新增表单 -->
<PopupForm />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { previewRenderFormatter } from './index'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import { auth } from '/@/utils/common'
defineOptions({
name: 'routine/attachment',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optBtn = defaultOptButtons(['edit', 'delete'])
optBtn[1].popconfirm = {
...optBtn[1].popconfirm,
title: t('routine.attachment.Files and records will be deleted at the same time Are you sure?'),
}
const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('utils.Breakdown'), prop: 'topic', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('routine.attachment.Upload administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('routine.attachment.Upload user'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.size'),
prop: 'size',
align: 'center',
formatter: (row: TableRow, column: TableColumn, cellValue: string) => {
const size = parseFloat(cellValue)
const i = Math.floor(Math.log(size) / Math.log(1024))
return (size / Math.pow(1024, i)).toFixed(i < 1 ? 0 : 2) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]
},
operator: 'RANGE',
sortable: 'custom',
operatorPlaceholder: 'bytes',
},
{
label: t('utils.type'),
prop: 'mimetype',
align: 'center',
operator: 'LIKE',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.preview'),
prop: 'suffix',
align: 'center',
formatter: previewRenderFormatter,
render: 'image',
operator: false,
},
{
label: t('utils.Upload (Reference) times'),
prop: 'quote',
align: 'center',
width: 150,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('utils.Original name'),
prop: 'name',
align: 'center',
showOverflowTooltip: true,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('routine.attachment.Storage mode'),
prop: 'storage',
align: 'center',
width: 100,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.Last upload time'),
prop: 'last_upload_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
width: 160,
sortable: 'custom',
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: optBtn,
operator: false,
},
],
defaultOrder: { prop: 'last_upload_time', order: 'desc' },
})
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
})
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
.table-header-operate-text {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
@keyup.enter="baTable.onSubmit()"
v-model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
>
<el-form-item :label="t('utils.preview')">
<el-image
class="preview-img"
:preview-src-list="[baTable.form.items!.full_url]"
:src="previewRenderFormatter(baTable.form.items!, {}, baTable.form.items!.suffix)"
></el-image>
</el-form-item>
<el-form-item :label="t('utils.Breakdown')">
<el-input
v-model="baTable.form.items!.topic"
type="string"
:placeholder="
t(
'routine.attachment.The file is saved in the directory, and the file will not be automatically transferred if the record is modified'
)
"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.Physical path')">
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('routine.attachment.File saving path Modifying records will not automatically transfer files')"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.image width')">
<el-input
v-model="baTable.form.items!.width"
type="number"
:placeholder="t('routine.attachment.Width of picture file')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.Picture height')">
<el-input
v-model="baTable.form.items!.height"
type="number"
:placeholder="t('routine.attachment.Height of picture file')"
></el-input>
</el-form-item>
<el-form-item :label="t('utils.Original name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('routine.attachment.Original file name')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.file size')">
<el-input
v-model="baTable.form.items!.size"
type="number"
:placeholder="t('routine.attachment.File size (bytes)')"
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.mime type')">
<el-input
v-model="baTable.form.items!.mimetype"
type="string"
:placeholder="t('routine.attachment.File MIME type')"
></el-input>
</el-form-item>
<el-form-item :label="t('utils.Upload (Reference) times')">
<el-input
v-model="baTable.form.items!.quote"
type="number"
:placeholder="t('routine.attachment.Upload (Reference) times of this file')"
></el-input>
<span class="block-help">
{{
t(
'routine.attachment.When the same file is uploaded multiple times, only one attachment record will be saved and added'
)
}}
</span>
</el-form-item>
<el-form-item :label="t('routine.attachment.Storage mode')">
<el-input
v-model="baTable.form.items!.storage"
type="string"
:placeholder="t('routine.attachment.Storage mode')"
readonly
></el-input>
</el-form-item>
<el-form-item :label="t('routine.attachment.SHA1 code')">
<el-input
v-model="baTable.form.items!.sha1"
type="string"
:placeholder="t('routine.attachment.SHA1 encoding of file')"
readonly
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit()" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { previewRenderFormatter } from './index'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="props.modelValue" @close="closeForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('routine.config.Add configuration item') }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div class="ba-operate-form ba-add-form" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + state.labelWidth / 2 + 'px)'">
<el-form
ref="formRef"
@keyup.enter="onAddSubmit()"
:rules="rules"
:model="{ ...state.addConfig, ...state.formItemData }"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="160"
>
<FormItem
:label="t('routine.config.Variable group')"
type="select"
v-model="state.addConfig.group"
prop="group"
:input-attr="{ content: configGroup }"
:placeholder="t('Please select field', { field: t('routine.config.Variable group') })"
/>
<CreateFormItemData v-model="state.formItemData" />
<FormItem :label="t('Weigh')" type="number" v-model="state.addConfig.weigh" prop="weigh" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + state.labelWidth / 1.8 + 'px)'">
<el-button @click="closeForm">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="state.submitLoading" @click="onAddSubmit()" type="primary"> {{ t('Add') }} </el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import FormItem from '/@/components/formItem/index.vue'
import type { FormRules } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { postData } from '/@/api/backend/routine/config'
import CreateFormItemData from '/@/components/formItem/createData.vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
const config = useConfig()
interface Props {
modelValue: boolean
configGroup: anyObj
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
configGroup: () => {
return {}
},
})
const emits = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const closeForm = () => {
emits('update:modelValue', false)
}
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const state: {
inputTypes: anyObj
labelWidth: number
submitLoading: boolean
addConfig: {
group: string
weigh: number
content: string
}
formItemData: anyObj
} = reactive({
inputTypes: {},
labelWidth: 180,
submitLoading: false,
addConfig: {
group: '',
weigh: 0,
content: '',
},
formItemData: {
dict: `key1=value1
key2=value2`,
},
})
const rules = reactive<FormRules>({
group: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('routine.config.Variable group') }),
}),
],
name: [
buildValidatorData({ name: 'required', title: t('routine.config.Variable name') }),
buildValidatorData({ name: 'varName', message: t('Please enter the correct field', { field: t('routine.config.Variable name') }) }),
],
title: [buildValidatorData({ name: 'required', title: t('routine.config.Variable title') })],
type: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('routine.config.Variable type') }),
}),
],
weigh: [buildValidatorData({ name: 'integer', title: t('routine.config.number') })],
})
const onAddSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
state.addConfig.content = state.formItemData.dict
delete state.formItemData.dict
postData('add', { ...state.addConfig, ...state.formItemData }).then(() => {
emits('update:modelValue', false)
})
}
})
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="default-main">
<el-row v-loading="state.loading" :gutter="20">
<el-col class="xs-mb-20" :xs="24" :sm="16">
<el-form
v-if="!state.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="onSubmit()"
:model="state.form"
:rules="state.rules"
:label-position="'top'"
:key="state.formKey"
>
<el-tabs v-model="state.activeTab" type="border-card" :before-leave="onBeforeLeave">
<el-tab-pane class="config-tab-pane" v-for="(group, key) in state.config" :key="key" :name="key" :label="group.title">
<div class="config-form-item" v-for="(item, idx) in group.list" :key="idx">
<template v-if="item.group == state.activeTab">
<FormItem
v-if="item.type == 'number'"
:label="item.title"
:type="item.type"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{ ...item.input_extend }"
:tip="item.tip"
:key="'number-' + item.id"
/>
<!-- 富文本在dialog内全屏编辑器时必须拥有很高的z-index此处选择单独为editor设定较小的z-index -->
<FormItem
v-else-if="item.type == 'editor'"
:label="item.title"
:type="item.type"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{
style: {
zIndex: 99,
},
...item.input_extend,
}"
:tip="item.tip"
:key="'editor-' + item.id"
/>
<FormItem
v-else-if="item.type == 'textarea'"
:label="item.title"
:type="item.type"
@keyup.enter.stop=""
@keyup.ctrl.enter="onSubmit()"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="{ rows: 3, ...item.input_extend }"
:tip="item.tip"
:key="'textarea-' + item.id"
/>
<FormItem
v-else
:label="item.title"
:type="item.type"
v-model="state.form[item.name]"
:attr="{ prop: item.name, ...item.extend }"
:input-attr="!isEmpty(item.content) ? { content: item.content, ...item.input_extend } : item.input_extend"
:tip="item.tip"
:key="'other-' + item.id"
/>
<div class="config-form-item-name">${{ item.name }}</div>
<div class="del-config-form-item">
<el-popconfirm
@confirm="onDelConfig(item)"
v-if="item.allow_del"
:confirmButtonText="t('Delete')"
:title="t('routine.config.Are you sure to delete the configuration item?')"
>
<template #reference>
<Icon class="close-icon" size="15" name="el-icon-Close" />
</template>
</el-popconfirm>
</div>
</template>
</div>
<div v-if="group.name == 'mail'" class="send-test-mail">
<el-button @click="onTestSendMail()">{{ t('routine.config.Test mail sending') }}</el-button>
</div>
<el-button type="primary" @click="onSubmit()">{{ t('Save') }}</el-button>
</el-tab-pane>
<el-tab-pane
name="add_config"
class="config-tab-pane config-tab-pane-add"
:label="t('routine.config.Add configuration item')"
></el-tab-pane>
</el-tabs>
</el-form>
</el-col>
<el-col :xs="24" :sm="8">
<el-card :header="t('routine.config.Quick configuration entry')">
<el-button v-for="(item, idx) in state.quickEntrance" class="config_quick_entrance" :key="idx">
<div @click="routePush({ name: item['value'] })">{{ item['key'] }}</div>
</el-button>
</el-card>
</el-col>
</el-row>
<AddFrom v-if="!state.loading" v-model="state.showAddForm" :config-group="state.configGroup" />
</div>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { ElMessageBox, ElNotification } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { onActivated, onDeactivated, onMounted, onUnmounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AddFrom from './add.vue'
import { del, index, postData, postSendTestMail } from '/@/api/backend/routine/config'
import FormItem from '/@/components/formItem/index.vue'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import type { SiteConfig } from '/@/stores/interface'
import { useSiteConfig } from '/@/stores/siteConfig'
import { uuid } from '/@/utils/random'
import { routePush } from '/@/utils/router'
import { buildValidatorData, type buildValidatorParams } from '/@/utils/validate'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'routine/config',
})
const { t } = useI18n()
const siteConfig = useSiteConfig()
const formRef = useTemplateRef('formRef')
const state: {
loading: boolean
config: anyObj
remark: string
configGroup: anyObj
activeTab: string
showAddForm: boolean
rules: Partial<Record<string, FormItemRule[]>>
form: anyObj
quickEntrance: anyObj
formKey: string
} = reactive({
loading: true,
config: [],
remark: '',
configGroup: {},
activeTab: '',
showAddForm: false,
rules: {},
form: {},
quickEntrance: {},
formKey: uuid(),
})
const getData = () => {
index()
.then((res) => {
state.config = res.data.list
state.remark = res.data.remark
state.configGroup = res.data.configGroup
state.quickEntrance = res.data.quickEntrance
state.loading = false
for (const key in state.configGroup) {
state.activeTab = key
break
}
let formNames: anyObj = {}
let rules: Partial<Record<string, FormItemRule[]>> = {}
for (const key in state.config) {
for (const lKey in state.config[key].list) {
if (state.config[key].list[lKey].rule) {
let ruleStr = state.config[key].list[lKey].rule.split(',')
let ruleArr: anyObj = []
ruleStr.forEach((item: string) => {
ruleArr.push(
buildValidatorData({ name: item as buildValidatorParams['name'], title: state.config[key].list[lKey].title })
)
})
rules = Object.assign(rules, {
[state.config[key].list[lKey].name]: ruleArr,
})
}
formNames[state.config[key].list[lKey].name] =
state.config[key].list[lKey].type == 'number'
? parseFloat(state.config[key].list[lKey].value)
: state.config[key].list[lKey].value
}
}
state.form = formNames
state.rules = rules
state.formKey = uuid()
})
.catch(() => {
state.loading = false
})
}
const onBeforeLeave = (newTabName: string | number) => {
if (newTabName == 'add_config') {
state.showAddForm = true
return false
}
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
// 只提交当前tab的表单数据
const formData: anyObj = {}
for (const key in state.config) {
if (key != state.activeTab) {
continue
}
for (const lKey in state.config[key].list) {
formData[state.config[key].list[lKey].name] = state.form[state.config[key].list[lKey].name] ?? ''
}
}
postData('edit', formData).then(() => {
for (const key in siteConfig.$state) {
if (formData[key] && siteConfig.$state[key as keyof SiteConfig] != formData[key]) {
;(siteConfig.$state[key as keyof SiteConfig] as any) = formData[key]
}
}
if (formData.backend_entrance && formData.backend_entrance != adminBaseRoutePath) {
window.open(window.location.href.replace(adminBaseRoutePath, formData.backend_entrance))
window.close()
}
})
}
})
}
const onDelConfig = (config: anyObj) => {
del([config.id]).then(() => {
getData()
})
}
const onTestSendMail = () => {
if (!state.form.smtp_server || !state.form.smtp_port || !state.form.smtp_user || !state.form.smtp_pass || !state.form.smtp_sender_mail) {
ElNotification({
type: 'error',
message: t('routine.config.Please enter the correct mail configuration'),
})
return false
}
ElMessageBox.prompt(t('routine.config.Please enter the recipient email address'), t('routine.config.Test mail sending'), {
confirmButtonText: t('routine.config.send out'),
cancelButtonText: t('Cancel'),
inputPattern: /[\w!#$%&'*+/=?^_`{|}~-]+(?:\.[\w!#$%&'*+/=?^_`{|}~-]+)*@(?:[\w](?:[\w-]*[\w])?\.)+[\w](?:[\w-]*[\w])?/,
inputErrorMessage: t('routine.config.Please enter the correct email address'),
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
instance.confirmButtonText = t('routine.config.Sending')
postSendTestMail(state.form, instance.inputValue)
.then(() => {
done()
})
.catch(() => {
done()
})
} else {
done()
}
},
})
}
onMounted(() => {
getData()
closeHotUpdate('config')
})
onActivated(() => {
closeHotUpdate('config')
})
onDeactivated(() => {
openHotUpdate('config')
})
onUnmounted(() => {
openHotUpdate('config')
})
</script>
<style scoped lang="scss">
.send-test-mail {
padding-bottom: 20px;
}
.el-tabs--border-card {
border: none;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__header) {
background-color: var(--ba-bg-color);
border-bottom: none;
border-top-left-radius: var(--el-border-radius-base);
border-top-right-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
border: 1px solid transparent;
}
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
border-top-left-radius: var(--el-border-radius-base);
border-top-right-radius: var(--el-border-radius-base);
}
.el-card :deep(.el-card__header) {
height: 40px;
padding: 0;
line-height: 40px;
border: none;
padding-left: 20px;
background-color: var(--ba-bg-color);
}
.config-tab-pane {
padding: 5px;
}
.config-tab-pane-add {
width: 80%;
}
.config-form-item {
display: flex;
align-items: center;
.el-form-item {
flex: 13;
}
.config-form-item-name {
opacity: 0;
flex: 3;
font-size: 13px;
color: var(--el-text-color-disabled);
padding-left: 20px;
}
.del-config-form-item {
cursor: pointer;
flex: 1;
padding-left: 10px;
}
.close-icon {
display: none;
}
&:hover {
.config-form-item-name {
opacity: 1;
}
.close-icon {
display: block;
color: var(--el-text-color-disabled) !important;
}
}
}
.config_quick_entrance {
margin-left: 10px;
margin-bottom: 10px;
}
@media screen and (max-width: 768px) {
.xs-mb-20 {
margin-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.dataRecycle.Rule name') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import { add, url } from '/@/api/backend/security/dataRecycle'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycle',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: 'ID', prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('security.dataRecycle.Rule name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.dataRecycle.controller'),
prop: 'controller',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycle.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycle.Data table primary key'),
prop: 'primary_key',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 100,
},
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: 1,
},
}
)
// 获取控制器和数据表数据
baTable.before.toggleForm = ({ operate }) => {
if (operate == 'Add' || operate == 'Edit') {
baTable.form.loading = true
add().then((res) => {
baTable.form.extend!.controllerList = res.data.controllers
baTable.form.loading = false
})
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('security.dataRecycle.Rule name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('security.dataRecycle.The rule name helps to identify deleted data later')"
/>
<FormItem
:label="t('security.dataRecycle.controller')"
type="select"
v-model="baTable.form.items!.controller"
prop="controller"
:input-attr="{ content: baTable.form.extend!.controllerList }"
:placeholder="t('security.dataRecycle.The data collection mechanism will monitor delete operations under this controller')"
/>
<FormItem
:label="t('Database connection')"
v-model="baTable.form.items!.connection"
type="remoteSelect"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
valueOnClear: '',
}"
/>
<FormItem
:label="t('security.dataRecycle.Corresponding data sheet')"
type="remoteSelect"
v-model="baTable.form.items!.data_table"
:key="baTable.form.items!.connection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: baTable.form.items!.connection,
samePrefix: 1,
excludeTable: ['area', 'token', 'captcha', 'admin_group_access', 'user_money_log', 'user_score_log'],
},
remoteUrl: getTableListUrl,
onRow: onTableChange,
}"
prop="data_table"
/>
<FormItem
:label="t('security.dataRecycle.Data table primary key')"
type="string"
v-model="baTable.form.items!.primary_key"
prop="primary_key"
/>
<FormItem
:label="t('State')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { getTablePk, getTableListUrl, getDatabaseConnectionListUrl } from '/@/api/common'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('security.dataRecycle.Rule name') })],
controller: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.dataRecycle.controller') }),
}),
],
data_table: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.dataRecycle.data sheet') }),
}),
],
primary_key: [buildValidatorData({ name: 'required', trigger: 'change', title: t('security.dataRecycle.Data table primary key') })],
})
const onTableChange = () => {
if (!baTable.form.items!.data_table) return
getTablePk(baTable.form.items!.data_table, baTable.form.items!.connection).then((res) => {
baTable.form.items!.primary_key = res.data.pk
baTable.form.defaultItems!.primary_key = res.data.pk
})
}
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.dataRecycleLog.Rule name') })"
>
<el-popconfirm
@confirm="onRestoreAction"
:confirm-button-text="t('security.dataRecycleLog.restore')"
:cancel-button-text="t('Cancel')"
confirmButtonType="success"
:title="t('security.dataRecycleLog.Are you sure to restore the selected records?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('security.dataRecycleLog.Restore the selected record to the original data table')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="success"
>
<Icon color="#ffffff" name="el-icon-RefreshRight" />
<span class="table-header-operate-text">{{ t('security.dataRecycleLog.restore') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<InfoDialog />
</div>
</template>
<script setup lang="ts">
import { provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { info, restore, url } from '/@/api/backend/security/dataRecycleLog'
import InfoDialog from './info.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { buildJsonToElTreeData } from '/@/utils/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycleLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-info',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row[baTable.table.pk!])
},
},
{
render: 'confirmButton',
name: 'restore',
title: 'security.dataRecycleLog.restore',
text: '',
type: 'success',
icon: 'el-icon-RefreshRight',
class: 'table-row-edit',
popconfirm: {
confirmButtonText: t('security.dataRecycleLog.restore'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'success',
title: t('security.dataRecycleLog.Are you sure to restore the selected records?'),
},
disabledTip: false,
click: (row: TableRow) => {
onRestore([row[baTable.table.pk!]])
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi(url), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('security.dataRecycleLog.Operation administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.Recycling rule name'),
prop: 'recycle.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.controller'),
prop: 'recycle.controller_as',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.dataRecycleLog.DeletedData'),
prop: 'data',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('security.dataRecycleLog.Arbitrary fragment fuzzy query'),
showOverflowTooltip: true,
},
{ label: 'IP', prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
show: false,
label: 'User Agent',
prop: 'useragent',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('security.dataRecycleLog.Delete time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: 120,
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row[baTable.table.pk!])
return false
}
const onRestore = (ids: string[]) => {
restore(ids).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
}
const onRestoreAction = () => {
onRestore(baTable.getSelectionIds())
}
const infoButtonClick = (id: string) => {
baTable.form.extend!['info'] = {}
baTable.form.operate = 'Info'
baTable.form.loading = true
info(id).then((res) => {
res.data.row.data = res.data.row.data
? [{ label: t('security.dataRecycleLog.Click to expand'), children: buildJsonToElTreeData(res.data.row.data) }]
: []
baTable.form.extend!['info'] = res.data.row
baTable.form.loading = false
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions v-if="!isEmpty(baTable.form.extend!.info)" :column="2" border>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Operation administrator')">
{{ baTable.form.extend!.info.admin?.nickname + '(' + baTable.form.extend!.info.admin?.username + ')' }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Recycling rule name')">
{{ baTable.form.extend!.info.recycle?.name }}
</el-descriptions-item>
<el-descriptions-item :label="t('Connection')">
{{ baTable.form.extend!.info.connection }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.data sheet')">
{{ baTable.form.extend!.info.data_table }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Data table primary key')">
{{ baTable.form.extend!.info.primary_key }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.dataRecycleLog.Delete time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" :label="t('security.dataRecycleLog.Deleted data')" label-class-name="color-red">
<el-tree class="table-el-tree" :data="baTable.form.extend!.info.data" :props="{ label: 'label', children: 'children' }" />
</el-descriptions-item>
</el-descriptions>
</div>
</el-scrollbar>
<template #footer>
<el-button v-blur @click="onRestore(baTable.form.extend!.info.id)" type="success">
<Icon color="#ffffff" name="el-icon-RefreshRight" />
<span class="table-header-operate-text">{{ t('security.dataRecycleLog.restore') }}</span>
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
import { isEmpty } from 'lodash-es'
import { ElMessageBox } from 'element-plus'
import { restore } from '/@/api/backend/security/dataRecycleLog'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
const onRestore = (id: string) => {
ElMessageBox.confirm(t('security.dataRecycleLog.Are you sure to restore the selected records?'), '', {
confirmButtonText: t('security.dataRecycleLog.restore'),
cancelButtonText: t('Cancel'),
})
.then(() => {
restore([id]).then(() => {
baTable.toggleForm()
baTable.onTableHeaderAction('refresh', {})
})
})
.catch(() => {})
}
</script>
<style scoped lang="scss">
:deep(.color-red) {
color: var(--el-color-danger) !important;
}
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
</style>

View File

@@ -0,0 +1,113 @@
import baTableClass from '/@/utils/baTable'
import type { baTableApi } from '/@/api/common'
import { getTableFieldList } from '/@/api/common'
import { add } from '/@/api/backend/security/sensitiveData'
import { uuid } from '/@/utils/random'
export interface DataFields {
name: string
value: string
}
export class sensitiveDataClass extends baTableClass {
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
super(api, table, form, before, after)
}
// 重写编辑
getEditData = (id: string) => {
this.form.loading = true
this.form.items = {}
return this.api.edit({ id: id }).then((res) => {
const fields: string[] = []
const dataFields: DataFields[] = []
for (const key in res.data.row.data_fields) {
fields.push(key)
dataFields.push({
name: key,
value: res.data.row.data_fields[key] ?? '',
})
}
this.form.items!.connection = res.data.row.connection ? res.data.row.connection : ''
this.form.extend!.controllerList = res.data.controllers
if (res.data.row.data_table) {
this.onTableChange(res.data.row.data_table)
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields(dataFields)
}
res.data.row.data_fields = fields
this.form.loading = false
this.form.items = res.data.row
})
}
onConnectionChange = () => {
this.form.extend!.fieldList = {}
this.form.extend!.fieldSelect = {}
this.form.extend!.fieldSelectKey = uuid()
this.form.items!.data_table = ''
this.form.items!.data_fields = []
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
}
// 数据表改变事件
onTableChange = (table: string) => {
this.form.extend = Object.assign(this.form.extend!, {
fieldLoading: true,
fieldList: {},
fieldSelect: {},
fieldSelectKey: uuid(),
})
this.form.items!.data_fields = []
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
getTableFieldList(table, true, this.form.items!.connection).then((res) => {
this.form.items!.primary_key = res.data.pk
this.form.defaultItems!.primary_key = res.data.pk
const fieldSelect: anyObj = {}
for (const key in res.data.fieldList) {
fieldSelect[key] = (key ? key + ' - ' : '') + res.data.fieldList[key]
}
this.form.extend = Object.assign(this.form.extend!, {
fieldLoading: false,
fieldList: res.data.fieldList,
fieldSelect: fieldSelect,
fieldSelectKey: uuid(),
})
})
}
/**
* 重写打开表单方法
*/
toggleForm = (operate = '', operateIds: string[] = []) => {
if (this.form.ref) {
this.form.ref.resetFields()
}
if (this.form.extend!.parentRef) this.form.extend!.parentRef.setDataFields([])
if (operate == 'Edit') {
if (!operateIds.length) {
return false
}
this.getEditData(operateIds[0])
} else if (operate == 'Add') {
this.form.loading = true
add().then((res) => {
this.form.extend!.controllerList = res.data.controllers
this.form.items = Object.assign({}, this.form.defaultItems)
this.form.loading = false
})
}
this.form.operate = operate
this.form.operateIds = operateIds
}
}

View File

@@ -0,0 +1,125 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.sensitiveData.controller') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { sensitiveDataClass } from './index'
import { url } from '/@/api/backend/security/sensitiveData'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/dataRecycle',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new sensitiveDataClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: 'ID', prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('security.sensitiveData.Rule name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.sensitiveData.controller'),
prop: 'controller',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveData.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveData.Data table primary key'),
prop: 'primary_key',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
width: 100,
},
{
label: t('security.sensitiveData.Sensitive fields'),
prop: 'data_fields',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
baTable.before.onSubmit = () => {
baTable.form.items!.fields = formRef.value?.getDataFields()
}
provide('baTable', baTable)
onMounted(() => {
baTable.form.extend!.parentRef = formRef.value
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,229 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('security.sensitiveData.Rule name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('security.sensitiveData.The rule name helps to identify the modified data later')"
/>
<FormItem
:label="t('security.sensitiveData.controller')"
type="select"
v-model="baTable.form.items!.controller"
prop="controller"
:input-attr="{ content: baTable.form.extend!.controllerList }"
:placeholder="
t('security.sensitiveData.The data listening mechanism will monitor the modification operations under this controller')
"
/>
<FormItem
:label="t('Database connection')"
v-model="baTable.form.items!.connection"
type="remoteSelect"
:block-help="t('Database connection help')"
:input-attr="{
pk: 'key',
field: 'key',
remoteUrl: getDatabaseConnectionListUrl,
onChange: baTable.onConnectionChange,
valueOnClear: '',
}"
/>
<FormItem
:label="t('security.sensitiveData.Corresponding data sheet')"
type="remoteSelect"
v-model="baTable.form.items!.data_table"
:key="baTable.form.items!.connection"
:input-attr="{
pk: 'table',
field: 'comment',
params: {
connection: baTable.form.items!.connection,
samePrefix: 1,
excludeTable: ['area', 'token', 'captcha', 'admin_group_access', 'admin_log', 'user_money_log', 'user_score_log'],
},
remoteUrl: getTableListUrl,
onChange: baTable.onTableChange,
}"
prop="data_table"
/>
<FormItem
:label="t('security.sensitiveData.Data table primary key')"
type="string"
v-model="baTable.form.items!.primary_key"
prop="primary_key"
/>
<template v-if="!isEmpty(baTable.form.extend!.fieldSelect)">
<hr class="form-hr" />
<FormItem
:label="t('security.sensitiveData.Sensitive fields')"
type="selects"
v-model="baTable.form.items!.data_fields"
:key="baTable.form.extend!.fieldSelectKey"
prop="data_fields"
:input-attr="{
onChange: onFieldChange,
content: baTable.form.extend!.fieldSelect,
}"
v-loading="baTable.form.extend!.fieldLoading"
/>
<FormItem
v-for="(item, idx) in state.dataFields"
:key="idx"
:label="item.name"
type="string"
v-model="item.value"
:tip="t('security.sensitiveData.Filling in field notes helps you quickly identify fields later')"
/>
<hr class="form-hr" />
</template>
<FormItem
:label="t('State')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { sensitiveDataClass, DataFields } from './index'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
import { getTableListUrl, getDatabaseConnectionListUrl } from '/@/api/common'
import { isEmpty } from 'lodash-es'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as sensitiveDataClass
const { t } = useI18n()
const state: {
dataFields: DataFields[]
} = reactive({
dataFields: [],
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('security.sensitiveData.Rule name') })],
controller: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.sensitiveData.controller') }),
}),
],
data_table: [
buildValidatorData({
name: 'required',
trigger: 'change',
message: t('Please select field', { field: t('security.sensitiveData.data sheet') }),
}),
],
primary_key: [
buildValidatorData({
name: 'required',
trigger: 'change',
title: t('security.sensitiveData.Data table primary key'),
}),
],
data_fields: [
buildValidatorData({
name: 'required',
message: t('Please select field', { field: t('security.sensitiveData.Sensitive fields') }),
}),
],
})
/**
* 敏感数据字段更新
* 保留原始输入,而又需要去掉已删除的字段
*/
const onFieldChange = (val: string[]) => {
let dataFields: DataFields[] = []
for (const key in val) {
let exist: boolean | DataFields = false
for (const k in state.dataFields) {
if (state.dataFields[k].name == val[key]) {
exist = state.dataFields[k]
}
}
dataFields[key] = exist ? exist : { name: val[key], value: baTable.form.extend!.fieldList[val[key]] ?? '' }
}
state.dataFields = dataFields
}
const getDataFields = () => {
return state.dataFields
}
const setDataFields = (dataFields: DataFields[]) => {
state.dataFields = dataFields
}
defineExpose({
getDataFields,
setDataFields,
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.form-hr {
border-color: #dcdfe6;
border-style: solid;
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('security.sensitiveDataLog.Rule name') })"
>
<el-popconfirm
@confirm="onRollbackAction"
:confirm-button-text="t('security.sensitiveDataLog.RollBACK')"
:cancel-button-text="t('Cancel')"
confirmButtonType="success"
:title="t('security.sensitiveDataLog.Are you sure you want to rollback the record?')"
:disabled="baTable.table.selection!.length > 0 ? false : true"
>
<template #reference>
<div class="mlr-12">
<el-tooltip :content="t('security.sensitiveDataLog.Rollback the selected record to the original data table')" placement="top">
<el-button
v-blur
:disabled="baTable.table.selection!.length > 0 ? false : true"
class="table-header-operate"
type="success"
>
<Icon size="16" color="#ffffff" name="fa fa-sign-in" />
<span class="table-header-operate-text">{{ t('security.sensitiveDataLog.RollBACK') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<InfoDialog />
</div>
</template>
<script setup lang="ts">
import { provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { info, rollback, url } from '/@/api/backend/security/sensitiveDataLog'
import InfoDialog from './info.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'security/sensitiveDataLog',
})
const { t } = useI18n()
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'info',
title: 'Info',
text: '',
type: 'primary',
icon: 'fa fa-search-plus',
class: 'table-row-info',
disabledTip: false,
click: (row: TableRow) => {
infoButtonClick(row[baTable.table.pk!])
},
},
{
render: 'confirmButton',
name: 'rollback',
title: 'security.sensitiveDataLog.RollBACK',
text: '',
type: 'success',
icon: 'fa fa-sign-in',
class: 'table-row-edit',
popconfirm: {
confirmButtonText: t('security.sensitiveDataLog.RollBACK'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'success',
title: t('security.sensitiveDataLog.Are you sure you want to rollback the record?'),
},
disabledTip: false,
click: (row: TableRow) => {
onRollback([row[baTable.table.pk!]])
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['delete']))
const baTable = new baTableClass(new baTableApi(url), {
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{
label: t('security.sensitiveDataLog.Operation administrator'),
prop: 'admin.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Rule name'),
prop: 'sensitive.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.controller'),
prop: 'sensitive.controller_as',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('Connection'),
prop: 'connection',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.data sheet'),
prop: 'data_table',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Modify line'),
prop: 'id_value',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Modification'),
prop: 'data_comment',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('security.sensitiveDataLog.Before modification'),
prop: 'before',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('security.sensitiveDataLog.After modification'),
prop: 'after',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: 'IP', prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('security.sensitiveDataLog.Modification time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{
label: t('Operate'),
align: 'center',
width: 120,
render: 'buttons',
buttons: optButtons,
operator: false,
},
],
dblClickNotEditColumn: [undefined],
})
// 利用双击单元格前钩子重写双击操作
baTable.before.onTableDblclick = ({ row }) => {
infoButtonClick(row[baTable.table.pk!])
return false
}
const onRollback = (ids: string[]) => {
rollback(ids).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
}
const onRollbackAction = () => {
onRollback(baTable.getSelectionIds())
}
const infoButtonClick = (id: string) => {
baTable.form.extend!['info'] = {}
baTable.form.operate = 'Info'
baTable.form.loading = true
info(id).then((res) => {
baTable.form.extend!['info'] = res.data.row
baTable.form.loading = false
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss">
.table-header-operate {
margin-left: 12px;
}
.table-header-operate-text {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<el-dialog class="ba-operate-dialog" :model-value="baTable.form.operate ? true : false" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ t('Info') }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'">
<el-descriptions v-if="!isEmpty(baTable.form.extend!.info)" :column="2" border>
<el-descriptions-item :width="120" :span="2" :label="t('security.sensitiveDataLog.Rule name')">
{{ baTable.form.extend!.info.sensitive?.name }}
</el-descriptions-item>
<el-descriptions-item :label="t('Id')">
{{ baTable.form.extend!.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Operation administrator')">
{{ baTable.form.extend!.info.admin?.nickname + '(' + baTable.form.extend!.info.admin?.username + ')' }}
</el-descriptions-item>
<el-descriptions-item :label="t('Connection')">
{{ baTable.form.extend!.info.connection }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.data sheet')">
{{ baTable.form.extend!.info.data_table }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Modification time')">
{{ timeFormat(baTable.form.extend!.info.create_time) }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Operator IP')">
{{ baTable.form.extend!.info.ip }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Data table primary key')">
{{ baTable.form.extend!.info.primary_key + '=' + baTable.form.extend!.info.id_value }}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Modified item')">
{{
baTable.form.extend!.info.data_field +
(baTable.form.extend!.info.data_comment ? '(' + baTable.form.extend!.info.data_comment + ')' : '')
}}
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.Before modification')" label-class-name="color-red">
<div class="info-content">{{ baTable.form.extend!.info.before }}</div>
</el-descriptions-item>
<el-descriptions-item :label="t('security.sensitiveDataLog.After modification')" label-class-name="color-red">
<div class="info-content">{{ baTable.form.extend!.info.after }}</div>
</el-descriptions-item>
<el-descriptions-item :width="120" :span="2" label="User Agent">
{{ baTable.form.extend!.info.useragent }}
</el-descriptions-item>
</el-descriptions>
<div class="diff-box">
<div class="diff-box-title">{{ t('security.sensitiveDataLog.Modification comparison') }}</div>
<code-diff
diffStyle="char"
:old-string="baTable.form.extend!.info.before ?? ''"
:new-string="baTable.form.extend!.info.after ?? ''"
/>
</div>
</div>
</el-scrollbar>
<template #footer>
<el-button v-blur @click="onRollback(baTable.form.extend!.info.id)" type="success">
<Icon size="16" color="#ffffff" name="fa fa-sign-in" />
<span class="table-header-operate-text">{{ t('security.sensitiveDataLog.RollBACK') }}</span>
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type BaTable from '/@/utils/baTable'
import { timeFormat } from '/@/utils/common'
import { isEmpty } from 'lodash-es'
import { ElMessageBox } from 'element-plus'
import { rollback } from '/@/api/backend/security/sensitiveDataLog'
import { CodeDiff } from 'v-code-diff'
const baTable = inject('baTable') as BaTable
const { t } = useI18n()
const onRollback = (id: string) => {
ElMessageBox.confirm(t('security.sensitiveDataLog.Are you sure you want to rollback the record?'), '', {
confirmButtonText: t('security.sensitiveDataLog.RollBACK'),
cancelButtonText: t('Cancel'),
})
.then(() => {
rollback([id]).then(() => {
baTable.toggleForm()
baTable.onTableHeaderAction('refresh', {})
})
})
.catch(() => {})
}
</script>
<style scoped lang="scss">
:deep(.color-red) {
color: var(--el-color-danger) !important;
}
.table-el-tree {
:deep(.el-tree-node) {
white-space: unset;
}
:deep(.el-tree-node__content) {
display: block;
align-items: unset;
height: unset;
}
}
.info-content {
word-wrap: break-word;
word-break: break-all;
}
.table-header-operate-text {
margin-left: 6px;
}
.diff-box :deep(.d2h-file-wrapper) {
border: 1px solid #ebeef5;
}
.diff-box-title {
display: flex;
font-weight: bold;
line-height: 40px;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getUserRules } from '/@/api/backend/user/group'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'user/group',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Group/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.group.Group name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 打开表单后
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 获取到编辑数据后
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getUserRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,145 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="10vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<el-form-item prop="name" :label="t('user.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('user.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('user.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import type { ElTree, FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('user.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('user.group.jurisdiction') })))
}
return callback()
},
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.moneyLog.balance') + ':' + state.userInfo.money }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/moneyLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/moneyLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.moneyLog.Change balance'), prop: 'money', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.before.onTableAction = ({ event }) => {
// 公共搜索
if (event === 'com-search') {
baTable.table.filter!.search = baTable.getComSearchData()
for (const key in baTable.table.filter!.search) {
if (['money', 'before', 'after'].includes(baTable.table.filter!.search[key].field)) {
const val = (baTable.table.filter!.search[key].val as string).split(',')
const newVal: (string | number)[] = []
for (const k in val) {
newVal.push(isNaN(parseFloat(val[k])) ? '' : parseFloat(val[k]) * 100)
}
baTable.table.filter!.search[key].val = newVal.join(',')
}
}
baTable.onTableHeaderAction('refresh', { event: 'com-search', data: baTable.table.filter!.search })
return false
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,160 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Current balance')">
<el-input v-model="state.userInfo.money" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="money" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeMoney"
v-model="baTable.form.items!.money"
type="number"
:placeholder="t('user.moneyLog.Please enter the balance change amount')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Balance after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.moneyLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/moneyLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
money: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseFloat(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.money
})
}
const changeMoney = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat((parseFloat(state.userInfo.money) + newValue).toFixed(2))
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.Rule title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/rule',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Rule/'),
{
expandAll: false,
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', route: 'info' },
replaceValue: {
menu: t('user.rule.Member center menu items'),
menu_dir: t('user.rule.Member center menu contents'),
route: t('user.rule.Normal routing'),
nav: t('user.rule.Top bar menu items'),
button: t('user.rule.Page button'),
nav_user_menu: t('user.rule.Top bar user dropdown'),
},
},
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons() },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
type: 'route',
menu_type: 'tab',
extend: 'none',
no_login_valid: '0',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
},
}
)
// 表单提交前
baTable.before.onSubmit = () => {
if (baTable.form.items!.type == 'route') {
baTable.form.items!.menu_type = 'tab'
} else if (['menu', 'menu_dir', 'nav_user_menu'].includes(baTable.form.items!.type)) {
baTable.form.items!.no_login_valid = '0'
}
}
// 取得编辑行的数据后
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,237 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="5vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item :label="t('auth.rule.Rule type')">
<el-radio-group v-model="baTable.form.items!.type">
<el-radio class="ba-el-radio" value="route" :border="true">{{ t('user.rule.Normal routing') }}</el-radio>
<el-radio class="ba-el-radio" value="menu_dir" :border="true">{{ t('user.rule.Member center menu contents') }}</el-radio>
<el-radio class="ba-el-radio" value="menu" :border="true">{{ t('user.rule.Member center menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="nav" :border="true">{{ t('user.rule.Top bar menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="button" :border="true">{{ t('user.rule.Page button') }}</el-radio>
<el-radio class="ba-el-radio" value="nav_user_menu" :border="true">{{ t('user.rule.Top bar user dropdown') }}</el-radio>
</el-radio-group>
<div class="block-help">{{ t('user.rule.Type ' + baTable.form.items!.type + ' tips') }}</div>
</el-form-item>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input v-model="baTable.form.items!.name" type="string" :placeholder="t('user.rule.English name')"></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item v-if="baTable.form.items!.type != 'button'" prop="path" :label="t('auth.rule.Routing path')">
<el-input v-model="baTable.form.items!.path" type="string" :placeholder="t('user.rule.Web side routing path')"></el-input>
</el-form-item>
<!-- 规则图标 -->
<FormItem
v-if="baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{ showIconName: true }"
/>
<!-- 菜单类型tablinkiframe -->
<FormItem
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type)"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<!-- URL -->
<el-form-item
prop="url"
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type != 'tab'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<!-- 组件路径 -->
<el-form-item
v-if="
baTable.form.items!.type == 'route' ||
(!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab')
"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('user.rule.For example, if you add account/overview as a route only')"
></el-input>
<div class="block-help component-path-tips">
{{ t('user.rule.Component path tips') }}
</div>
</el-form-item>
<!-- 扩展属性 -->
<el-form-item
v-if="!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">
{{ t('user.rule.Web side component path, please start with /src, such as: /src/views/frontend/index') }}
</div>
</el-form-item>
<FormItem
v-if="!['menu_dir', 'menu', 'nav_user_menu'].includes(baTable.form.items!.type)"
:label="t('user.rule.no_login_valid')"
v-model="baTable.form.items!.no_login_valid"
type="radio"
:input-attr="{
border: true,
content: { '0': t('user.rule.no_login_valid 0'), '1': t('user.rule.no_login_valid 1') },
}"
:block-help="t('user.rule.no_login_valid tips')"
/>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('Please input field', { field: t('auth.rule.Rule comments') })"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', message: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.component-path-tips {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.scoreLog.integral') + ':' + state.userInfo.score }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/scoreLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/scoreLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.scoreLog.Change points'), prop: 'score', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,160 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Current points')">
<el-input v-model="state.userInfo.score" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="score" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeScore"
v-model="baTable.form.items!.score"
type="number"
:placeholder="t('user.scoreLog.Please enter the change amount of points')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Points after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.scoreLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/scoreLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
score: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseInt(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.score
})
}
const changeScore = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat(state.userInfo.score) + newValue
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.User name') + '/' + t('user.user.nickname') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/user',
})
const { t } = useI18n()
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.user.User name'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('user.user.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.group'),
prop: 'userGroup.name',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{ label: t('user.user.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
},
{ label: t('user.user.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.Last login IP'),
prop: 'last_login_ip',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{
label: t('user.user.Last login'),
prop: 'last_login_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
},
{
label: t('Operate'),
align: 'center',
width: '100',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
gender: 0,
money: '0',
score: '0',
status: 'enable',
},
}
)
baTable.mount()
baTable.getData()
provide('baTable', baTable)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,238 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<el-form-item prop="username" :label="t('user.user.User name')">
<el-input
v-model="baTable.form.items!.username"
type="string"
:placeholder="t('Please input field', { field: t('user.user.User name') + '(' + t('user.user.Login account') + ')' })"
></el-input>
</el-form-item>
<el-form-item prop="nickname" :label="t('user.user.nickname')">
<el-input
v-model="baTable.form.items!.nickname"
type="string"
:placeholder="t('Please input field', { field: t('user.user.nickname') })"
></el-input>
</el-form-item>
<FormItem
type="remoteSelect"
:label="t('user.user.group')"
v-model="baTable.form.items!.group_id"
prop="group_id"
:placeholder="t('user.user.group')"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
/>
<FormItem :label="t('user.user.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<el-form-item prop="email" :label="t('user.user.email')">
<el-input
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('user.user.email') })"
></el-input>
</el-form-item>
<el-form-item prop="mobile" :label="t('user.user.mobile')">
<el-input
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('user.user.mobile') })"
></el-input>
</el-form-item>
<FormItem
:label="t('user.user.Gender')"
v-model="baTable.form.items!.gender"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
/>
<el-form-item :label="t('user.user.birthday')">
<el-date-picker
class="w100"
value-format="YYYY-MM-DD"
v-model="baTable.form.items!.birthday"
type="date"
:placeholder="t('Please select field', { field: t('user.user.birthday') })"
/>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.balance')">
<el-input v-model="baTable.form.items!.money" readonly>
<template #append>
<el-button @click="changeAccount('money')">{{ t('user.user.Adjustment balance') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.integral')">
<el-input v-model="baTable.form.items!.score" readonly>
<template #append>
<el-button @click="changeAccount('score')">{{ t('user.user.Adjust integral') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password" :label="t('user.user.password')">
<el-input
v-model="baTable.form.items!.password"
type="password"
autocomplete="new-password"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('user.user.password') })
: t('user.user.Please leave blank if not modified')
"
></el-input>
</el-form-item>
<el-form-item prop="motto" :label="t('user.user.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('user.user.Personal signature') })"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import router from '/@/router/index'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.User name') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('user.user.nickname') })],
group_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.user.group') }) })],
email: [buildValidatorData({ name: 'email', title: t('user.user.email') })],
mobile: [buildValidatorData({ name: 'mobile' })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('user.user.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
})
const changeAccount = (type: string) => {
baTable.toggleForm()
router.push({
name: type == 'money' ? 'user/moneyLog' : 'user/scoreLog',
query: {
user_id: baTable.form.items!.id,
},
})
}
watch(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
}
)
</script>
<style scoped lang="scss">
.avatar-uploader {
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="page">
<div class="container">
<div class="fbi">401 WARNING</div>
<div class="warning">
{{ $t('401.noPowerTip') }}
</div>
<div class="page-footer">
<el-button-group>
<el-button size="large" type="info">
<router-link class="stopcode-a" to="/">{{ $t('404.Return to home page') }}</router-link>
</el-button>
<el-button size="large" type="info">
<router-link class="stopcode-a" to="">
<span @click="$router.back()">{{ $t('404.Back to previous page') }}</span>
</router-link>
</el-button>
</el-button-group>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.page {
height: 100vh;
width: 100vw;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.container {
color: var(--ba-bg-color-overlay);
width: 60vw;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.fbi {
display: inline-block;
font-size: 80px;
font-weight: bold;
text-align: center;
background: #aa0000;
margin: 20px auto;
padding: 0 30px;
}
.warning {
font-size: 24px;
width: 100%;
}
.warning:first-letter {
font-size: 40px;
}
.page-footer {
padding-top: 60px;
}
.stopcode-a {
color: var(--ba-bg-color-overlay);
text-decoration: none;
}
@media screen and (max-width: 768px) {
.container {
width: 90vw;
}
.fbi {
font-size: 50px;
padding: 10px 30px;
}
.warning {
font-size: 16px;
}
.warning:first-letter {
font-size: 30px;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="page">
<div class="container">
<div class="font-h1">:(</div>
<div class="tip">{{ $t('404.problems tip') }}</div>
<div class="complete">
{{ $t('Complete') }} <span class="percentage">{{ complete }}</span
>%
</div>
<div class="details">
<div class="qr-image">
<img src="~/assets/qr.png" alt="QR Code" />
</div>
<div class="stopcode">
<div class="stopcode-text">{{ $t('404.We will automatically return to the previous page when we are finished') }}</div>
<div class="stopcode-text">
<router-link class="stopcode-a" to="">
<span @click="$router.back()">{{ $t('404.Back to previous page') }}</span>
</router-link>
<router-link class="stopcode-a" to="/">{{ $t('404.Return to home page') }}</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const complete = ref(0)
var timer: any = null
function process() {
complete.value += Math.floor(Math.random() * 50)
if (complete.value >= 100) {
complete.value = 100
router.back()
} else {
processInterval()
}
}
function processInterval() {
timer = setTimeout(process, Math.random() * (1000 - 500) + 500)
}
onMounted(() => {
processInterval()
})
onBeforeUnmount(() => {
clearTimeout(timer)
})
</script>
<style scoped lang="scss">
.page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: #0078d7;
color: var(--ba-bg-color-overlay);
.container {
width: 50vw;
.font-h1 {
font-size: 120px;
}
.tip {
font-size: 30px;
padding-top: 20px;
}
.complete {
font-size: 30px;
padding: 30px 0;
}
.details {
display: flex;
align-items: center;
.qr-image img {
height: 80px;
width: 80px;
}
.stopcode {
padding-left: 10px;
.stopcode-text {
display: block;
padding: 4px 0;
font-size: 16px;
}
}
}
}
}
.stopcode-a {
font-size: 16px;
color: var(--ba-bg-color-overlay);
padding-right: 16px;
}
@media screen and (max-width: 720px) {
.container {
width: 90vw !important;
}
.tip {
font-size: 20px !important;
padding-top: 20px;
}
.complete {
font-size: 20px !important;
padding: 30px 0;
}
.stopcode-text {
font-size: 15px !important;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div>
<Header />
<el-container class="container">
<el-main class="main">
<div class="main-container">
<div class="main-left">
<div class="main-title">{{ siteConfig.siteName }}</div>
<div class="main-content">
{{ $t('index.Steve Jobs') }}
</div>
<el-button
v-if="memberCenter.state.open"
@click="$router.push(memberCenterBaseRoutePath)"
class="container-button"
color="#ffffff"
size="large"
>
{{ $t('Member Center') }}
</el-button>
</div>
<div class="main-right">
<img :src="indexCover" alt="" />
</div>
</div>
</el-main>
</el-container>
<Footer />
</div>
</template>
<script setup lang="ts">
import indexCover from '/@/assets/index/index-cover.svg'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import Header from '/@/layouts/frontend/components/header.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase'
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
</script>
<style scoped lang="scss">
.container-button {
margin: 0 15px 15px 0;
}
.container {
width: 100vw;
height: 100vh;
background: url(/@/assets/bg.jpg) repeat;
color: var(--el-color-white);
.main {
height: calc(100vh - 120px);
padding: 0;
.main-container {
display: flex;
height: 100%;
width: 66%;
margin: 0 auto;
align-items: center;
justify-content: space-between;
.main-left {
padding-right: 50px;
.main-title {
font-size: 45px;
}
.main-content {
padding-top: 20px;
padding-bottom: 40px;
font-size: var(--el-font-size-large);
}
}
.main-right {
img {
width: 380px;
}
}
}
}
}
.header {
background-color: transparent !important;
box-shadow: none !important;
position: fixed;
width: 100%;
:deep(.header-logo) {
span {
padding-left: 4px;
color: var(--el-color-white);
}
}
:deep(.frontend-header-menu) {
background: transparent;
.el-menu-item,
.el-sub-menu .el-sub-menu__title {
color: var(--el-color-white);
&.is-active {
color: var(--el-color-white) !important;
}
&:hover {
background-color: transparent !important;
color: var(--el-menu-hover-text-color);
}
}
}
}
.footer {
color: var(--el-text-color-secondary);
background-color: transparent !important;
position: fixed;
bottom: 0;
}
@media screen and (max-width: 1024px) {
.container {
.main {
height: unset;
}
}
.main-container {
width: 90% !important;
flex-wrap: wrap;
align-content: center;
justify-content: center !important;
.main-right {
padding-top: 50px;
}
}
}
@media screen and (max-width: 375px) {
.main-right img {
width: 300px !important;
}
}
@media screen and (max-height: 650px) {
.main-right img {
display: none;
}
}
@at-root html.dark {
.container {
background: url(/@/assets/bg-dark.jpg) repeat;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover">
<template #header>
<div class="card-header">
<span>{{ $t('user.account.balance.Balance change record') }}</span>
<span class="right-title">{{ $t('user.account.balance.Current balance') + ' ' + userInfo.money }}</span>
</div>
</template>
<div v-loading="state.pageLoading" class="logs">
<div class="log-item" v-for="(item, idx) in state.logs" :key="idx">
<div class="log-title">{{ item.memo }}</div>
<div v-if="item.money > 0" class="log-change-amount increase">{{ $t('Balance') + '+' + item.money }}</div>
<div v-else class="log-change-amount reduce">{{ $t('Balance') + '' + item.money }}</div>
<div class="log-after">{{ $t('user.account.balance.Balance after change') + '' + item.after }}</div>
<div class="log-change-time">{{ $t('user.account.balance.Change time') + '' + timeFormat(item.create_time) }}</div>
</div>
</div>
<div v-if="state.total > 0" class="log-footer">
<el-pagination
:currentPage="state.currentPage"
:page-size="state.pageSize"
:page-sizes="[10, 20, 50, 100]"
background
:layout="memberCenter.state.shrink ? 'prev, next, jumper' : 'sizes, ->, prev, pager, next, jumper'"
:total="state.total"
@size-change="onTableSizeChange"
@current-change="onTableCurrentChange"
></el-pagination>
</div>
<el-empty v-else />
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { getBalanceLog } from '/@/api/frontend/user/index'
import { useMemberCenter } from '/@/stores/memberCenter'
import { timeFormat } from '/@/utils/common'
import { useUserInfo } from '/@/stores/userInfo'
const userInfo = useUserInfo()
const memberCenter = useMemberCenter()
const state: {
logs: {
memo: string
create_time: number
money: number
after: number
}[]
currentPage: number
total: number
pageSize: number
pageLoading: boolean
} = reactive({
logs: [],
currentPage: 1,
total: 0,
pageSize: 10,
pageLoading: true,
})
const onTableSizeChange = (val: number) => {
state.pageSize = val
loadData()
}
const onTableCurrentChange = (val: number) => {
state.currentPage = val
loadData()
}
const loadData = () => {
getBalanceLog(state.currentPage, state.pageSize).then((res) => {
state.pageLoading = false
state.logs = res.data.list
state.total = res.data.total
})
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-views-card :deep(.el-card__body) {
padding-top: 0;
}
.right-title {
color: var(--el-text-color-secondary);
}
.log-item {
border-bottom: 1px solid var(--ba-bg-color);
padding: 15px 0;
div {
padding: 4px 0;
}
}
.log-title {
font-size: var(--el-font-size-medium);
}
.log-change-amount.increase {
color: var(--el-color-success);
}
.log-change-amount.reduce {
color: var(--el-color-danger);
}
.log-after,
.log-change-time {
font-size: var(--el-font-size-small);
color: var(--el-text-color-secondary);
}
.log-footer {
padding-top: 20px;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover" :header="t('user.account.changePassword.Change Password')">
<div class="change-password">
<el-form :model="state.form" :rules="state.rules" label-position="top" ref="formRef" @keyup.enter="onSubmit()">
<FormItem
:label="t('user.account.changePassword.Old password')"
type="password"
v-model="state.form.oldPassword"
prop="oldPassword"
:input-attr="{ showPassword: true }"
:placeholder="t('user.account.changePassword.Please enter your current password')"
/>
<FormItem
:label="t('user.account.changePassword.New password')"
type="password"
v-model="state.form.newPassword"
prop="newPassword"
:input-attr="{ showPassword: true }"
:placeholder="t('Please input field', { field: t('user.account.changePassword.New password') })"
/>
<FormItem
:label="t('user.account.changePassword.Confirm new password')"
type="password"
v-model="state.form.confirmPassword"
prop="confirmPassword"
:input-attr="{
showPassword: true,
}"
:placeholder="t('Please input field', { field: t('user.account.changePassword.Confirm new password') })"
/>
<el-form-item class="submit-buttons">
<el-button @click="onResetForm(formRef)">{{ $t('Reset') }}</el-button>
<el-button type="primary" :loading="state.formSubmitLoading" @click="onSubmit()">{{ $t('Save') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { onResetForm } from '/@/utils/common'
import { buildValidatorData } from '/@/utils/validate'
import { changePassword } from '/@/api/frontend/user/index'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useUserInfo } from '/@/stores/userInfo'
const { t } = useI18n()
const userInfo = useUserInfo()
const formRef = useTemplateRef('formRef')
const state = reactive({
formSubmitLoading: false,
form: {
oldPassword: '',
newPassword: '',
confirmPassword: '',
},
rules: {
oldPassword: [buildValidatorData({ name: 'required', title: t('user.account.changePassword.Old password') })],
newPassword: [
buildValidatorData({ name: 'required', title: t('user.account.changePassword.New password') }),
buildValidatorData({ name: 'password' }),
],
confirmPassword: [
buildValidatorData({ name: 'required', title: t('user.account.changePassword.Confirm new password') }),
buildValidatorData({ name: 'password' }),
{
validator: (rule: any, val: string, callback: Function) => {
if (state.form.newPassword || state.form.confirmPassword) {
if (state.form.newPassword == state.form.confirmPassword) {
callback()
} else {
callback(new Error(t('user.account.changePassword.The duplicate password does not match the new password')))
}
}
callback()
},
trigger: 'blur',
},
],
},
})
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
state.formSubmitLoading = true
changePassword(state.form)
.then((res) => {
state.formSubmitLoading = false
if (res.code == 1) {
userInfo.logout()
}
})
.catch(() => {
state.formSubmitLoading = false
})
}
})
}
</script>
<style scoped lang="scss">
.change-password {
width: 360px;
max-width: 100%;
}
.submit-buttons :deep(.el-form-item__content) {
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover">
<template #header>
<div class="card-header">
<span>{{ $t('user.account.integral.Score change record') }}</span>
<span class="right-title">{{ $t('user.account.integral.Current points') + ' ' + userInfo.score }}</span>
</div>
</template>
<div v-loading="state.pageLoading" class="logs">
<div class="log-item" v-for="(item, idx) in state.logs" :key="idx">
<div class="log-title">{{ item.memo }}</div>
<div v-if="item.score > 0" class="log-change-amount increase">
{{ $t('Integral') + '+' + item.score }}
</div>
<div v-else class="log-change-amount reduce">{{ $t('Integral') + '' + item.score }}</div>
<div class="log-after">{{ $t('user.account.integral.Points after change') + '' + item.after }}</div>
<div class="log-change-time">{{ $t('user.account.integral.Change time') + '' + timeFormat(item.create_time) }}</div>
</div>
</div>
<div v-if="state.total > 0" class="log-footer">
<el-pagination
:currentPage="state.currentPage"
:page-size="state.pageSize"
:page-sizes="[10, 20, 50, 100]"
background
:layout="memberCenter.state.shrink ? 'prev, next, jumper' : 'sizes, ->, prev, pager, next, jumper'"
:total="state.total"
@size-change="onTableSizeChange"
@current-change="onTableCurrentChange"
></el-pagination>
</div>
<el-empty v-else />
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { getIntegralLog } from '/@/api/frontend/user/index'
import { useMemberCenter } from '/@/stores/memberCenter'
import { timeFormat } from '/@/utils/common'
import { useUserInfo } from '/@/stores/userInfo'
const userInfo = useUserInfo()
const memberCenter = useMemberCenter()
const state: {
logs: {
memo: string
create_time: number
score: number
after: number
}[]
currentPage: number
total: number
pageSize: number
pageLoading: boolean
} = reactive({
logs: [],
currentPage: 1,
total: 0,
pageSize: 10,
pageLoading: true,
})
const onTableSizeChange = (val: number) => {
state.pageSize = val
loadData()
}
const onTableCurrentChange = (val: number) => {
state.currentPage = val
loadData()
}
const loadData = () => {
getIntegralLog(state.currentPage, state.pageSize).then((res) => {
state.pageLoading = false
state.logs = res.data.list
state.total = res.data.total
})
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-views-card :deep(.el-card__body) {
padding-top: 0;
}
.right-title {
color: var(--el-text-color-secondary);
}
.log-item {
border-bottom: 1px solid var(--ba-bg-color);
padding: 15px 0;
div {
padding: 4px 0;
}
}
.log-title {
font-size: var(--el-font-size-medium);
}
.log-change-amount.increase {
color: var(--el-color-success);
}
.log-change-amount.reduce {
color: var(--el-color-danger);
}
.log-after,
.log-change-time {
font-size: var(--el-font-size-small);
color: var(--el-text-color-secondary);
}
.log-footer {
padding-top: 20px;
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover">
<template #header>
<div class="card-header">
<span>{{ $t('user.account.overview.Account information') }}</span>
<el-button @click="router.push({ name: 'account/profile' })" type="info" v-blur plain>
{{ $t('user.account.overview.profile') }}
</el-button>
</div>
</template>
<div class="overview-userinfo">
<div class="user-avatar">
<img :src="fullUrl(userInfo.avatar)" alt="" />
<div class="user-avatar-icons">
<div @click="router.push({ name: 'account/profile' })" class="avatar-icon-item">
<el-tooltip
effect="light"
placement="right"
:content="
(userInfo.mobile ? $t('user.account.overview.Filled in') : $t('user.account.overview.Not filled in')) +
$t('user.account.overview.mobile')
"
>
<Icon
name="fa fa-tablet"
size="16"
:color="userInfo.mobile ? 'var(--el-color-primary)' : 'var(--el-text-color-secondary)'"
/>
</el-tooltip>
</div>
<div @click="router.push({ name: 'account/profile' })" class="avatar-icon-item">
<el-tooltip
effect="light"
placement="right"
:content="
(userInfo.email ? $t('user.account.overview.Filled in') : $t('user.account.overview.Not filled in')) +
$t('user.account.overview.email')
"
>
<Icon
name="fa fa-envelope-square"
size="14"
:color="userInfo.email ? 'var(--el-color-primary)' : 'var(--el-text-color-secondary)'"
/>
</el-tooltip>
</div>
</div>
</div>
<div class="user-data">
<div class="welcome-words">{{ userInfo.nickname + $t('utils.comma') + getGreet() }}</div>
<el-row class="data-item">
<el-col :span="4">{{ $t('Integral') }}</el-col>
<el-col :span="8">
<el-link @click="router.push({ name: 'account/integral' })" type="primary">{{ userInfo.score }}</el-link>
</el-col>
<el-col :span="4">{{ $t('Balance') }}</el-col>
<el-col :span="8">
<el-link @click="router.push({ name: 'account/balance' })" type="primary">{{ userInfo.money }}</el-link>
</el-col>
</el-row>
<el-row class="data-item">
<el-col class="lastlogin title" :span="4">{{ $t('user.account.overview.Last login') }}</el-col>
<el-col class="lastlogin value" :span="8">{{ timeFormat(userInfo.last_login_time) }}</el-col>
<el-col class="lastip" :span="4">{{ $t('user.account.overview.Last login IP') }}</el-col>
<el-col class="lastip" :span="8">{{ userInfo.last_login_ip }}</el-col>
</el-row>
</div>
</div>
</el-card>
<el-card class="user-views-card" shadow="hover" :header="$t('user.account.overview.Growth statistics')">
<div class="account-growth" ref="accountGrowthChartRef"></div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import * as echarts from 'echarts'
import { nextTick, onActivated, onBeforeMount, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { overview } from '/@/api/frontend/user/index'
import { useUserInfo } from '/@/stores/userInfo'
import { fullUrl, getGreet, timeFormat } from '/@/utils/common'
const { t } = useI18n()
const router = useRouter()
const userInfo = useUserInfo()
const accountGrowthChartRef = useTemplateRef('accountGrowthChartRef')
const state: {
days: string[]
score: number[]
money: number[]
charts: any[]
} = reactive({
days: [],
score: [],
money: [],
charts: [],
})
const initUserGrowthChart = () => {
const userGrowthChart = echarts.init(accountGrowthChartRef.value!)
const option = {
grid: {
top: 40,
right: 0,
bottom: 20,
left: 50,
},
xAxis: {
data: state.days,
},
yAxis: {},
legend: {
data: [t('Integral'), t('Balance')],
top: 0,
},
series: [
{
name: t('Integral'),
data: state.score,
type: 'line',
smooth: true,
show: false,
color: '#f56c6c',
emphasis: {
label: {
show: true,
},
},
areaStyle: {},
},
{
name: t('Balance'),
data: state.money,
type: 'line',
smooth: true,
show: false,
color: '#409eff',
emphasis: {
label: {
show: true,
},
},
areaStyle: {
opacity: 0.4,
},
},
],
}
userGrowthChart.setOption(option)
state.charts.push(userGrowthChart)
}
const echartsResize = () => {
nextTick(() => {
for (const key in state.charts) {
state.charts[key].resize()
}
})
}
onActivated(() => {
echartsResize()
})
onMounted(() => {
overview().then((res) => {
state.days = res.data.days
state.score = res.data.score
state.money = res.data.money
initUserGrowthChart()
})
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
})
</script>
<style scoped lang="scss">
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.overview-userinfo {
display: flex;
width: 100%;
background-color: var(--ba-bg-color-overlay);
overflow: hidden;
.user-avatar {
width: 100px;
padding: 0 20px;
margin: 20px 0;
border-right: 1px solid var(--el-border-color-light);
img {
width: 60px;
height: 60px;
border-radius: 50%;
}
}
.user-avatar-icons {
display: flex;
align-items: center;
justify-content: center;
padding-top: 4px;
}
.avatar-icon-item {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border: 1px solid var(--el-border-color-light);
border-radius: 50%;
margin: 3px;
cursor: pointer;
&:hover {
border: 1px solid var(--el-color-primary);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
}
.user-data {
padding: 0 20px;
margin: 20px 0;
width: calc(100% - 100px);
}
.welcome-words {
color: var(--el-text-color-primary);
font-size: var(--el-font-size-medium);
padding: 20px 0;
}
.data-item {
display: flex;
align-items: center;
font-size: var(--el-font-size-base);
padding: 3px 0;
}
}
.account-growth {
width: 100%;
height: 300px;
}
@media screen and (max-width: 992px) {
.user-data {
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
.overview-userinfo .welcome-words {
padding-top: 0;
}
.user-avatar {
display: none;
}
}
@media screen and (max-width: 1280px) and (min-width: 992px) {
.lastip {
display: none;
}
.lastlogin.title {
width: 42%;
max-width: 42%;
flex: 0 0 42%;
}
.lastlogin.value {
width: 58%;
max-width: 58%;
flex: 0 0 58%;
}
}
@media screen and (max-width: 460px) {
.lastip {
display: none;
}
.lastlogin.title {
width: 42%;
max-width: 42%;
flex: 0 0 42%;
}
.lastlogin.value {
width: 58%;
max-width: 58%;
flex: 0 0 58%;
}
}
</style>

View File

@@ -0,0 +1,533 @@
<template>
<div class="user-views">
<el-card class="user-views-card" shadow="hover">
<template #header>
<div class="card-header">
<span>{{ $t('user.account.profile.profile') }}</span>
<el-button @click="router.push({ name: 'account/changePassword' })" type="info" v-blur plain>
{{ $t('user.account.profile.Change Password') }}
</el-button>
</div>
</template>
<div class="user-profile">
<el-form
:label-position="memberCenter.state.shrink ? 'top' : 'right'"
:model="state.form"
:rules="state.rules"
:label-width="100"
ref="formRef"
@keyup.enter="onSubmit()"
>
<FormItem
:label="$t('user.account.profile.avatar')"
:input-attr="{
hideSelectFile: true,
}"
type="image"
v-model="state.form.avatar"
prop="avatar"
/>
<FormItem
:label="$t('user.account.profile.User name')"
type="string"
v-model="state.form.username"
:placeholder="$t('Please input field', { field: $t('user.account.profile.User name') })"
prop="username"
/>
<FormItem
:label="$t('user.account.profile.User nickname')"
type="string"
v-model="state.form.nickname"
:placeholder="$t('Please input field', { field: $t('user.account.profile.User nickname') })"
prop="nickname"
/>
<el-form-item v-if="state.accountVerificationType.includes('email')" :label="t('user.account.profile.email')">
<el-input v-model="state.form.email" readonly :placeholder="t('user.account.profile.Operation via right button')">
<template #append>
<el-button type="primary" @click="onChangeBindInfo('email')">
{{ state.form.email ? t('user.account.profile.Click Modify') : t('user.account.profile.bind') }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="state.accountVerificationType.includes('mobile')" :label="t('user.account.profile.mobile')">
<el-input v-model="state.form.mobile" readonly :placeholder="t('user.account.profile.Operation via right button')">
<template #append>
<el-button type="primary" @click="onChangeBindInfo('mobile')">
{{ state.form.mobile ? t('user.account.profile.Click Modify') : t('user.account.profile.bind') }}
</el-button>
</template>
</el-input>
</el-form-item>
<FormItem
:label="$t('user.account.profile.Gender')"
type="radio"
v-model="state.form.gender"
:input-attr="{
border: true,
content: {
'0': $t('user.account.profile.secrecy'),
'1': $t('user.account.profile.male'),
'2': $t('user.account.profile.female'),
},
}"
/>
<FormItem :label="$t('user.account.profile.birthday')" type="date" v-model="state.form.birthday" />
<FormItem
:label="$t('user.account.profile.Personal signature')"
type="textarea"
:placeholder="$t('Please input field', { field: $t('user.account.profile.Personal signature') })"
v-model="state.form.motto"
:input-attr="{ showWordLimit: true, maxlength: 120, rows: 3 }"
/>
<UserProfileMixin />
<el-form-item class="submit-buttons">
<el-button @click="onResetForm(formRef)">{{ $t('Reset') }}</el-button>
<el-button type="primary" :loading="state.formSubmitLoading" @click="onSubmit()">{{ $t('Save') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 账户验证 -->
<el-dialog
:title="t('user.account.profile.Account verification')"
v-model="state.dialog.verification.show"
class="ba-change-bind-dialog ba-verification-dialog"
:destroy-on-close="true"
:close-on-click-modal="false"
width="30%"
>
<el-form
:model="state.dialog.verification.form"
:rules="state.dialog.verification.rules"
:label-position="'top'"
ref="verificationFormRef"
@keyup.enter="onSubmitVerification()"
>
<FormItem
:label="t('user.account.profile.Account password verification')"
type="password"
v-model="state.dialog.verification.form.password"
prop="password"
:input-attr="{ showPassword: true }"
:placeholder="$t('Please input field', { field: $t('user.account.profile.password') })"
/>
<el-form-item prop="captcha">
<template #label>
<span v-if="state.dialog.type == 'email'">
{{ t('user.account.profile.Mail verification') }}
({{ t('user.account.profile.accept') + t('user.account.profile.mail') + '' + userInfo.email }})
</span>
<span v-else>
{{ t('user.account.profile.SMS verification') }}
({{ t('user.account.profile.accept') + t('user.account.profile.mobile') + '' + userInfo.mobile }})
</span>
</template>
<el-row class="w100" :gutter="10">
<el-col :span="18">
<el-input
v-model="state.dialog.verification.form.captcha"
:placeholder="t('Please input field', { field: t('user.account.profile.Verification Code') })"
autocomplete="off"
/>
</el-col>
<el-col class="captcha-box" :span="6">
<el-button
@click="sendVerificationCaptchaPre"
:loading="state.dialog.sendCaptchaLoading"
:disabled="state.dialog.codeSendCountdown <= 0 ? false : true"
type="primary"
>
{{
state.dialog.codeSendCountdown <= 0
? t('user.account.profile.send')
: state.dialog.codeSendCountdown + t('user.account.profile.seconds')
}}
</el-button>
</el-col>
</el-row>
</el-form-item>
</el-form>
<template #footer>
<div :style="'width: calc(100% - 20px)'">
<el-button @click="state.dialog.verification.show = false">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="state.dialog.submitLoading" @click="onSubmitVerification()" type="primary">
{{ t('user.account.profile.next step') }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 绑定 -->
<el-dialog
:title="t('user.account.profile.bind') + t('user.account.profile.' + state.dialog.type)"
v-model="state.dialog.bind.show"
class="ba-change-bind-dialog ba-bind-dialog"
:destroy-on-close="true"
:close-on-click-modal="false"
width="30%"
>
<el-form
:model="state.dialog.bind.form"
:rules="state.dialog.bind.rules"
:label-position="'top'"
ref="bindFormRef"
@keyup.enter="onSubmitBind()"
>
<FormItem
v-if="!state.dialog.verification.accountVerificationToken"
:label="t('user.account.profile.Account password verification')"
type="password"
v-model="state.dialog.bind.form.password"
prop="password"
:input-attr="{ showPassword: true }"
:placeholder="$t('Please input field', { field: $t('user.account.profile.password') })"
/>
<FormItem
v-if="state.dialog.type == 'email'"
:label="t('user.account.profile.New ' + state.dialog.type)"
type="string"
v-model="state.dialog.bind.form.email"
prop="email"
:placeholder="$t('Please input field', { field: t('user.account.profile.New ' + state.dialog.type) })"
/>
<FormItem
v-if="state.dialog.type == 'mobile'"
:label="t('user.account.profile.New ' + state.dialog.type)"
type="string"
v-model="state.dialog.bind.form.mobile"
prop="mobile"
:placeholder="$t('Please input field', { field: t('user.account.profile.New ' + state.dialog.type) })"
/>
<el-form-item
:label="state.dialog.type == 'email' ? t('user.account.profile.Mail verification') : t('user.account.profile.SMS verification')"
prop="captcha"
>
<el-row class="w100" :gutter="10">
<el-col :span="18">
<el-input
v-model="state.dialog.bind.form.captcha"
:placeholder="t('Please input field', { field: t('user.account.profile.Verification Code') })"
autocomplete="off"
/>
</el-col>
<el-col class="captcha-box" :span="6">
<el-button
@click="sendBindCaptchaPre"
:loading="state.dialog.sendCaptchaLoading"
:disabled="state.dialog.codeSendCountdown <= 0 ? false : true"
type="primary"
>
{{
state.dialog.codeSendCountdown <= 0
? t('user.account.profile.send')
: state.dialog.codeSendCountdown + t('user.account.profile.seconds')
}}
</el-button>
</el-col>
</el-row>
</el-form-item>
</el-form>
<template #footer>
<div :style="'width: calc(100% - 20px)'">
<el-button @click="state.dialog.bind.show = false">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="state.dialog.submitLoading" @click="onSubmitBind()" type="primary">
{{ t('user.account.profile.bind') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import type { FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import { useUserInfo } from '/@/stores/userInfo'
import { onResetForm } from '/@/utils/common'
import { buildValidatorData } from '/@/utils/validate'
import { getProfile, postProfile, postVerification, postChangeBind } from '/@/api/frontend/user/index'
import UserProfileMixin from '/@/components/mixins/userProfile.vue'
import { useI18n } from 'vue-i18n'
import { sendEms, sendSms } from '/@/api/common'
import { uuid } from '/@/utils/random'
import clickCaptcha from '/@/components/clickCaptcha'
import { useMemberCenter } from '/@/stores/memberCenter'
let timer: number
const { t } = useI18n()
const router = useRouter()
const userInfo = useUserInfo()
const memberCenter = useMemberCenter()
const formRef = useTemplateRef('formRef')
const bindFormRef = useTemplateRef('bindFormRef')
const verificationFormRef = useTemplateRef('verificationFormRef')
const state: {
formSubmitLoading: boolean
form: anyObj
rules: Partial<Record<string, FormItemRule[]>>
accountVerificationType: string[]
dialog: {
type: 'email' | 'mobile'
submitLoading: boolean
sendCaptchaLoading: boolean
codeSendCountdown: number
captchaId: string
verification: {
show: boolean
rules: Partial<Record<string, FormItemRule[]>>
form: {
password: string
captcha: string
}
accountVerificationToken: string
}
bind: {
show: boolean
rules: Partial<Record<string, FormItemRule[]>>
form: {
password: string
email: string
mobile: string
captcha: string
}
}
}
} = reactive({
formSubmitLoading: false,
form: userInfo.$state,
rules: {
username: [buildValidatorData({ name: 'required', title: t('user.account.profile.User name') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('user.account.profile.nickname') })],
},
accountVerificationType: [],
dialog: {
type: 'email',
submitLoading: false,
sendCaptchaLoading: false,
codeSendCountdown: 0,
captchaId: uuid(),
verification: {
show: false,
rules: {
password: [
buildValidatorData({ name: 'required', title: t('user.account.profile.password') }),
buildValidatorData({ name: 'password' }),
],
captcha: [buildValidatorData({ name: 'required', title: t('user.account.profile.Verification Code') })],
},
form: {
password: '',
captcha: '',
},
accountVerificationToken: '',
},
bind: {
show: false,
rules: {
password: [
buildValidatorData({ name: 'required', title: t('user.account.profile.password') }),
buildValidatorData({ name: 'password' }),
],
email: [
buildValidatorData({ name: 'required', title: t('user.account.profile.email') }),
buildValidatorData({ name: 'email', title: t('user.account.profile.email') }),
],
mobile: [
buildValidatorData({ name: 'required', title: t('user.account.profile.mobile') }),
buildValidatorData({ name: 'mobile', title: t('user.account.profile.mobile') }),
],
captcha: [buildValidatorData({ name: 'required', title: t('user.account.profile.Verification Code') })],
},
form: {
password: '',
email: '',
mobile: '',
captcha: '',
},
},
},
})
const startTiming = (seconds: number) => {
state.dialog.codeSendCountdown = seconds
timer = window.setInterval(() => {
state.dialog.codeSendCountdown--
if (state.dialog.codeSendCountdown <= 0) {
endTiming()
}
}, 1000)
}
const endTiming = () => {
state.dialog.codeSendCountdown = 0
clearInterval(timer)
}
const onChangeBindInfo = (type: 'email' | 'mobile') => {
if ((type == 'email' && userInfo.email) || (type == 'mobile' && userInfo.mobile)) {
state.dialog.verification.show = true
} else {
state.dialog.bind.show = true
}
state.dialog.type = type
}
const sendVerificationCaptchaPre = () => {
if (state.dialog.codeSendCountdown > 0) return
verificationFormRef.value!.validateField('password').then((res) => {
if (!res) return
clickCaptcha(state.dialog.captchaId, (captchaInfo: string) => sendVerificationCaptcha(captchaInfo))
})
}
const sendVerificationCaptcha = (captchaInfo: string) => {
state.dialog.sendCaptchaLoading = true
const func = state.dialog.type == 'email' ? sendEms : sendSms
func(userInfo[state.dialog.type], `user_${state.dialog.type}_verify`, {
password: state.dialog.verification.form.password,
captchaId: state.dialog.captchaId,
captchaInfo,
})
.then((res) => {
if (res.code == 1) startTiming(60)
})
.finally(() => {
state.dialog.sendCaptchaLoading = false
})
}
const sendBindCaptchaPre = () => {
if (state.dialog.codeSendCountdown > 0) return
bindFormRef.value!.validateField(state.dialog.type).then((res) => {
if (!res) return
clickCaptcha(state.dialog.captchaId, (captchaInfo: string) => sendBindCaptcha(captchaInfo))
})
}
const sendBindCaptcha = (captchaInfo: string) => {
state.dialog.sendCaptchaLoading = true
const func = state.dialog.type == 'email' ? sendEms : sendSms
func(state.dialog.bind.form[state.dialog.type], `user_change_${state.dialog.type}`, {
captchaId: state.dialog.captchaId,
captchaInfo,
})
.then((res) => {
if (res.code == 1) startTiming(60)
})
.finally(() => {
state.dialog.sendCaptchaLoading = false
})
}
const onSubmitVerification = () => {
verificationFormRef.value?.validate((res) => {
if (res) {
state.dialog.submitLoading = true
postVerification({
type: state.dialog.type,
captcha: state.dialog.verification.form.captcha,
})
.then((res) => {
endTiming()
state.dialog.bind.show = true
state.dialog.type = res.data.type
state.dialog.verification.show = false
state.dialog.verification.accountVerificationToken = res.data.accountVerificationToken
})
.finally(() => {
state.dialog.submitLoading = false
})
}
})
}
const onSubmitBind = () => {
bindFormRef.value?.validate((res) => {
if (res) {
state.dialog.submitLoading = true
postChangeBind({
type: state.dialog.type,
accountVerificationToken: state.dialog.verification.accountVerificationToken,
...state.dialog.bind.form,
})
.then(() => {
endTiming()
state.dialog.bind.show = false
userInfo[state.dialog.type] = state.dialog.bind.form[state.dialog.type]
})
.finally(() => {
state.dialog.submitLoading = false
})
}
})
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (valid) {
state.formSubmitLoading = true
postProfile(state.form)
.then(() => {
state.formSubmitLoading = false
})
.catch(() => {
state.formSubmitLoading = false
})
}
})
}
onMounted(() => {
getProfile().then((res) => {
state.accountVerificationType = res.data.accountVerificationType
})
})
</script>
<style scoped lang="scss">
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-profile {
width: 400px;
max-width: 100%;
}
.submit-buttons :deep(.el-form-item__content) {
justify-content: flex-end;
}
:deep(.el-upload-list--picture-card) {
--el-upload-list-picture-card-size: 100px;
}
:deep(.el-upload--picture-card) {
--el-upload-picture-card-size: 100px;
}
.captcha-box {
margin-left: auto;
.el-button {
width: 100%;
}
}
:deep(.ba-verification-dialog) .el-dialog__body {
padding-bottom: 10px;
}
@media screen and (max-width: 1024px) {
:deep(.ba-change-bind-dialog) {
--el-dialog-width: 50% !important;
}
}
@media screen and (max-width: 768px) {
:deep(.ba-change-bind-dialog) {
--el-dialog-width: 70% !important;
}
}
@media screen and (max-width: 600px) {
:deep(.ba-change-bind-dialog) {
--el-dialog-width: 92% !important;
}
}
</style>

View File

@@ -0,0 +1,590 @@
<template>
<div class="login">
<el-container class="is-vertical">
<Header />
<el-main class="frontend-footer-brother">
<el-row justify="center">
<el-col :span="16" :xs="24">
<div v-if="memberCenter.state.open" class="login-box">
<div class="login-title">
{{ t('user.login.' + state.form.tab) + t('user.login.reach') + siteConfig.siteName }}
</div>
<el-form ref="formRef" @keyup.enter="onSubmitPre" :rules="rules" :model="state.form">
<!-- 注册验证方式 -->
<el-form-item v-if="state.form.tab == 'register'">
<el-radio-group size="large" v-model="state.form.registerType">
<el-radio
class="register-verification-radio"
value="email"
:disabled="!state.accountVerificationType.includes('email')"
border
>
{{ t('user.login.Via email') + t('user.login.register') }}
</el-radio>
<el-radio
class="register-verification-radio"
value="mobile"
:disabled="!state.accountVerificationType.includes('mobile')"
border
>
{{ t('user.login.Via mobile number') + t('user.login.register') }}
</el-radio>
</el-radio-group>
</el-form-item>
<!-- 登录注册用户名 -->
<el-form-item prop="username">
<el-input
v-model="state.form.username"
:placeholder="
state.form.tab == 'register'
? t('Please input field', { field: t('user.login.User name') })
: t('Please input field', { field: t('user.login.account') })
"
:clearable="true"
size="large"
>
<template #prefix>
<Icon name="fa fa-user" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<!-- 登录注册密码 -->
<el-form-item prop="password">
<el-input
v-model="state.form.password"
:placeholder="t('Please input field', { field: t('user.login.password') })"
type="password"
show-password
size="large"
>
<template #prefix>
<Icon name="fa fa-unlock-alt" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<!-- 注册手机号 -->
<el-form-item v-if="state.form.tab == 'register' && state.form.registerType == 'mobile'" prop="mobile">
<el-input
v-model="state.form.mobile"
:placeholder="t('Please input field', { field: t('user.login.mobile') })"
:clearable="true"
size="large"
>
<template #prefix>
<Icon name="fa fa-tablet" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<!-- 注册邮箱 -->
<el-form-item v-if="state.form.tab == 'register' && state.form.registerType == 'email'" prop="email">
<el-input
v-model="state.form.email"
:placeholder="t('Please input field', { field: t('user.login.email') })"
:clearable="true"
size="large"
>
<template #prefix>
<Icon name="fa fa-envelope" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<!-- 注册验证码 -->
<el-form-item v-if="state.form.tab == 'register'" prop="captcha">
<el-row class="w100">
<el-col :span="16">
<el-input
size="large"
v-model="state.form.captcha"
:placeholder="t('Please input field', { field: t('user.login.Verification Code') })"
autocomplete="off"
>
<template #prefix>
<Icon name="fa fa-ellipsis-h" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-col>
<el-col class="captcha-box" :span="8">
<el-button
size="large"
@click="sendRegisterCaptchaPre"
:loading="state.sendCaptchaLoading"
:disabled="state.codeSendCountdown <= 0 ? false : true"
type="primary"
>
{{
state.codeSendCountdown <= 0
? t('user.login.send')
: state.codeSendCountdown + t('user.login.seconds')
}}
</el-button>
</el-col>
</el-row>
</el-form-item>
<div v-if="state.form.tab != 'register'" class="form-footer">
<el-checkbox v-model="state.form.keep" :label="t('user.login.Remember me')" size="default"></el-checkbox>
<div
v-if="state.accountVerificationType.length > 0"
@click="state.showRetrievePasswordDialog = true"
class="forgot-password"
>
{{ t('user.login.Forgot your password?') }}
</div>
</div>
<el-form-item class="form-buttons">
<el-button class="login-btn" @click="onSubmitPre" :loading="state.formLoading" round type="primary" size="large">
{{ t('user.login.' + state.form.tab) }}
</el-button>
<el-button
v-if="state.form.tab == 'register'"
@click="switchTab(formRef, 'login')"
round
plain
type="info"
size="large"
>
{{ t('user.login.Back to login') }}
</el-button>
<el-button v-else @click="switchTab(formRef, 'register')" round plain type="info" size="large">
{{ t('user.login.No account yet? Click Register') }}
</el-button>
</el-form-item>
<LoginFooterMixin />
</el-form>
</div>
<el-alert v-else :center="true" :title="$t('Member center disabled')" type="error" />
</el-col>
</el-row>
</el-main>
<Footer />
</el-container>
<el-dialog
:close-on-click-modal="false"
:close-on-press-escape="false"
v-model="state.showRetrievePasswordDialog"
:title="t('user.login.Retrieve password')"
:width="state.dialogWidth + '%'"
:draggable="true"
>
<div class="retrieve-password-form">
<el-form
ref="retrieveFormRef"
@keyup.enter="onSubmitRetrieve()"
:rules="retrieveRules"
:model="state.retrievePasswordForm"
:label-width="100"
>
<el-form-item :label="t('user.login.Retrieval method')">
<el-radio-group v-model="state.retrievePasswordForm.type">
<el-radio value="email" :disabled="!state.accountVerificationType.includes('email')" border>
{{ t('user.login.Via email') }}
</el-radio>
<el-radio value="mobile" :disabled="!state.accountVerificationType.includes('mobile')" border>
{{ t('user.login.Via mobile number') }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="account" :label="state.retrievePasswordForm.type == 'email' ? t('user.login.email') : t('user.login.mobile')">
<el-input
v-model="state.retrievePasswordForm.account"
:placeholder="
t('Please input field', {
field: state.retrievePasswordForm.type == 'email' ? t('user.login.email') : t('user.login.mobile'),
})
"
:clearable="true"
>
<template #prefix>
<Icon name="fa fa-user" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="captcha" :label="t('user.login.Verification Code')">
<el-row class="w100">
<el-col :span="16">
<el-input
v-model="state.retrievePasswordForm.captcha"
:placeholder="t('Please input field', { field: t('user.login.Verification Code') })"
autocomplete="off"
>
<template #prefix>
<Icon name="fa fa-ellipsis-h" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-col>
<el-col class="captcha-box" :span="8">
<el-button
@click="sendRetrieveCaptchaPre"
:loading="state.sendCaptchaLoading"
:disabled="state.codeSendCountdown <= 0 ? false : true"
type="primary"
>
{{ state.codeSendCountdown <= 0 ? t('user.login.send') : state.codeSendCountdown + t('user.login.seconds') }}
</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item prop="password" :label="t('user.login.New password')">
<el-input
v-model="state.retrievePasswordForm.password"
:placeholder="t('Please input field', { field: t('user.login.New password') })"
show-password
>
<template #prefix>
<Icon name="fa fa-unlock-alt" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.showRetrievePasswordDialog = false">{{ t('Cancel') }}</el-button>
<el-button :loading="state.submitRetrieveLoading" @click="onSubmitRetrieve()" type="primary">
{{ t('user.login.second') }}
</el-button>
</el-form-item>
</el-form>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, onUnmounted, useTemplateRef } from 'vue'
import Header from '/@/layouts/frontend/components/header.vue'
import Footer from '/@/layouts/frontend/components/footer.vue'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useMemberCenter } from '/@/stores/memberCenter'
import { sendEms, sendSms } from '/@/api/common'
import { uuid } from '/@/utils/random'
import { useI18n } from 'vue-i18n'
import { buildValidatorData, validatorAccount } from '/@/utils/validate'
import { checkIn, retrievePassword } from '/@/api/frontend/user/index'
import { useEventListener } from '@vueuse/core'
import { onResetForm } from '/@/utils/common'
import { useUserInfo } from '/@/stores/userInfo'
import { useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import loginMounted from '/@/components/mixins/loginMounted'
import LoginFooterMixin from '/@/components/mixins/loginFooter.vue'
import type { FormItemRule, FormInstance } from 'element-plus'
import clickCaptcha from '/@/components/clickCaptcha'
let timer: number
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userInfo = useUserInfo()
const siteConfig = useSiteConfig()
const memberCenter = useMemberCenter()
const formRef = useTemplateRef('formRef')
const retrieveFormRef = useTemplateRef('retrieveFormRef')
interface State {
form: {
tab: 'login' | 'register'
email: string
mobile: string
username: string
password: string
captcha: string
keep: boolean
captchaId: string
captchaInfo: string
registerType: 'email' | 'mobile'
}
formLoading: boolean
showRetrievePasswordDialog: boolean
retrievePasswordForm: {
type: 'email' | 'mobile'
account: string
captcha: string
password: string
}
dialogWidth: number
userLoginCaptchaSwitch: boolean
accountVerificationType: string[]
codeSendCountdown: number
submitRetrieveLoading: boolean
sendCaptchaLoading: boolean
to: string
}
const state: State = reactive({
form: {
tab: 'login',
email: '',
mobile: '',
username: '',
password: '',
captcha: '',
keep: false,
captchaId: uuid(),
captchaInfo: '',
registerType: 'email',
},
formLoading: false,
showRetrievePasswordDialog: false,
retrievePasswordForm: {
type: 'email',
account: '',
captcha: '',
password: '',
},
dialogWidth: 36,
userLoginCaptchaSwitch: true,
accountVerificationType: [],
codeSendCountdown: 0,
submitRetrieveLoading: false,
sendCaptchaLoading: false,
// 登录成功后要跳转的URL
to: route.query.to as string,
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
email: [
buildValidatorData({ name: 'required', title: t('user.login.email') }),
buildValidatorData({ name: 'email', title: t('user.login.email') }),
],
username: [
buildValidatorData({ name: 'required', title: t('user.login.User name') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (state.form.tab == 'register') {
return validatorAccount(rule, val, callback)
} else {
callback()
}
},
trigger: 'blur',
},
],
password: [buildValidatorData({ name: 'required', title: t('user.login.password') }), buildValidatorData({ name: 'password' })],
mobile: [buildValidatorData({ name: 'required', title: t('user.login.mobile') }), buildValidatorData({ name: 'mobile' })],
captcha: [buildValidatorData({ name: 'required', title: t('user.login.Verification Code') })],
})
const retrieveRules: Partial<Record<string, FormItemRule[]>> = reactive({
account: [buildValidatorData({ name: 'required', title: t('user.login.Account name') })],
captcha: [buildValidatorData({ name: 'required', title: t('user.login.Verification Code') })],
password: [buildValidatorData({ name: 'required', title: t('user.login.password') }), buildValidatorData({ name: 'password' })],
})
const resize = () => {
let clientWidth = document.documentElement.clientWidth
let width = 36
if (clientWidth <= 790) {
width = 92
} else if (clientWidth <= 910) {
width = 56
} else if (clientWidth <= 1260) {
width = 46
}
state.dialogWidth = width
}
const onSubmitPre = () => {
formRef.value?.validate((valid) => {
if (!valid) return
if (state.form.tab == 'login' && state.userLoginCaptchaSwitch) {
clickCaptcha(state.form.captchaId, (captchaInfo: string) => onSubmit(captchaInfo))
} else {
onSubmit()
}
})
}
const onSubmit = (captchaInfo = '') => {
state.formLoading = true
state.form.captchaInfo = captchaInfo
checkIn('post', state.form)
.then((res) => {
userInfo.dataFill(res.data.userInfo, false)
if (state.to) return (location.href = state.to)
router.push({ path: res.data.routePath })
})
.finally(() => {
state.formLoading = false
})
}
const onSubmitRetrieve = () => {
if (!retrieveFormRef.value) return
retrieveFormRef.value.validate((valid) => {
if (valid) {
state.submitRetrieveLoading = true
retrievePassword(state.retrievePasswordForm)
.then((res) => {
state.submitRetrieveLoading = false
if (res.code == 1) {
state.showRetrievePasswordDialog = false
endTiming()
onResetForm(retrieveFormRef.value)
}
})
.catch(() => {
state.submitRetrieveLoading = false
})
}
})
}
const sendRegisterCaptchaPre = () => {
if (state.codeSendCountdown > 0) return
formRef.value!.validateField([state.form.registerType, 'username', 'password']).then((valid) => {
if (!valid) return
clickCaptcha(state.form.captchaId, (captchaInfo: string) => sendRegisterCaptcha(captchaInfo))
})
}
const sendRegisterCaptcha = (captchaInfo: string) => {
state.sendCaptchaLoading = true
const func = state.form.registerType == 'email' ? sendEms : sendSms
func(state.form[state.form.registerType], 'user_register', {
captchaInfo,
captchaId: state.form.captchaId,
})
.then((res) => {
if (res.code == 1) startTiming(60)
})
.finally(() => {
state.sendCaptchaLoading = false
})
}
const sendRetrieveCaptchaPre = () => {
if (state.codeSendCountdown > 0) return
retrieveFormRef.value!.validateField('account').then((valid) => {
if (!valid) return
clickCaptcha(state.form.captchaId, (captchaInfo: string) => sendRetrieveCaptcha(captchaInfo))
})
}
const sendRetrieveCaptcha = (captchaInfo: string) => {
state.sendCaptchaLoading = true
const func = state.retrievePasswordForm.type == 'email' ? sendEms : sendSms
func(state.retrievePasswordForm.account, 'user_retrieve_pwd', {
captchaInfo,
captchaId: state.form.captchaId,
})
.then((res) => {
if (res.code == 1) startTiming(60)
})
.finally(() => {
state.sendCaptchaLoading = false
})
}
const switchTab = (formRef: FormInstance | null | undefined = undefined, tab: 'login' | 'register') => {
state.form.tab = tab
if (tab == 'register') state.form.username = ''
if (formRef) formRef.clearValidate()
}
const startTiming = (seconds: number) => {
state.codeSendCountdown = seconds
timer = window.setInterval(() => {
state.codeSendCountdown--
if (state.codeSendCountdown <= 0) {
endTiming()
}
}, 1000)
}
const endTiming = () => {
state.codeSendCountdown = 0
clearInterval(timer)
}
onMounted(async () => {
if (await loginMounted()) return
resize()
useEventListener(window, 'resize', resize)
checkIn('get').then((res) => {
state.userLoginCaptchaSwitch = res.data.userLoginCaptchaSwitch
state.accountVerificationType = res.data.accountVerificationType
state.retrievePasswordForm.type = res.data.accountVerificationType.length > 0 ? res.data.accountVerificationType[0] : ''
})
if (route.query.type == 'register') state.form.tab = 'register'
})
onUnmounted(() => {
state.codeSendCountdown = 0
endTiming()
})
</script>
<style scoped lang="scss">
.login-box {
width: 460px;
margin: 40px auto;
padding: 10px 60px 20px 60px;
background-color: var(--ba-bg-color-overlay);
}
.login-title {
text-align: center;
font-size: var(--el-font-size-large);
line-height: 100px;
user-select: none;
}
:deep(.el-input--large) .el-input__wrapper {
padding: 4px 15px;
}
.form-buttons {
padding-top: 20px;
.el-button {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 20px;
margin-left: 0;
}
}
.register-verification-radio {
margin-top: 10px;
}
.captcha-box {
display: flex;
align-items: center;
justify-content: flex-end;
.el-button {
width: 90%;
height: 100%;
}
}
.form-footer {
display: flex;
align-items: center;
.forgot-password {
color: var(--ba-color-primary-light);
margin-left: auto;
user-select: none;
cursor: pointer;
}
}
.retrieve-password-form {
display: flex;
justify-content: center;
margin-right: 50px;
}
@media screen and (max-width: 768px) {
.login-box {
width: 100%;
margin: 0 auto;
}
.retrieve-password-form {
margin-right: 0;
}
}
// 暗黑样式
@at-root .dark {
.form-buttons {
.login-btn {
--el-button-bg-color: var(--el-color-primary-light-5);
--el-button-border-color: rgba(240, 252, 241, 0.1);
}
}
}
</style>

23
web/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"useDefineForClassFields": true,
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"baseUrl": "./",
"allowJs": true,
"skipLibCheck": true,
"paths": {
"/@/*": ["src/*"]
},
"types": ["vite/client", "element-plus/global"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "types/**/*.d.ts", "vite.config.ts"]
}

30
web/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
interface Window {
existLoading: boolean
lazy: number
unique: number
tokenRefreshing: boolean
requests: Function[]
eventSource: EventSource
loadLangHandle: Record<string, any>
}
interface anyObj {
[key: string]: any
}
interface TableDefaultData<T = any> {
list: T
remark: string
total: number
}
interface ApiResponse<T = any> {
code: number
data: T
msg: string
time: number
}
type ApiPromise<T = any> = Promise<ApiResponse<T>>
type Writeable<T> = { -readonly [P in keyof T]: T[P] }

8
web/types/module.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
const component: DefineComponent<{}, {}, any>
export default component
}

519
web/types/table.d.ts vendored Normal file
View File

@@ -0,0 +1,519 @@
import type {
ButtonProps,
ButtonType,
ColProps,
ElTooltipProps,
FormInstance,
ImageProps,
PopconfirmProps,
SwitchProps,
TableColumnCtx,
TagProps,
} from 'element-plus'
import type { Component, ComponentPublicInstance } from 'vue'
import Icon from '/@/components/icon/index.vue'
import Table from '/@/components/table/index.vue'
declare global {
interface BaTable {
/**
* 表格数据,通过 baTable.getData 获取
* 刷新数据可使用 baTable.onTableHeaderAction('refresh', { event: 'custom' })
*/
data?: TableRow[]
/**
* 表格列定义
*/
column: TableColumn[]
/**
* 获取表格数据时的过滤条件(含公共搜索、快速搜索、分页、排序等数据)
* 公共搜索数据可使用 baTable.setComSearchData 和 baTable.getComSearchData 进行管理
*/
filter?: {
page?: number
limit?: number
order?: string
quickSearch?: string
search?: ComSearchData[]
[key: string]: any
}
/**
* 不需要双击编辑的字段type=selection 的列为 undefined
* 禁用全部列的双击编辑,可使用 ['all']
*/
dblClickNotEditColumn?: (string | undefined)[]
/**
* 表格扩展数据,随意定义,以便一些自定义数据可以随 baTable 实例传递
*/
extend?: anyObj
// 表格 ref通常在 页面 onMounted 时赋值,可选的
ref?: InstanceType<typeof Table> | null
// 表格对应数据表的主键字段
pk?: string
// 路由 remark后台菜单规则备注信息
remark?: string | null
// 表格加载状态
loading?: boolean
// 当前选中行
selection?: TableRow[]
// 数据总量
total?: number
// 默认排序字段和排序方式
defaultOrder?: { prop: string; order: string }
// 拖动排序限位字段,例如拖动行 pid=1那么拖动目的行 pid 也需要为 1
dragSortLimitField?: string
// 接受 url 的 query 参数并自动触发公共搜索
acceptQuery?: boolean
// 显示公共搜索
showComSearch?: boolean
// 是否展开所有子项,树状表格专用属性
expandAll?: boolean
// 当前表格所在页面的路由 path
routePath?: string
}
interface BaTableForm {
/**
* 当前表单项数据
*/
items?: anyObj
/**
* 当前操作标识:Add=添加,Edit=编辑
*/
operate?: string
/**
* 添加表单字段默认值,打开表单时会使用 cloneDeep 赋值给 this.form.items 对象
*/
defaultItems?: anyObj
/**
* 表单扩展数据,可随意定义,以便一些自定义数据可以随 baTable 实例传递
*/
extend?: anyObj
// 表单 ref实例化表格时通常无需传递
ref?: FormInstance | undefined
// 表单项 label 的宽度
labelWidth?: number
// 被操作数据ID支持批量编辑:add=[0],edit=[1,2,n]
operateIds?: string[]
// 提交按钮状态
submitLoading?: boolean
// 表单加载状态
loading?: boolean
}
/**
* BaTable 前置处理函数(前置埋点)
*/
interface BaTableBefore {
/**
* 获取表格数据前的钩子(返回 false 可取消原操作)
*/
getData?: () => boolean | void
/**
* 删除前的钩子(返回 false 可取消原操作)
* @param object.ids 被删除数据的主键集合
*/
postDel?: ({ ids }: { ids: string[] }) => boolean | void
/**
* 获取被编辑行数据前的钩子(返回 false 可取消原操作)
* @param object.id 被编辑行主键
*/
getEditData?: ({ id }: { id: string }) => boolean | void
/**
* 双击表格具体操作执行前钩子(返回 false 可取消原操作)
* @param object.row 被双击行数据
* @param object.column 被双击列数据
*/
onTableDblclick?: ({ row, column }: { row: TableRow; column: TableColumn }) => boolean | void
/**
* 表单切换前钩子(返回 false 可取消默认行为)
* @param object.operate 当前操作标识:Add=添加,Edit=编辑
* @param object.operateIds 被操作的行 ID 集合
*/
toggleForm?: ({ operate, operateIds }: { operate: string; operateIds: string[] }) => boolean | void
/**
* 表单提交前钩子(返回 false 可取消原操作)
* @param object.formEl 表单组件ref
* @param object.operate 当前操作标识:Add=添加,Edit=编辑
* @param object.items 表单数据
*/
onSubmit?: ({ formEl, operate, items }: { formEl?: FormInstance | null; operate: string; items: anyObj }) => boolean | void
/**
* 表格内事件响应前钩子(返回 false 可取消原操作)
* @param object.event 事件名称
* @param object.data 事件携带的数据
*/
onTableAction?: ({ event, data }: { event: BaTableActionEventName; data: anyObj }) => boolean | void
/**
* 表格顶部菜单事件响应前钩子(返回 false 可取消原操作)
* @param object.event 事件名称
* @param object.data 事件携带的数据
*/
onTableHeaderAction?: ({ event, data }: { event: BaTableHeaderActionEventName; data: anyObj }) => boolean | void
/**
* 表格初始化前钩子
*/
mount?: () => boolean | void
/** getData 的别名 */
getIndex?: () => boolean | void
/** getEditData 的别名 */
requestEdit?: ({ id }: { id: string }) => boolean | void
// 可自定义其他钩子
[key: string]: Function | undefined
}
/**
* BaTable 后置处理函数(后置埋点)
*/
interface BaTableAfter {
/**
* 请求到表格数据后钩子
* 此时 baTable.table.data 已赋值
* @param object.res 请求完整响应
*/
getData?: ({ res }: { res: ApiResponse }) => void
/**
* 删除请求后钩子
* @param object.res 请求完整响应
*/
postDel?: ({ res }: { res: ApiResponse }) => void
/**
* 获取到编辑行数据后钩子
* 此时 baTable.form.items 已赋值
* @param object.res 请求完整响应
*/
getEditData?: ({ res }: { res: ApiResponse }) => void
/**
* 双击单元格操作执行后钩子
* @param object.row 当前行数据
* @param object.column 当前列数据
*/
onTableDblclick?: ({ row, column }: { row: TableRow; column: TableColumn }) => void
/**
* 表单切换后钩子
* @param object.operate 当前操作标识:Add=添加,Edit=编辑
* @param object.operateIds 被操作的 ID 集合
*/
toggleForm?: ({ operate, operateIds }: { operate: string; operateIds: string[] }) => void
/**
* 表单提交后钩子
* @param object.res 请求完整响应
*/
onSubmit?: ({ res }: { res: ApiResponse }) => void
/**
* 表格内事件响应后钩子
* @param object.event 事件名称
* @param object.data 事件携带的数据
*/
onTableAction?: ({ event, data }: { event: BaTableActionEventName; data: anyObj }) => void
/**
* 表格顶部菜单事件响应后钩子
* @param object.event 事件名称
* @param object.data 事件携带的数据
*/
onTableHeaderAction?: ({ event, data }: { event: BaTableHeaderActionEventName; data: anyObj }) => void
/** getData 的别名 */
getIndex?: ({ res }: { res: ApiResponse }) => void
/** getEditData 的别名 */
requestEdit?: ({ res }: { res: ApiResponse }) => void
// 可自定义其他钩子
[key: string]: Function | undefined
}
/**
* baTable 表格内事件名称
* selection-change=选中项改变,page-size-change=每页数量改变,current-page-change=翻页,sort-change=排序,edit=编辑,delete=删除,field-change=单元格值改变,com-search=公共搜索
*/
type BaTableActionEventName =
| 'selection-change'
| 'page-size-change'
| 'current-page-change'
| 'sort-change'
| 'edit'
| 'delete'
| 'field-change'
| 'com-search'
/**
* baTable 表格头部事件名称
* refresh=刷新,add=添加,edit=编辑,delete=删除,quick-search=快速查询,unfold=折叠/展开,change-show-column=调整列显示状态
*/
type BaTableHeaderActionEventName = 'refresh' | 'add' | 'edit' | 'delete' | 'quick-search' | 'unfold' | 'change-show-column'
/**
* 表格公共搜索数据
*/
interface ComSearch {
/** 表单项数据 */
form: anyObj
/** 字段搜索配置搜索操作符operator、字段渲染方式render等 */
fieldData: Map<string, any>
}
/**
* 表格列
*/
interface TableColumn extends Partial<TableColumnCtx<TableRow>> {
// 是否于表格显示此列
show?: boolean
// 渲染器组件名,即 \src\components\table\fieldRender\ 中的组件之一,也可以查看 TableRenderer 类型定义
render?: TableRenderer
// 值替换数据(字典数据),同时用于单元格渲染时和作为公共搜索下拉框数据,格式如:{ open: '开', close: '关', disable: '已禁用' }
replaceValue?: Record<string, any>
// render=slot 时slot 的名称
slotName?: string
// render=customRender 时,要渲染的组件或已注册组件名称的字符串
customRender?: string | Component
// render=customTemplate 时,自定义渲染 html应谨慎使用请返回 html 内容,务必确保返回内容是 xss 安全的
customTemplate?: (row: TableRow, field: TableColumn, value: any, column: TableColumnCtx<TableRow>, index: number) => string
// 渲染前对字段值的预处理函数(对 el-table 的 formatter 扩展)
formatter?: (row: TableRow, column: TableColumnCtx<TableRow>, cellValue: any, index: number) => any
/**
* 自定义单元格渲染属性(比如单元格渲染器内部的 tag、button 组件的属性,设计上不仅是组件属性,也可以自定义其他渲染相关属性)
* 直接定义对应组件的属性 object或使用一个函数返回组件属性 object
*/
customRenderAttr?: {
tag?: TableContextDataFun<TagProps>
icon?: TableContextDataFun<InstanceType<typeof Icon>['$props']>
image?: TableContextDataFun<ImageProps>
switch?: TableContextDataFun<SwitchProps>
tooltip?: TableContextDataFun<ElTooltipProps>
[key: string]: any
}
// render=tag 时el-tag 组件的 effect
effect?: TagProps['effect']
// render=tag 时el-tag 组件的 size
size?: TagProps['size']
// render=url 时,链接的打开方式
target?: '_blank' | '_self'
// render=datetime 时,时间日期的格式化方式,字母可以自由组合:y=年,m=月,d=日,h=时,M=分,s=秒默认yyyy-mm-dd hh:MM:ss
timeFormat?: string
// render=buttons 时,操作按钮数组
buttons?: OptButton[]
/**
* 单元格渲染器需要的其他任意自定义数据
* 1. render=tag 时,可单独指定每个不同的值 tag 的 type 属性 { open: 'success', close: 'info', disable: 'danger' }
*/
custom?: any
// 默认值(单元格值为 undefined,null,'' 时取默认值,仅使用了 render 时有效)
default?: any
// 是否允许动态控制字段是否显示,默认为 true
enableColumnDisplayControl?: boolean
// 单元格渲染组件的 key默认将根据列配置等属性自动生成此 key 值改变时单元格将自动重新渲染)
getRenderKey?: (row: TableRow, field: TableColumn, column: TableColumnCtx<TableRow>, index: number) => string
// 公共搜索操作符,默认值为 = ,值为 false 禁用此字段公共搜索,支持的操作符见下类型定义
operator?: boolean | OperatorStr
// 公共搜索框的 placeholder
operatorPlaceholder?: string | string[]
// 公共搜索渲染方式render=tag|switch 时公共搜索也会渲染为下拉,数字会渲染为范围筛选,时间渲染为时间选择器等
comSearchRender?: 'string' | 'remoteSelect' | 'select' | 'time' | 'date' | 'datetime' | 'customRender' | 'slot'
// 公共搜索自定义组件/函数渲染
comSearchCustomRender?: string | Component
// 公共搜索自定义渲染为 slot 时slot 的名称
comSearchSlotName?: string
// 公共搜索自定义渲染时,外层 el-col 的属性(仅 customRender、slot 支持)
comSearchColAttr?: Partial<ColProps>
// 公共搜索是否显示字段的 label
comSearchShowLabel?: boolean
// 公共搜索输入组件的扩展属性
comSearchInputAttr?: anyObj
// 公共搜索渲染为远程下拉时,远程下拉组件的必要属性
remote?: {
pk?: string
field?: string
params?: anyObj
multiple?: boolean
remoteUrl: string
}
// 使用了 render 属性时,渲染前对字段值的预处理方法(即将废弃,请使用兼容 el-table 的 formatter 函数代替)
renderFormatter?: (row: TableRow, field: TableColumn, value: any, column: TableColumnCtx<TableRow>, index: number) => any
// 渲染为 url 时的点击事件(即将废弃,请使用 el-table 的 @cell-click 或单元格自定义渲染代替)
click?: (row: TableRow, field: TableColumn, value: any, column: TableColumnCtx<TableRow>, index: number) => any
}
/**
* 表格右侧操作按钮
*/
interface OptButton {
/**
* 渲染方式:tipButton=带tip的按钮,confirmButton=带确认框的按钮,moveButton=移动按钮,basicButton=普通按钮
*/
render: 'tipButton' | 'confirmButton' | 'moveButton' | 'basicButton'
/**
* 按钮名称将作为触发表格内事件onTableAction时的事件名
*/
name: string
/**
* 鼠标 hover 时的提示
* 可使用多语言翻译 key比如 user.group
*/
title?: string
/**
* 直接在按钮内显示的文字title 有值时可为空
* 可使用多语言翻译 key比如 user.group
*/
text?: string
/**
* 自定义按钮的点击事件
* @param row 当前行数据
* @param field 当前列数据
*/
click?: (row: TableRow, field: TableColumn) => void
/**
* 按钮是否显示请返回布尔值比如display: auth('add')
* @param row 当前行数据
* @param field 当前列数据
*/
display?: (row: TableRow, field: TableColumn) => boolean
/**
* 按钮是否禁用(请返回布尔值)
* @param row 当前行数据
* @param field 当前列数据
*/
disabled?: (row: TableRow, field: TableColumn) => boolean
/**
* 按钮是否正在加载中(请返回布尔值)
* @param row 当前行数据
* @param field 当前列数据
*/
loading?: (row: TableRow, field: TableColumn) => boolean
/**
* 自定义 el-button 的其他属性(格式为属性 object 或一个返回属性 object 的函数)
*/
attr?: TableContextDataFun<ButtonProps>
// 按钮 class
class?: string
// 按钮 type
type: ButtonType
// 按钮 icon 的名称
icon: string
// 确认按钮的气泡确认框的属性el-popconfirm 的属性,格式为属性 object 或一个返回属性 object 的函数)
popconfirm?: TableContextDataFun<PopconfirmProps>
// 是否禁用 title 提示,此值通常由系统动态调整以确保提示的显示效果
disabledTip?: boolean
}
/**
* 表格行
*/
interface TableRow extends anyObj {
children?: TableRow[]
}
/**
* 表头支持的按钮
*/
type HeaderOptButton = 'refresh' | 'add' | 'edit' | 'delete' | 'unfold' | 'comSearch' | 'quickSearch' | 'columnDisplay'
/**
* 公共搜索操作符支持的值
*/
type OperatorStr =
| 'eq' // 等于,默认值
| 'ne' // 不等于
| 'gt' // 大于
| 'egt' // 大于等于
| 'lt' // 小于
| 'elt' // 小于等于
| 'LIKE'
| 'NOT LIKE'
| 'IN'
| 'NOT IN'
| 'RANGE' // 范围,将生成两个输入框,可以输入最小值和最大值
| 'NOT RANGE'
| 'NULL' // 是否为NULL将生成单个复选框
| 'NOT NULL'
| 'FIND_IN_SET'
// 不推荐使用的,因为部分符号不利于网络传输
| '='
| '<>'
| '>'
| '>='
| '<'
| '<='
/**
* 公共搜索事件返回的 Data
*/
interface ComSearchData {
field: string
val: string | string[] | number | number[]
operator: string
render?: string
}
interface ElTreeData {
label: string
children?: ElTreeData[]
}
/**
* 表格上下文数据
*/
interface TableContextData {
row?: TableRow
field?: TableColumn
cellValue?: any
column?: TableColumnCtx<TableRow>
index?: number
}
/**
* 接受表格上下文数据的任意属性计算函数
*/
type TableContextDataFun<T> = Partial<T> | ((context: TableContextData) => Partial<T>)
interface TableRenderPublicInstance extends ComponentPublicInstance {
$attrs: {
renderValue: any
renderRow: TableRow
renderField: TableColumn
renderColumn: TableColumnCtx<TableRow>
renderIndex: number
}
}
}

15
web/types/tableRenderer.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/** 可用的表格单元格渲染器,以 ./src/components/table/fieldRender/ 目录中的文件名自动生成 */
type TableRenderer =
| 'buttons'
| 'color'
| 'customRender'
| 'customTemplate'
| 'datetime'
| 'icon'
| 'image'
| 'images'
| 'switch'
| 'tag'
| 'tags'
| 'url'
| 'slot'

50
web/vite.config.ts Normal file
View File

@@ -0,0 +1,50 @@
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import type { ConfigEnv, UserConfig } from 'vite'
import { loadEnv } from 'vite'
import { svgBuilder } from '/@/components/icon/svg/index'
import { customHotUpdate, isProd } from '/@/utils/vite'
const pathResolve = (dir: string): any => {
return resolve(__dirname, '.', dir)
}
// https://vitejs.cn/config/
const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
const { VITE_PORT, VITE_OPEN, VITE_BASE_PATH, VITE_OUT_DIR } = loadEnv(mode, process.cwd())
const alias: Record<string, string> = {
'/@': pathResolve('./src/'),
assets: pathResolve('./src/assets'),
'vue-i18n': isProd(mode) ? 'vue-i18n/dist/vue-i18n.cjs.prod.js' : 'vue-i18n/dist/vue-i18n.cjs.js',
}
return {
plugins: [vue(), svgBuilder('./src/assets/icons/'), customHotUpdate()],
root: process.cwd(),
resolve: { alias },
base: VITE_BASE_PATH,
server: {
port: parseInt(VITE_PORT),
open: VITE_OPEN != 'false',
},
build: {
cssCodeSplit: false,
sourcemap: false,
outDir: VITE_OUT_DIR,
emptyOutDir: true,
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
// 分包配置,配置完成自动按需加载
vue: ['vue', 'vue-router', 'pinia', 'vue-i18n', 'element-plus'],
echarts: ['echarts'],
},
},
},
},
}
}
export default viteConfig