Browse Source

第一次提交

PC-7\Administrator 10 tháng trước cách đây
commit
a9e67b7e0b
100 tập tin đã thay đổi với 10479 bổ sung và 0 xóa
  1. 19 0
      .env-example
  2. 4 0
      .gitattributes
  3. 47 0
      .gitignore
  4. 941 0
      CHANGELOG.md
  5. 201 0
      LICENSE
  6. 147 0
      README.md
  7. 1 0
      app/.htaccess
  8. 22 0
      app/AppService.php
  9. 93 0
      app/BaseController.php
  10. 119 0
      app/ExceptionHandle.php
  11. 26 0
      app/Request.php
  12. 34 0
      app/admin/common.php
  13. 217 0
      app/admin/controller/Ajax.php
  14. 20 0
      app/admin/controller/Dashboard.php
  15. 133 0
      app/admin/controller/Index.php
  16. 137 0
      app/admin/controller/Module.php
  17. 261 0
      app/admin/controller/auth/Admin.php
  18. 54 0
      app/admin/controller/auth/AdminLog.php
  19. 379 0
      app/admin/controller/auth/Group.php
  20. 274 0
      app/admin/controller/auth/Rule.php
  21. 992 0
      app/admin/controller/crud/Crud.php
  22. 37 0
      app/admin/controller/crud/Log.php
  23. 90 0
      app/admin/controller/routine/AdminInfo.php
  24. 59 0
      app/admin/controller/routine/Attachment.php
  25. 246 0
      app/admin/controller/routine/Config.php
  26. 150 0
      app/admin/controller/security/DataRecycle.php
  27. 106 0
      app/admin/controller/security/DataRecycleLog.php
  28. 204 0
      app/admin/controller/security/SensitiveData.php
  29. 117 0
      app/admin/controller/security/SensitiveDataLog.php
  30. 163 0
      app/admin/controller/user/Group.php
  31. 50 0
      app/admin/controller/user/MoneyLog.php
  32. 260 0
      app/admin/controller/user/Rule.php
  33. 50 0
      app/admin/controller/user/ScoreLog.php
  34. 156 0
      app/admin/controller/user/User.php
  35. 16 0
      app/admin/event.php
  36. 38 0
      app/admin/lang/en.php
  37. 9 0
      app/admin/lang/en/ajax.php
  38. 5 0
      app/admin/lang/en/auth/admin.php
  39. 6 0
      app/admin/lang/en/auth/group.php
  40. 6 0
      app/admin/lang/en/auth/menu.php
  41. 7 0
      app/admin/lang/en/crud/crud.php
  42. 4 0
      app/admin/lang/en/dashboard.php
  43. 9 0
      app/admin/lang/en/index.php
  44. 6 0
      app/admin/lang/en/routine/admininfo.php
  45. 5 0
      app/admin/lang/en/routine/attachment.php
  46. 23 0
      app/admin/lang/en/routine/config.php
  47. 7 0
      app/admin/lang/en/security/datarecycle.php
  48. 4 0
      app/admin/lang/en/security/datarecyclelog.php
  49. 8 0
      app/admin/lang/en/security/sensitivedata.php
  50. 4 0
      app/admin/lang/en/security/sensitivedatalog.php
  51. 8 0
      app/admin/lang/en/user/moneylog.php
  52. 8 0
      app/admin/lang/en/user/scorelog.php
  53. 62 0
      app/admin/lang/zh-cn.php
  54. 12 0
      app/admin/lang/zh-cn/ajax.php
  55. 6 0
      app/admin/lang/zh-cn/auth/admin.php
  56. 13 0
      app/admin/lang/zh-cn/auth/group.php
  57. 6 0
      app/admin/lang/zh-cn/auth/rule.php
  58. 11 0
      app/admin/lang/zh-cn/crud/crud.php
  59. 4 0
      app/admin/lang/zh-cn/dashboard.php
  60. 9 0
      app/admin/lang/zh-cn/index.php
  61. 29 0
      app/admin/lang/zh-cn/module.php
  62. 6 0
      app/admin/lang/zh-cn/routine/admininfo.php
  63. 5 0
      app/admin/lang/zh-cn/routine/attachment.php
  64. 25 0
      app/admin/lang/zh-cn/routine/config.php
  65. 8 0
      app/admin/lang/zh-cn/security/datarecycle.php
  66. 4 0
      app/admin/lang/zh-cn/security/datarecyclelog.php
  67. 9 0
      app/admin/lang/zh-cn/security/sensitivedata.php
  68. 4 0
      app/admin/lang/zh-cn/security/sensitivedatalog.php
  69. 8 0
      app/admin/lang/zh-cn/user/moneylog.php
  70. 8 0
      app/admin/lang/zh-cn/user/scorelog.php
  71. 525 0
      app/admin/library/Auth.php
  72. 1291 0
      app/admin/library/crud/Helper.php
  73. 63 0
      app/admin/library/crud/stubs/html/form.stub
  74. 69 0
      app/admin/library/crud/stubs/html/index.stub
  75. 24 0
      app/admin/library/crud/stubs/mixins/controller/controller.stub
  76. 32 0
      app/admin/library/crud/stubs/mixins/controller/index.stub
  77. 6 0
      app/admin/library/crud/stubs/mixins/controller/initialize.stub
  78. 12 0
      app/admin/library/crud/stubs/mixins/model/afterInsert.stub
  79. 5 0
      app/admin/library/crud/stubs/mixins/model/beforeInsert.stub
  80. 5 0
      app/admin/library/crud/stubs/mixins/model/belongsTo.stub
  81. 7 0
      app/admin/library/crud/stubs/mixins/model/getters/cityNames.stub
  82. 5 0
      app/admin/library/crud/stubs/mixins/model/getters/float.stub
  83. 5 0
      app/admin/library/crud/stubs/mixins/model/getters/htmlDecode.stub
  84. 5 0
      app/admin/library/crud/stubs/mixins/model/getters/jsonDecode.stub
  85. 7 0
      app/admin/library/crud/stubs/mixins/model/getters/remoteSelectLabels.stub
  86. 5 0
      app/admin/library/crud/stubs/mixins/model/getters/string.stub
  87. 9 0
      app/admin/library/crud/stubs/mixins/model/getters/stringToArray.stub
  88. 2 0
      app/admin/library/crud/stubs/mixins/model/mixins/beforeInsertWithSnowflake.stub
  89. 18 0
      app/admin/library/crud/stubs/mixins/model/model.stub
  90. 5 0
      app/admin/library/crud/stubs/mixins/model/setters/arrayToString.stub
  91. 5 0
      app/admin/library/crud/stubs/mixins/model/setters/time.stub
  92. 31 0
      app/admin/library/crud/stubs/mixins/validate/validate.stub
  93. 966 0
      app/admin/library/module/Manage.php
  94. 590 0
      app/admin/library/module/Server.php
  95. 24 0
      app/admin/library/stubs/backendEntrance.stub
  96. 301 0
      app/admin/library/traits/Backend.php
  97. 6 0
      app/admin/middleware.php
  98. 72 0
      app/admin/model/Admin.php
  99. 13 0
      app/admin/model/AdminGroup.php
  100. 160 0
      app/admin/model/AdminLog.php

+ 19 - 0
.env-example

@@ -0,0 +1,19 @@
+APP_DEBUG = true
+
+[APP]
+DEFAULT_TIMEZONE = Asia/Shanghai
+
+[LANG]
+default_lang = zh-cn
+
+
+[DATABASE]
+TYPE = mysql
+HOSTNAME = 127.0.0.1
+DATABASE = user
+USERNAME = root
+PASSWORD = root
+HOSTPORT = 3306
+PREFIX = ba_
+CHARSET = utf8mb4
+DEBUG = true

+ 4 - 0
.gitattributes

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

+ 47 - 0
.gitignore

@@ -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

+ 941 - 0
CHANGELOG.md

@@ -0,0 +1,941 @@
+### [BuildAdmin 更新日志](https://gitee.com/wonderful-code/buildadmin)
+
+🔥🔥基于 Vue3.x + ThinkPHP8 + TypeScript + Vite + Pinia + Element Plus 等流行技术栈的后台管理系统,支持常驻内存运行、可视化CRUD代码生成、自带WEB终端、自适应多端、同时提供Web、WebNuxt、Server端、内置全局数据回收站和字段级数据修改保护、自动注册路由、无限子级权限管理等,无需授权即可免费商用,希望能帮助大家实现快速开发。
+
+## 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:修复编辑关联字段时可能不会更新表字段的问题
+- 可视化CRUD:MySQL 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 - 0
LICENSE

@@ -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.

+ 147 - 0
README.md

@@ -0,0 +1,147 @@
+<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)
+- [官方邮箱 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 - 0
app/.htaccess

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

+ 22 - 0
app/AppService.php

@@ -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 - 0
app/BaseController.php

@@ -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 - 0
app/ExceptionHandle.php

@@ -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 - 0
app/Request.php

@@ -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 - 0
app/admin/common.php

@@ -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',
+            ]
+        ]);
+    }
+}

+ 217 - 0
app/admin/controller/Ajax.php

@@ -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();
+    }
+}

+ 20 - 0
app/admin/controller/Dashboard.php

@@ -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()
+        ]);
+    }
+}

+ 133 - 0
app/admin/controller/Index.php

@@ -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();
+        }
+    }
+}

+ 137 - 0
app/admin/controller/Module.php

@@ -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
+        ]);
+    }
+}

+ 261 - 0
app/admin/controller/auth/Admin.php

@@ -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!'));
+            }
+        }
+    }
+}

+ 54 - 0
app/admin/controller/auth/AdminLog.php

@@ -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(),
+        ]);
+    }
+}

+ 379 - 0
app/admin/controller/auth/Group.php

@@ -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~'));
+        }
+    }
+
+}

+ 274 - 0
app/admin/controller/auth/Rule.php

@@ -0,0 +1,274 @@
+<?php
+
+namespace app\admin\controller\auth;
+
+use ba\Tree;
+use Throwable;
+use app\admin\model\AdminRule;
+use app\admin\model\AdminGroup;
+use app\common\controller\Backend;
+
+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'])) {
+                    $groups = AdminGroup::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();
+    }
+
+    /**
+     * 重写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;
+    }
+}

+ 992 - 0
app/admin/controller/crud/Crud.php

@@ -0,0 +1,992 @@
+<?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]['table']['render'] = 'tags';
+                if ($field['designType'] == 'remoteSelects') {
+                    $columns[$relationField]['table']['operator'] = 'false';
+                    $this->indexVueData['tableColumn'][]          = $this->getTableColumn($columns[$relationField], $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
+
+                    // 额外生成一个公共搜索,渲染为远程下拉的列
+                    unset($columns[$relationField]['table']['render']);
+                    $columns[$relationField]['table']['label']           = "t('" . $this->webTranslate . $relationFieldLangPrefix . $columns[$relationField]['name'] . "')";
+                    $columns[$relationField]['name']                     = $field['name'];
+                    $columns[$relationField]['table']['show']            = 'false';
+                    $columns[$relationField]['table']['operator']        = 'FIND_IN_SET';
+                    $columns[$relationField]['table']['comSearchRender'] = 'remoteSelect';
+
+                    $columns[$relationField]['table']['remote'] = [
+                        'pk'        => $this->getRemoteSelectPk($field),
+                        'field'     => $field['form']['remote-field'] ?? 'name',
+                        'remoteUrl' => $this->getRemoteSelectUrl($field),
+                        'multiple'  => 'true',
+                    ];
+                    $this->indexVueData['tableColumn'][]        = $this->getTableColumn($columns[$relationField], $columnDict, '', $relationFieldLangPrefix);
+                } else {
+                    $columns[$relationField]['table']['operator'] = 'LIKE';
+                    $this->indexVueData['tableColumn'][]          = $this->getTableColumn($columns[$relationField], $columnDict, $relationFieldPrefix, $relationFieldLangPrefix);
+                }
+            }
+        }
+        $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 (isset($field['table']) && $field['table']) {
+            $column = array_merge($column, $field['table']);
+        }
+
+        // 需要值替换的渲染类型
+        $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;
+    }
+}

+ 37 - 0
app/admin/controller/crud/Log.php

@@ -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);
+        }
+    }
+
+}

+ 90 - 0
app/admin/controller/routine/AdminInfo.php

@@ -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'));
+            }
+        }
+    }
+}

+ 59 - 0
app/admin/controller/routine/Attachment.php

@@ -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'));
+        }
+    }
+}

+ 246 - 0
app/admin/controller/routine/Config.php

@@ -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~'));
+    }
+}

+ 150 - 0
app/admin/controller/security/DataRecycle.php

@@ -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;
+    }
+}

+ 106 - 0
app/admin/controller/security/DataRecycleLog.php

@@ -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;
+    }
+}

+ 204 - 0
app/admin/controller/security/SensitiveData.php

@@ -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;
+    }
+}

+ 117 - 0
app/admin/controller/security/SensitiveDataLog.php

@@ -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'));
+        }
+    }
+}

+ 163 - 0
app/admin/controller/user/Group.php

@@ -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;
+    }
+}

+ 50 - 0
app/admin/controller/user/MoneyLog.php

@@ -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
+        ]);
+    }
+}

+ 260 - 0
app/admin/controller/user/Rule.php

@@ -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;
+    }
+
+}

+ 50 - 0
app/admin/controller/user/ScoreLog.php

@@ -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
+        ]);
+    }
+}

+ 156 - 0
app/admin/controller/user/User.php

@@ -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 - 0
app/admin/event.php

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

+ 38 - 0
app/admin/lang/en.php

@@ -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.',
+];

+ 9 - 0
app/admin/lang/en/ajax.php

@@ -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',
+];

+ 5 - 0
app/admin/lang/en/auth/admin.php

@@ -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!',
+];

+ 6 - 0
app/admin/lang/en/auth/group.php

@@ -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!',
+];

+ 6 - 0
app/admin/lang/en/auth/menu.php

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

+ 7 - 0
app/admin/lang/en/crud/crud.php

@@ -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',
+];

+ 4 - 0
app/admin/lang/en/dashboard.php

@@ -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!",
+];

+ 9 - 0
app/admin/lang/en/index.php

@@ -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',
+];

+ 6 - 0
app/admin/lang/en/routine/admininfo.php

@@ -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!',
+];

+ 5 - 0
app/admin/lang/en/routine/attachment.php

@@ -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!',
+];

+ 23 - 0
app/admin/lang/en/routine/config.php

@@ -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.',
+];

+ 7 - 0
app/admin/lang/en/security/datarecycle.php

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

+ 4 - 0
app/admin/lang/en/security/datarecyclelog.php

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

+ 8 - 0
app/admin/lang/en/security/sensitivedata.php

@@ -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',
+];

+ 4 - 0
app/admin/lang/en/security/sensitivedatalog.php

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

+ 8 - 0
app/admin/lang/en/user/moneylog.php

@@ -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',
+];

+ 8 - 0
app/admin/lang/en/user/scorelog.php

@@ -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 - 0
app/admin/lang/zh-cn.php

@@ -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'                                                                    => '登录态过期,请重新登录!',
+];

+ 12 - 0
app/admin/lang/zh-cn/ajax.php

@@ -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'                                                                     => '数据表不存在~',
+];

+ 6 - 0
app/admin/lang/zh-cn/auth/admin.php

@@ -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!'            => '您没有权限向此分组添加管理员!',
+];

+ 13 - 0
app/admin/lang/zh-cn/auth/group.php

@@ -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'                                                                                                         => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
+];

+ 6 - 0
app/admin/lang/zh-cn/auth/rule.php

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

+ 11 - 0
app/admin/lang/zh-cn/crud/crud.php

@@ -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'        => '加载云端数据失败,请稍后重试!',
+];

+ 4 - 0
app/admin/lang/zh-cn/dashboard.php

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

+ 9 - 0
app/admin/lang/zh-cn/index.php

@@ -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'                                                        => '注销登录',
+];

+ 29 - 0
app/admin/lang/zh-cn/module.php

@@ -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 存在冲突',
+];

+ 6 - 0
app/admin/lang/zh-cn/routine/admininfo.php

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

+ 5 - 0
app/admin/lang/zh-cn/routine/attachment.php

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

+ 25 - 0
app/admin/lang/zh-cn/routine/config.php

@@ -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'                                                                             => '后台入口请以 / 开头,且只包含数字和字母。',
+];

+ 8 - 0
app/admin/lang/zh-cn/security/datarecycle.php

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

+ 4 - 0
app/admin/lang/zh-cn/security/datarecyclelog.php

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

+ 9 - 0
app/admin/lang/zh-cn/security/sensitivedata.php

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

+ 4 - 0
app/admin/lang/zh-cn/security/sensitivedatalog.php

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

+ 8 - 0
app/admin/lang/zh-cn/user/moneylog.php

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

+ 8 - 0
app/admin/lang/zh-cn/user/scorelog.php

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

+ 525 - 0
app/admin/library/Auth.php

@@ -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;
+    }
+}

+ 1291 - 0
app/admin/library/crud/Helper.php

@@ -0,0 +1,1291 @@
+<?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
+     */
+    protected 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 = '';
+        foreach ($tableColumnList as $column) {
+            $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;
+        }
+    }
+
+}

+ 63 - 0
app/admin/library/crud/stubs/html/form.stub

@@ -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>

+ 69 - 0
app/admin/library/crud/stubs/html/index.stub

@@ -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>

+ 24 - 0
app/admin/library/crud/stubs/mixins/controller/controller.stub

@@ -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 中对应的方法至此进行重写
+     */
+}

+ 32 - 0
app/admin/library/crud/stubs/mixins/controller/index.stub

@@ -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(),
+        ]);
+    }

+ 6 - 0
app/admin/library/crud/stubs/mixins/controller/initialize.stub

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

+ 12 - 0
app/admin/library/crud/stubs/mixins/model/afterInsert.stub

@@ -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]]);
+            }
+        }
+    }

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/beforeInsert.stub

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

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/belongsTo.stub

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

+ 7 - 0
app/admin/library/crud/stubs/mixins/model/getters/cityNames.stub

@@ -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) : '';
+    }

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/getters/float.stub

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

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/getters/htmlDecode.stub

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

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/getters/jsonDecode.stub

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

+ 7 - 0
app/admin/library/crud/stubs/mixins/model/getters/remoteSelectLabels.stub

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

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/getters/string.stub

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

+ 9 - 0
app/admin/library/crud/stubs/mixins/model/getters/stringToArray.stub

@@ -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;
+    }

+ 2 - 0
app/admin/library/crud/stubs/mixins/model/mixins/beforeInsertWithSnowflake.stub

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

+ 18 - 0
app/admin/library/crud/stubs/mixins/model/model.stub

@@ -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%}
+}

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/setters/arrayToString.stub

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

+ 5 - 0
app/admin/library/crud/stubs/mixins/model/setters/time.stub

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

+ 31 - 0
app/admin/library/crud/stubs/mixins/validate/validate.stub

@@ -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' => [],
+    ];
+
+}

+ 966 - 0
app/admin/library/module/Manage.php

@@ -0,0 +1,966 @@
+<?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;
+    }
+
+    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->uid        = $uid;
+            $this->modulesDir = $this->installDir . $uid . DIRECTORY_SEPARATOR;
+        }
+    }
+
+    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->uid        = $info['uid'];
+        $this->modulesDir = $this->installDir . $info['uid'] . DIRECTORY_SEPARATOR;
+
+        $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;
+    }
+}

+ 590 - 0
app/admin/library/module/Server.php

@@ -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;
+        }
+    }
+}

+ 24 - 0
app/admin/library/stubs/backendEntrance.stub

@@ -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);

+ 301 - 0
app/admin/library/traits/Backend.php

@@ -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 - 0
app/admin/middleware.php

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

+ 72 - 0
app/admin/model/Admin.php

@@ -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' => '']);
+    }
+}

+ 13 - 0
app/admin/model/AdminGroup.php

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

+ 160 - 0
app/admin/model/AdminLog.php

@@ -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 $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);
+    }
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác