From 8cc08bcb8c0757b62c03a72c9a41c6b35c774b59 Mon Sep 17 00:00:00 2001 From: augushong Date: Thu, 26 Mar 2026 20:22:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/AGENTS.md | 20 + .agents/rules/.gitkeep | 0 .../skills/tp-controller-url-rules/SKILL.md | 1 + .agents/skills/ulthon-admin-menu-cli/SKILL.md | 86 ++++ .../skills/ulthon-auth-session-token/SKILL.md | 0 .../ulthon-base-app-architecture/SKILL.md | 95 ++++ .agents/skills/ulthon-cli-reference/SKILL.md | 78 +++ .../ulthon-core-extend-pattern/SKILL.md | 45 ++ .../skills/ulthon-db-tools-debug/SKILL.md | 0 .../skills/ulthon-page-api-dual-mode/SKILL.md | 48 ++ .agents/skills/ulthon-permission-cli/SKILL.md | 106 +++++ .../ulthon-scheme-curd-workflow/SKILL.md | 21 + .../skills/ulthon-scheme-definition/SKILL.md | 1 + .agents/skills/ulthon-timer/SKILL.md | 140 ++++++ .../skills/ulthon-tools-http-call/SKILL.md | 60 +++ .gitignore | 3 +- .trae/rules/project_rules.md | 28 -- .../ulthon-core-extend-pattern/SKILL.md | 33 -- .../skills/ulthon-page-api-dual-mode/SKILL.md | 32 -- AGENTS.md | 206 +++++++- CODERULE.md | 141 ------ app/admin/controller/system/Config.php | 2 - app/admin/model/SystemConfig.php | 1 + app/admin/scheme/MallCate.php | 4 +- app/admin/scheme/MallGoods.php | 14 +- app/admin/scheme/MallTag.php | 2 +- app/admin/scheme/SystemAdmin.php | 4 +- app/admin/scheme/SystemAuth.php | 5 +- app/admin/scheme/SystemAuthNode.php | 6 +- app/admin/scheme/SystemConfig.php | 4 +- app/admin/scheme/SystemHost.php | 65 +++ app/admin/scheme/SystemMenu.php | 4 +- app/admin/scheme/SystemQuick.php | 4 +- app/admin/scheme/SystemUploadfile.php | 2 +- app/admin/scheme/TestGoods.php | 121 +++++ app/admin/scheme/TreeTree.php | 55 +++ app/admin/scheme/UlthonDemoGoods.php | 120 +++++ app/admin/scheme/UlthonDemoGoodsBatch.php | 52 ++ .../command/admin/menu/AdminMenuCreate.php | 14 + .../command/admin/menu/AdminMenuDelete.php | 14 + .../command/admin/menu/AdminMenuExport.php | 14 + .../command/admin/menu/AdminMenuList.php | 11 + .../command/admin/menu/AdminMenuUpdate.php | 14 + .../admin/permission/AdminPermissionNodes.php | 11 + .../admin/permission/PermissionUser.php | 11 + .../command/admin/role/AdminRoleCreate.php | 11 + .../command/admin/role/AdminRoleDelete.php | 11 + .../command/admin/role/AdminRoleInfo.php | 11 + .../command/admin/role/AdminRoleList.php | 11 + .../admin/role/AdminRolePermissionAssign.php | 11 + .../admin/role/AdminRolePermissionList.php | 11 + .../admin/role/AdminRolePermissionRevoke.php | 11 + .../admin/user/AdminUserRoleAssign.php | 11 + .../command/admin/user/AdminUserRoleList.php | 11 + .../admin/user/AdminUserRoleRevoke.php | 11 + .../command/tools/agent/ToolsAgentPublish.php | 10 + .../command/tools/http/ToolsHttpCall.php | 13 + .../command/tools/log/ToolsLogSearch.php | 9 + app/common/command/tools/log/ToolsLogShow.php | 9 + .../command/tools/log/ToolsLogStats.php | 9 + app/common/console/Command.php | 18 + app/common/service/tools/DbService.php | 15 + app/provider.php | 3 +- config/console.php | 24 - extend/base/admin/controller/AjaxBase.php | 7 +- extend/base/admin/controller/IndexBase.php | 6 +- .../admin/service/adminInitData/MallCate.php | 11 + .../admin/service/adminInitData/MallGoods.php | 11 + .../admin/service/adminInitData/MallTag.php | 11 + .../service/adminInitData/SystemAuth.php | 11 + .../service/adminInitData/SystemAuthNode.php | 11 + .../service/adminInitData/SystemConfig.php | 11 + .../service/adminInitData/SystemMenu.php | 11 + .../service/adminInitData/SystemQuick.php | 11 + .../service/adminUpdateCodeData/v2.0.74.php | 10 + .../service/curd/BuildCurdServiceBase.php | 30 ++ extend/base/common/command/CommandBase.php | 23 + extend/base/common/command/CurdBase.php | 75 ++- extend/base/common/command/OssStaticBase.php | 58 ++- extend/base/common/command/TestBase.php | 2 +- extend/base/common/command/TimerBase.php | 83 ++-- .../base/common/command/admin/ClearBase.php | 45 +- .../base/common/command/admin/UpdateBase.php | 9 +- .../base/common/command/admin/VersionBase.php | 5 +- .../admin/menu/AdminMenuCreateBase.php | 106 +++++ .../admin/menu/AdminMenuDeleteBase.php | 84 ++++ .../admin/menu/AdminMenuExportBase.php | 127 +++++ .../command/admin/menu/AdminMenuListBase.php | 132 ++++++ .../admin/menu/AdminMenuUpdateBase.php | 129 +++++ .../permission/AdminPermissionNodesBase.php | 99 ++++ .../permission/AdminPermissionUserBase.php | 95 ++++ .../admin/role/AdminRoleCreateBase.php | 53 +++ .../admin/role/AdminRoleDeleteBase.php | 63 +++ .../command/admin/role/AdminRoleInfoBase.php | 65 +++ .../command/admin/role/AdminRoleListBase.php | 64 +++ .../role/AdminRolePermissionAssignBase.php | 127 +++++ .../role/AdminRolePermissionListBase.php | 65 +++ .../role/AdminRolePermissionRevokeBase.php | 113 +++++ .../admin/user/AdminUserRoleAssignBase.php | 121 +++++ .../admin/user/AdminUserRoleListBase.php | 91 ++++ .../admin/user/AdminUserRoleRevokeBase.php | 117 +++++ .../base/common/command/curd/MigrateBase.php | 13 +- extend/base/common/command/scheme/Make.php | 24 +- extend/base/common/command/scheme/Sync.php | 81 +++- extend/base/common/command/tools/README.md | 96 ++-- .../tools/agent/ToolsAgentPublishBase.php | 277 +++++++++++ .../command/tools/db/ToolsDbCountBase.php | 32 +- .../command/tools/db/ToolsDbDescBase.php | 84 ++-- .../command/tools/db/ToolsDbExecuteBase.php | 41 +- .../command/tools/db/ToolsDbInfoBase.php | 96 +++- .../command/tools/db/ToolsDbQueryBase.php | 30 +- .../command/tools/db/ToolsDbTableBase.php | 27 +- .../command/tools/http/ToolsHttpCallBase.php | 445 ++++++++++++++++++ .../command/tools/log/ToolsLogSearchBase.php | 199 ++++++++ .../command/tools/log/ToolsLogShowBase.php | 195 ++++++++ .../command/tools/log/ToolsLogStatsBase.php | 259 ++++++++++ extend/base/common/console/OutputBase.php | 2 +- .../common/controller/AdminControllerBase.php | 38 ++ extend/base/common/scheme/attribute/Field.php | 9 + .../base/common/service/ErrorHandlerBase.php | 326 +++++++++++++ .../base/common/service/MenuServiceBase.php | 194 ++++++++ .../service/scheme/SchemeToDbService.php | 144 +++++- .../DbServiceBase.php} | 4 +- extend/base/helper.php | 29 +- extend/think/UlthonAdminService.php | 68 +++ extend/think/log/driver/DebugMysql.php | 53 ++- source/clients/uniapp/src/api/auth.js | 57 +++ source/clients/uniapp/src/api/permission.js | 101 ++++ source/clients/uniapp/src/api/user.js | 130 +++++ source/clients/uniapp/src/pages.json | 30 ++ .../uniapp/src/pages/permission/index.vue | 247 ++++++++++ .../clients/uniapp/src/pages/user/delete.vue | 233 +++++++++ .../clients/uniapp/src/pages/user/detail.vue | 257 ++++++++++ source/clients/uniapp/src/pages/user/edit.vue | 263 +++++++++++ source/clients/uniapp/src/pages/user/list.vue | 363 ++++++++++++++ source/clients/uniapp/src/types/index.ts | 131 ++++++ source/clients/uniapp/tsconfig.json | 25 + source/clients/uniapp/tsconfig.node.json | 11 + 138 files changed, 7964 insertions(+), 660 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 .agents/rules/.gitkeep rename {.trae => .agents}/skills/tp-controller-url-rules/SKILL.md (99%) create mode 100644 .agents/skills/ulthon-admin-menu-cli/SKILL.md rename {.trae => .agents}/skills/ulthon-auth-session-token/SKILL.md (100%) create mode 100644 .agents/skills/ulthon-base-app-architecture/SKILL.md create mode 100644 .agents/skills/ulthon-cli-reference/SKILL.md create mode 100644 .agents/skills/ulthon-core-extend-pattern/SKILL.md rename {.trae => .agents}/skills/ulthon-db-tools-debug/SKILL.md (100%) create mode 100644 .agents/skills/ulthon-page-api-dual-mode/SKILL.md create mode 100644 .agents/skills/ulthon-permission-cli/SKILL.md rename {.trae => .agents}/skills/ulthon-scheme-curd-workflow/SKILL.md (71%) rename {.trae => .agents}/skills/ulthon-scheme-definition/SKILL.md (99%) create mode 100644 .agents/skills/ulthon-timer/SKILL.md create mode 100644 .agents/skills/ulthon-tools-http-call/SKILL.md delete mode 100644 .trae/rules/project_rules.md delete mode 100644 .trae/skills/ulthon-core-extend-pattern/SKILL.md delete mode 100644 .trae/skills/ulthon-page-api-dual-mode/SKILL.md delete mode 100644 CODERULE.md create mode 100644 app/admin/scheme/SystemHost.php create mode 100644 app/admin/scheme/TestGoods.php create mode 100644 app/admin/scheme/TreeTree.php create mode 100644 app/admin/scheme/UlthonDemoGoods.php create mode 100644 app/admin/scheme/UlthonDemoGoodsBatch.php create mode 100644 app/common/command/admin/menu/AdminMenuCreate.php create mode 100644 app/common/command/admin/menu/AdminMenuDelete.php create mode 100644 app/common/command/admin/menu/AdminMenuExport.php create mode 100644 app/common/command/admin/menu/AdminMenuList.php create mode 100644 app/common/command/admin/menu/AdminMenuUpdate.php create mode 100644 app/common/command/admin/permission/AdminPermissionNodes.php create mode 100644 app/common/command/admin/permission/PermissionUser.php create mode 100644 app/common/command/admin/role/AdminRoleCreate.php create mode 100644 app/common/command/admin/role/AdminRoleDelete.php create mode 100644 app/common/command/admin/role/AdminRoleInfo.php create mode 100644 app/common/command/admin/role/AdminRoleList.php create mode 100644 app/common/command/admin/role/AdminRolePermissionAssign.php create mode 100644 app/common/command/admin/role/AdminRolePermissionList.php create mode 100644 app/common/command/admin/role/AdminRolePermissionRevoke.php create mode 100644 app/common/command/admin/user/AdminUserRoleAssign.php create mode 100644 app/common/command/admin/user/AdminUserRoleList.php create mode 100644 app/common/command/admin/user/AdminUserRoleRevoke.php create mode 100644 app/common/command/tools/agent/ToolsAgentPublish.php create mode 100644 app/common/command/tools/http/ToolsHttpCall.php create mode 100644 app/common/command/tools/log/ToolsLogSearch.php create mode 100644 app/common/command/tools/log/ToolsLogShow.php create mode 100644 app/common/command/tools/log/ToolsLogStats.php create mode 100644 app/common/console/Command.php create mode 100644 app/common/service/tools/DbService.php create mode 100644 extend/base/common/command/CommandBase.php create mode 100644 extend/base/common/command/admin/menu/AdminMenuCreateBase.php create mode 100644 extend/base/common/command/admin/menu/AdminMenuDeleteBase.php create mode 100644 extend/base/common/command/admin/menu/AdminMenuExportBase.php create mode 100644 extend/base/common/command/admin/menu/AdminMenuListBase.php create mode 100644 extend/base/common/command/admin/menu/AdminMenuUpdateBase.php create mode 100644 extend/base/common/command/admin/permission/AdminPermissionNodesBase.php create mode 100644 extend/base/common/command/admin/permission/AdminPermissionUserBase.php create mode 100644 extend/base/common/command/admin/role/AdminRoleCreateBase.php create mode 100644 extend/base/common/command/admin/role/AdminRoleDeleteBase.php create mode 100644 extend/base/common/command/admin/role/AdminRoleInfoBase.php create mode 100644 extend/base/common/command/admin/role/AdminRoleListBase.php create mode 100644 extend/base/common/command/admin/role/AdminRolePermissionAssignBase.php create mode 100644 extend/base/common/command/admin/role/AdminRolePermissionListBase.php create mode 100644 extend/base/common/command/admin/role/AdminRolePermissionRevokeBase.php create mode 100644 extend/base/common/command/admin/user/AdminUserRoleAssignBase.php create mode 100644 extend/base/common/command/admin/user/AdminUserRoleListBase.php create mode 100644 extend/base/common/command/admin/user/AdminUserRoleRevokeBase.php create mode 100644 extend/base/common/command/tools/agent/ToolsAgentPublishBase.php create mode 100644 extend/base/common/command/tools/http/ToolsHttpCallBase.php create mode 100644 extend/base/common/command/tools/log/ToolsLogSearchBase.php create mode 100644 extend/base/common/command/tools/log/ToolsLogShowBase.php create mode 100644 extend/base/common/command/tools/log/ToolsLogStatsBase.php create mode 100644 extend/base/common/service/ErrorHandlerBase.php rename extend/base/common/service/{ToolsDbServiceBase.php => tools/DbServiceBase.php} (99%) create mode 100644 source/clients/uniapp/src/api/auth.js create mode 100644 source/clients/uniapp/src/api/permission.js create mode 100644 source/clients/uniapp/src/api/user.js create mode 100644 source/clients/uniapp/src/pages/permission/index.vue create mode 100644 source/clients/uniapp/src/pages/user/delete.vue create mode 100644 source/clients/uniapp/src/pages/user/detail.vue create mode 100644 source/clients/uniapp/src/pages/user/edit.vue create mode 100644 source/clients/uniapp/src/pages/user/list.vue create mode 100644 source/clients/uniapp/src/types/index.ts create mode 100644 source/clients/uniapp/tsconfig.json create mode 100644 source/clients/uniapp/tsconfig.node.json diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..72d00b2 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,20 @@ +# 项目使用者补充规则(业务侧) + +本文件用于记录“使用框架的开发者(做业务)”在开发过程中补充的项目规则、团队偏好与临时约束。 + +## 定位与优先级 + +- 根目录 [AGENTS.md](../AGENTS.md) 的「项目级规则」为唯一权威;本文件不得覆盖或弱化其内容。 +- 本文件只记录**业务侧/项目侧**的增量约束(例如:交付格式、验证方式、代码生成偏好、团队约定等)。 +- 如发现需要变更框架基础规则,应由框架作者将规则整理并合并到根目录 `AGENTS.md` 的「项目级规则」或其他对应规则文件中。 + +## 维护方式(必须遵守) + +- 智能体以“使用框架的开发者”身份执行任务时,如发现需要记录或调整的项目约束,应更新到本文件,并可按开发者要求随时调整。 +- 智能体以“框架作者”身份开发时,如需调整本文件内容,必须先与开发者确认记录位置与写法,并按确认结果执行。 +- 规则应可执行、可复现、可验证;避免空泛口号。 + +## 记录模板(按条新增) + +| 日期 | 场景/触发 | 规则内容(可执行) | 影响范围 | 来源(文件/路径/命令) | 状态(临时/已合并) | +|---|---|---|---|---|---| diff --git a/.agents/rules/.gitkeep b/.agents/rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.trae/skills/tp-controller-url-rules/SKILL.md b/.agents/skills/tp-controller-url-rules/SKILL.md similarity index 99% rename from .trae/skills/tp-controller-url-rules/SKILL.md rename to .agents/skills/tp-controller-url-rules/SKILL.md index 0d979f6..ba0e033 100644 --- a/.trae/skills/tp-controller-url-rules/SKILL.md +++ b/.agents/skills/tp-controller-url-rules/SKILL.md @@ -50,3 +50,4 @@ ThinkPHP 8 默认开启了 URL 自动转换(`url_convert`)。大驼峰命名 - **统一风格**:在代码中使用大驼峰命名类和方法,但在前端请求、模板链接(如 `{:url('...')}`)中建议明确指向转换后的路径或使用系统助手函数自动生成。 - **路由定义**:对于复杂的 URL,建议在 `route/*.php` 中手动定义路由规则,以提供更友好的访问地址。 + diff --git a/.agents/skills/ulthon-admin-menu-cli/SKILL.md b/.agents/skills/ulthon-admin-menu-cli/SKILL.md new file mode 100644 index 0000000..1e7e4f1 --- /dev/null +++ b/.agents/skills/ulthon-admin-menu-cli/SKILL.md @@ -0,0 +1,86 @@ +--- +name: "ulthon-admin-menu-cli" +description: "解释并指导后台菜单(system_menu)与 admin:menu:* 命令的使用方式。需要导出菜单、通过命令行创建/更新/删除菜单、或排查菜单字段映射(pid/href/auth_node)时调用。" +--- + +# 菜单管理(admin:menu:* CLI) + +## 何时调用 + +- 需要导出当前系统菜单数据,用于备份/迁移/对比。 +- 需要在不进入后台页面的情况下,通过命令行创建/更新/删除菜单。 +- 需要排查菜单字段映射:`pid`/`href`/`auth_node` 与命令参数 `parent-id`/`path`/`node` 的对应关系。 +- 需要脚本化输出(JSON)对接自动化运维/测试(仅导出命令支持)。 + +## 命令清单 + +### 1) 导出菜单 + +```bash +php think admin:menu:export +``` + +常用参数: +- `--format=json`:JSON 输出(含 count/exported_at) +- `--output=<文件路径>`:把导出的菜单数组写入文件(JSON) + +示例: + +```bash +php think admin:menu:export --format=json +php think admin:menu:export --output=runtime/agent/menu_export.json +php think admin:menu:export --output=runtime/agent/menu_export.json --format=json +``` + +### 2) 创建菜单(会写入数据库) + +```bash +php think admin:menu:create --title="测试菜单" --path="demo.index/index" --icon="fa fa-list" --parent-id=0 --sort=100 +``` + +### 3) 更新菜单(会写入数据库) + +```bash +php think admin:menu:update --id=123 --title="新标题" --path="demo.index/index" --icon="fa fa-list" --parent-id=0 --sort=100 +``` + +说明: +- `--id` 必填;其余参数不传则不更新该字段。 +- `--path` 更新的是数据库字段 `href`。 +- `--parent-id` 更新的是数据库字段 `pid`。 + +### 4) 删除菜单(会写入数据库,软删除) + +```bash +php think admin:menu:delete --id=123 +``` + +说明: +- 若存在子菜单会拒绝删除(需先删除子菜单)。 + +## 字段映射速查 + +- `--parent-id` → `system_menu.pid` +- `--path` → `system_menu.href` +- `--node` → `system_menu.auth_node` + +## 典型工作流 + +### 工作流 A:备份菜单到文件 + +```bash +php think admin:menu:export --output=runtime/agent/menu_export.json +``` + +### 工作流 B:创建 → 更新 → 删除(用于验证命令链路) + +```bash +php think admin:menu:create --title="CLI测试菜单" --path="test.cli/index" --parent-id=0 +php think admin:menu:update --id=<上一步返回的id> --title="CLI测试菜单_改" +php think admin:menu:delete --id=<上一步返回的id> +``` + +## 注意事项 + +- `create/update/delete` 会实际修改数据库,建议在测试环境或确认无风险后执行。 +- 导出默认过滤 `delete_time=0`(只导出未删除菜单)。 diff --git a/.trae/skills/ulthon-auth-session-token/SKILL.md b/.agents/skills/ulthon-auth-session-token/SKILL.md similarity index 100% rename from .trae/skills/ulthon-auth-session-token/SKILL.md rename to .agents/skills/ulthon-auth-session-token/SKILL.md diff --git a/.agents/skills/ulthon-base-app-architecture/SKILL.md b/.agents/skills/ulthon-base-app-architecture/SKILL.md new file mode 100644 index 0000000..af9652e --- /dev/null +++ b/.agents/skills/ulthon-base-app-architecture/SKILL.md @@ -0,0 +1,95 @@ +--- +name: "ulthon-base-app-architecture" +description: "详细说明了 Base/App 双层架构的设计理念、三层结构、身份职责、目录映射、扩展模式及调用红线,帮助开发者理解框架结构并避免误操作。" +--- + +# Base/App 双层架构 + +本架构旨在解决**框架内核升级**与**业务代码定制**之间的矛盾。为了清晰起见,请根据您的身份阅读对应部分。 + +## 🎭 角色定位 + +| 你的身份 | 你的主要工作 | 请关注章节 | +| :--- | :--- | :--- | +| **框架使用者** | 开发具体业务功能、使用框架内置能力 | 👉 [一、我是框架使用者(做业务)](#一我是框架使用者做业务) | +| **框架作者** | 维护框架内核、修复 Bug、新增通用组件 | 👉 [二、我是框架作者(修内核)](#二我是框架作者修内核) | + +--- + +## 一、我是框架使用者(做业务) + +### 1. 你的地盘与禁区 +- ✅ **自由开发区**:除了 `extend/base/` 之外的所有目录。虽然业务代码通常推荐放在 `app/` 下,但你完全可以在项目中自由发挥。 +- ❌ **绝对禁区**:`extend/base/` 目录。**严禁修改**这里的文件,因为 `php think admin:update` 会无情覆盖它。 + +### 2. 开发场景指南 + +#### 场景 A:新增全新的业务功能 +直接在 `app/` 下创建控制器、模型或服务即可。不需要关心 Base 层,也不需要继承任何 Base 类(除非你需要利用框架基类的功能)。 + +#### 场景 B:修改/扩展框架内置功能 +如果你对框架默认的某个功能(如登录逻辑、CURD流程)不满意,请按以下步骤操作: + +1. **定位**:找到该功能对应的 `app/` 入口类(例如 `app/admin/model/SystemAdmin.php`)。 +2. **重写**:在该类中重写你需要修改的方法。 + - 保持方法签名(参数、返回值)一致。 + - 可以使用 `parent::method()` 复用父类逻辑。 +3. **生效**:系统会自动调用你的类,而不是底层的 Base 类。 + +### 3. 更新机制与保障 +执行 `php think admin:update` 时,系统会检查框架所有文件的更新: +- **Base 层(extend/base/)**:默认为 **覆盖更新**(Always Yes),因为这里是内核。 +- **App 层(app/)及其他**:默认为 **保护模式**(Default No)。命令会提示即将变动(新增/修改/删除)的文件列表。 + - **推荐操作**:一般选择 **跳过**,随后根据实际业务代码与上游更新进行对比,手动合并差异。 + - **替代操作**:也可选择 **覆盖**,更新后通过 Git 查看差异,并恢复不应变动的业务代码。 + +--- + +## 二、我是框架作者(修内核) + +### 1. 你的地盘与职责 +- ✅ **你的地盘**:`extend/base/` 目录。通用逻辑、基类代码写在这里。 +- 📝 **你的义务**:每在 Base 层新增一个类(如 `UserBase`),**必须**在 `app/` 层提供对应的入口类(如 `User`),并让入口类继承 Base 类。 + +### 2. 开发关键原则(依赖倒置) + +为了保证使用者的重写能生效,你必须遵守以下**铁律**: + +> **❌ 严禁直接调用 Base 类** +> 无论在何处,禁止写 `new UserBase()` 或 `UserBase::find()`。 + +> **✅ 必须调用 App 入口类** +> 必须写 `new \app\...\User()`。 + +**为什么?** +如果代码直接调用了 `UserBase`,那么使用者在 `app/` 下重写的 `User` 类就变成了“摆设”,无法拦截逻辑。只有调用 `app/` 下的子类,多态机制才能生效,使用者才有机会改变世界。 + +### 3. 文件组织规范 +- **类文件**:以 `*Base.php` 结尾,放在 `extend/base/`。 +- **辅助函数**:放在 `extend/base/helper.php`,通过 `app/common.php` 引入。 +- **初始化数据**:放在 `extend/base/adminInitData/`。 + +--- + +## 三、架构参考资料 + +### 1. 三层结构图解 + +``` +ThinkPHP 框架层(底层基础设施) + ▲ + │ 继承/扩展 +ulthon_admin 内核层(extend/base/,框架作者维护) + ▲ + │ 继承/覆盖(依赖倒置:内核只调用 App 层入口) +App 应用层(app/,框架使用者维护) +``` + +### 2. 常见目录映射 + +| Base 层(内核实现) | App 层(调用入口) | +| :--- | :--- | +| `extend/base/admin/controller/system/AdminBase.php` | `app/admin/controller/system/Admin.php` | +| `extend/base/admin/model/SystemAdminBase.php` | `app/admin/model/SystemAdmin.php` | +| `extend/base/common/command/CurdBase.php` | `app/common/command/Curd.php` | +| `extend/base/common/service/SmsBase.php` | `app/common/service/Sms.php` | diff --git a/.agents/skills/ulthon-cli-reference/SKILL.md b/.agents/skills/ulthon-cli-reference/SKILL.md new file mode 100644 index 0000000..28040da --- /dev/null +++ b/.agents/skills/ulthon-cli-reference/SKILL.md @@ -0,0 +1,78 @@ +--- +name: "ulthon-cli-reference" +description: "Ulthon Admin 框架常用 CLI 命令速查手册,包括菜单管理、Scheme 同步与 CURD 生成等命令说明。" +--- + +# CLI 命令参考文档 + +本文档提供 Ulthon Admin 框架的常用 CLI 命令速查手册。 + +## 何时调用 + +- 当需要查找 Ulthon Admin 特定的 CLI 命令(如菜单管理、Scheme 同步、CURD 生成)及其参数时。 +- 当需要验证或调试 CLI 命令功能时。 + +## 关键原则 + +- 菜单管理命令用于操作后台菜单结构,支持 JSON/Tree 格式输出。 +- Scheme 与 CURD 命令用于保持数据库与代码的一致性,以及生成基础代码。 +- CURD 操作建议先使用 `-r` 生成到临时目录进行预览。 +- Scheme 同步操作涉及数据库变更,需谨慎使用。 + +## 菜单管理 + +| 命令 | 说明 | 常用参数 | +|------|------|----------| +| `admin:menu:list` | 列出菜单(树形) | `--format`, `--status`, `--pid` | +| `admin:menu:create` | 创建菜单 | - | +| `admin:menu:update` | 编辑菜单 | - | +| `admin:menu:delete` | 删除菜单 | - | +| `admin:menu:export` | 导出菜单数据 | `--format=json` | + +## Scheme & CURD + +| 命令 | 说明 | 常用参数 | +|------|------|----------| +| `scheme:sync` | 同步 Scheme 到数据库 | `--force` (跳过确认) | +| `scheme:make` | 从数据库生成 Scheme | `-t {table}` | +| `curd` | 生成 CURD 代码 | `-t {table}` `-r` (临时) `-f` (强制) `-d` (删除) | + +### CURD 参数说明 + +| 参数 | 简写 | 说明 | +|------|------|------| +| `--table` | `-t` | 主表名(支持带前缀或不带前缀) | +| `--force` | `-f` | 强制覆盖模式(**谨慎使用**) | +| `--delete` | `-d` | 删除模式(**删除生成的文件,不是数据库操作**) | +| `--runtime` | `-r` | 临时生成模式(**推荐用于预览**) | +| `--examples` | | 显示使用示例 | + +## 快速示例 + +### 菜单管理 + +```bash +php think admin:menu:list # 查看菜单树 +php think admin:menu:create # 创建菜单 +php think admin:menu:export --format=json # 导出菜单 +``` + +### CURD 操作 + +```bash +# 预览生成(推荐) +php think curd -t daka_record -r + +# 正式生成 +php think curd -t daka_record + +# 强制覆盖(小心) +php think curd -t daka_record -f +``` + +## 注意事项 + +1. **表名参数**:CURD 和 Scheme 命令的表名支持带前缀或不带前缀(v2.x+ 自动检测) +2. **安全确认**:`scheme:sync` 默认需要确认,使用 `-ff` 跳过 +3. **临时生成**:CURD 使用 `-r` 可生成到临时目录预览 +4. **删除模式**:CURD 的 `-d` 是删除生成的文件,不是数据库操作 diff --git a/.agents/skills/ulthon-core-extend-pattern/SKILL.md b/.agents/skills/ulthon-core-extend-pattern/SKILL.md new file mode 100644 index 0000000..dfcc48b --- /dev/null +++ b/.agents/skills/ulthon-core-extend-pattern/SKILL.md @@ -0,0 +1,45 @@ +--- +name: "ulthon-core-extend-pattern" +description: "业务开发默认只写 app;需要改内置能力时,通过 app 入口类覆盖 *Base 默认实现。" +--- + +# 扩展内置能力(继承与重写) + +## 何时调用 + +- 需要新增业务能力(新控制器/模型/服务/命令等):直接在 `app/` 写代码。 +- 需要扩展/调整系统已有能力(默认实现存在于 `extend/base/`):在 `app/` 下对应入口类重写以覆盖默认实现。 + +## 关键原则 + +- 新写业务代码:只写 `app/`,不需要、也不应该去 `extend/base/` 做任何“对齐”。 +- 改内置能力:只改 `app/` 下对应入口类(必要时 `extends` 对应 `*Base`)。 +- 严禁直接修改 `extend/base/` 下任何文件。 +- 重写时保持方法签名一致;可用 `parent::method()` 复用父类逻辑,或复制父类代码后自行实现。 + +## 操作流程 + +### A. 新写业务能力(推荐默认路径) + +1. 直接在 `app/` 下创建需要的控制器/模型/服务/命令等类文件(按项目命名规范与目录约定放置)。 +2. 让业务入口只依赖 `app/` 下的类,不需要为“对应 Base 层文件”做额外映射。 + +### B. 覆盖框架默认行为(扩展/调整已有能力) + +1. 定位对应的 `*Base` 默认实现文件路径(通常在 `extend/base/`)。 +2. 在 `app/` 下创建或修改同路径的入口类文件,并 `extends` 对应 `*Base`。 +3. 在入口类中重写需要调整的方法,确保方法签名一致。 + +## 常见误区(避免歧义) + +- 误区:新增业务功能时,也要先在 `extend/base/` 建一个 `*Base` 再在 `app/` 继承 + 结论:不需要;这是框架内核维护模式,不是业务开发默认模式 +- 误区:看到框架存在 Base/App 双层机制,就认为所有类都必须走“入口类” + 结论:入口类主要用于“覆盖框架默认实现”,纯业务类可以直接使用,不需要额外包装 + +## 常见映射示例 + +- `extend/base/admin/controller/system/AdminBase.php` → `app/admin/controller/system/Admin.php` +- `extend/base/admin/model/SystemAdminBase.php` → `app/admin/model/SystemAdmin.php` +- `extend/base/common/service/SmsBase.php` → `app/common/service/Sms.php` +- `extend/base/common/command/admin/role/AdminRoleCreateBase.php` → `app/common/command/admin/role/AdminRoleCreate.php` diff --git a/.trae/skills/ulthon-db-tools-debug/SKILL.md b/.agents/skills/ulthon-db-tools-debug/SKILL.md similarity index 100% rename from .trae/skills/ulthon-db-tools-debug/SKILL.md rename to .agents/skills/ulthon-db-tools-debug/SKILL.md diff --git a/.agents/skills/ulthon-page-api-dual-mode/SKILL.md b/.agents/skills/ulthon-page-api-dual-mode/SKILL.md new file mode 100644 index 0000000..69fd5d1 --- /dev/null +++ b/.agents/skills/ulthon-page-api-dual-mode/SKILL.md @@ -0,0 +1,48 @@ +--- +name: "ulthon-page-api-dual-mode" +description: "指导控制器实现“页面/接口同体”。需要同一路由同时返回 HTML 与 JSON 时调用。" +--- + +# 页面 / 接口同体(Controller Dual Mode) + +## 何时调用 + +- 希望同一个控制器方法既能渲染页面(HTML),也能作为接口返回 JSON。 +- 需要让现有页面接口在移动端/脚本调用时返回结构化数据。 + +## 触发与认证 + +- 触发:请求头 `Accept` 包含 `application/json`(框架使用 `request()->isJson()` 判断)。 +- 认证:Header 传 `Authorization: Bearer `。 + +## 实现要点 + +- 控制器方法必须调用 `$this->fetch()`,不要使用 `View::fetch()`。 +- 无论使用 `$this->assign()` 还是 `View::assign()`,其数据都会被转换为 JSON 返回。 +- 若不希望某个 assign 字段出现在 JSON 中,可使用: + +```php +$this->assign('name', 'value', -1); +``` + +## 特殊行为 + +- 这里存在两种“JSON 语义”,不要混淆: + - **接口模式(API)**:只需要 `Accept: application/json`。典型用法是 `index` 的表格分页数据、以及所有 `POST` 提交的 success/error JSON 返回。 + - **页面数据模式(Page Data)**:用于“拿页面 assign 的数据”(例如表单的下拉选项、默认值等),需要在 `Accept: application/json` 的基础上追加 `get_page_data=1`。 +- `get_page_data=1` 会强制让 `request()->isAjax()` 返回 false,从而避免 `index` 这类方法走“Ajax 分页数据分支”,转而执行 `$this->fetch()`;随后 `$this->fetch()` 会把 `View::fetchData()`(即 assign 的变量)打包成 `json_message()` 返回。 +- 当 URL 带 `get_page_data=1` 但请求头不含 `Accept: application/json` 时,框架会直接返回 JSON 错误提示,避免“看起来参数写了但返回了 HTML”的误解。 +- 仅在控制器模式(Controller Mode)生效;路由模式(Route Mode)不生效。 + +## 常见例子 + +- 获取列表分页数据(API):只加 `Accept: application/json`,不要带 `get_page_data` + - `GET /admin/system.admin/index` +- 获取页面 assign 数据(Page Data):`Accept: application/json` + `get_page_data=1` + - `GET /admin/system.menu/add?get_page_data=1` + - `GET /admin/system.admin/add?get_page_data=1` + +## 命令行联调建议 + +- `php think tools:http:call --app=admin --controller=system.admin --action=index`:默认按 API 语义调用(不再自动追加 `get_page_data=1`) +- `php think tools:http:call --app=admin --controller=system.admin --action=add --page-data`:获取该页面的 assign 数据(等价于追加 `get_page_data=1`) diff --git a/.agents/skills/ulthon-permission-cli/SKILL.md b/.agents/skills/ulthon-permission-cli/SKILL.md new file mode 100644 index 0000000..01e39e8 --- /dev/null +++ b/.agents/skills/ulthon-permission-cli/SKILL.md @@ -0,0 +1,106 @@ +--- +name: "ulthon-permission-cli" +description: "解释并指导 RBAC 权限体系与相关 CLI 命令(admin:role:* / admin:user:role:* / admin:permission:*)。用于查看节点、管理角色权限、给用户分配角色、排查权限问题。" +--- + +# 权限与角色管理(RBAC CLI) + +## 何时调用 + +- 需要查看系统当前有哪些权限节点。 +- 需要创建/查看/删除角色,或查看角色详情。 +- 需要为角色分配/撤回权限节点。 +- 需要给指定用户分配/撤回角色。 +- 需要排查“有菜单但无权限 / 有权限但仍被拦截 / 节点名称不一致”等权限问题。 + +## 概念速览 + +- 权限节点:基于控制器/方法注解生成的权限标识(用于授权与鉴权)。 +- 角色:权限节点的集合(`SystemAuth`)。 +- 用户:后台用户(`SystemAdmin`),通过 `auth_ids`(逗号分隔)绑定角色 ID 列表。 +- 角色-节点:`SystemAuthNode` 记录 `auth_id + node` 的多对多关系。 +- 鉴权入口:后台请求进入后会根据“当前节点”进行权限检查(未授权则拦截)。 +- 输出说明:权限相关命令默认以文本输出为主;如命令涉及交互确认,可使用 `--force-force` 或 `-ff` 跳过确认。 + +## 常用命令 + +### 1) 查看权限节点 + +```bash +php think admin:permission:nodes +``` + +常用参数(如需): +- `--module=<模块>`:按模块过滤 +- `--level=1|2`:按级别过滤(1=控制器,2=方法) + +### 2) 角色管理 + +```bash +php think admin:role:list +php think admin:role:info --role-id=12 +php think admin:role:create --title="测试角色" --remark="测试用" +php think admin:role:delete --role-id=12 +``` + +要点: +- 删除角色会检查该角色是否仍被用户分配;若仍有用户绑定,则删除会失败。 + +### 3) 角色权限管理 + +```bash +php think admin:role:permission:list --role-id=12 +php think admin:role:permission:assign --role-id=12 --nodes="system.admin/index,system.admin/add" +php think admin:role:permission:revoke --role-id=12 --nodes="system.admin/add" +``` + +要点: +- `--nodes` 支持逗号分隔批量操作。 +- 建议先用 `admin:permission:nodes` 确认节点名称完全一致后再分配到角色。 + +### 4) 用户角色管理 + +```bash +php think admin:user:role:list --user-id=1 +php think admin:user:role:assign --user-id=1 --role-ids="12,13" +php think admin:user:role:revoke --user-id=1 --role-ids="13" +``` + +### 5) 透视查询用户当前权限 + +```bash +php think admin:permission:user --user-id=1 +``` + +## 典型工作流 + +### 工作流 A:新增/调整注解后,核对节点并授权 + +1. 在控制器/方法上新增或调整权限注解(节点名称、标题、模块等)。 +2. 执行 `admin:permission:nodes`,确认节点列表中已出现新节点且名称符合预期。 +3. 确定要使用的角色(可通过 `admin:role:list` / `admin:role:info` 查看;没有合适角色就用 `admin:role:create` 新建)。 +4. 执行 `admin:role:permission:assign` 将新节点分配给角色。 +5. 执行 `admin:user:role:assign` 将角色分配给用户。 +6. 执行 `admin:permission:user` 透视确认该用户已拥有节点。 + +### 工作流 B:排查“明明有权限但仍被拦截” + +1. 执行 `admin:permission:user --user-id=`,确认用户是否真的拥有当前节点。 +2. 执行 `admin:permission:nodes`,确认“当前被拦截节点名”是否存在、是否有大小写/分隔符差异。 +3. 如果用户通过角色获得权限,执行 `admin:role:permission:list --role-id=` 核对角色是否包含该节点。 +4. 检查后台配置中是否存在免鉴权规则(例如 no_login/no_auth 的控制器/节点白名单)。 + +## 变更说明(重要) + +- 旧命令 `admin:permission:assign` / `admin:permission:revoke` 已移除。 +- “按用户直接增删节点”的需求,应通过“角色”承接: + - 只影响单个用户:创建专用角色 → 给该角色分配节点 → 将角色分配给该用户。 + - 影响一批用户:维护共享角色 → 给该角色增删节点 → 所有拥有该角色的用户一起生效。 + +## 常见坑位 + +- 节点名不一致:注解里写的节点名与实际鉴权使用的当前节点不一致,导致分配了权限但无法命中。 +- 只改了菜单没改权限:菜单能看到不代表有权限访问,权限检查以节点为准。 +- 缓存/环境差异:在不同环境中节点生成来源不一致时,先以 `admin:permission:nodes` 的输出为准核对。 +- 修改共享角色影响面过大:撤回/新增角色节点会影响所有拥有该角色的用户。 +- 删除角色失败:仍有用户绑定该角色,先用 `admin:user:role:revoke` 撤回后再删除。 diff --git a/.trae/skills/ulthon-scheme-curd-workflow/SKILL.md b/.agents/skills/ulthon-scheme-curd-workflow/SKILL.md similarity index 71% rename from .trae/skills/ulthon-scheme-curd-workflow/SKILL.md rename to .agents/skills/ulthon-scheme-curd-workflow/SKILL.md index d11b48a..647a0f5 100644 --- a/.trae/skills/ulthon-scheme-curd-workflow/SKILL.md +++ b/.agents/skills/ulthon-scheme-curd-workflow/SKILL.md @@ -16,13 +16,34 @@ description: "指导使用 Scheme 与 CURD 的标准开发流程。需要新增/ - 业务 Scheme 代码统一放在 `app/admin/scheme/`。 - 表结构设计遵循项目数据库规范:表名小写下划线、字段注释完整、避免 ENUM。 - CURD 命令中的 `{table}` 参数应为**不含前缀的下划线**格式(例如:数据库表 `ul_user_profile` 对应的参数为 `user_profile`)。 +- CURD 生成的页面脚本(`index.js` / `add.js` / `edit.js` / `read.js` / `_common.js`)默认与视图文件放在同一目录:`app/admin/view/<模块路径>/`,不提供“输出到其他 JS 目录”的配置项。 - 一旦你开始在生成代码上做业务改造,就应默认“正式目录不可被覆盖”,后续结构变更需要走“临时生成 + 按需合并”。 +## CURD 命令参数说明 + +| 参数 | 简写 | 说明 | +|------|------|------| +| `--table` | `-t` | 主表名(支持带前缀或不带前缀) | +| `--force` | `-f` | 强制覆盖模式(**谨慎使用**) | +| `--delete` | `-d` | 删除模式(**删除生成的文件,不是数据库操作**) | +| `--runtime` | `-r` | 临时生成模式(**推荐用于预览**) | + +### 参数使用建议 + +1. **首次生成**:直接使用 `-t` 生成到项目目录 +2. **已有业务代码**:使用 `-r` 生成到临时目录,对比并手动合并新增字段/逻辑 +3. **删除文件**:`-d` 用于清理已生成的文件,不影响数据库 + ## 推荐流程 ### A. 以代码为准(推荐) 1. 在 `app/admin/scheme/` 新建或修改对应表的 Scheme 类。 +2. 仅查看差异(不改库),确认当前 Scheme 与 DB 的不一致点: + +```bash +php think scheme:sync --dry-run +``` 2. 执行同步,将代码结构同步到数据库: ```bash diff --git a/.trae/skills/ulthon-scheme-definition/SKILL.md b/.agents/skills/ulthon-scheme-definition/SKILL.md similarity index 99% rename from .trae/skills/ulthon-scheme-definition/SKILL.md rename to .agents/skills/ulthon-scheme-definition/SKILL.md index 08c40e5..5641369 100644 --- a/.trae/skills/ulthon-scheme-definition/SKILL.md +++ b/.agents/skills/ulthon-scheme-definition/SKILL.md @@ -115,3 +115,4 @@ class YourClassName extends BaseScheme - `images`, `photos`, `icons`: 默认为多图片。 - `file`: 默认为单文件。 - `files`: 默认为多文件。 + diff --git a/.agents/skills/ulthon-timer/SKILL.md b/.agents/skills/ulthon-timer/SKILL.md new file mode 100644 index 0000000..f6c2ed3 --- /dev/null +++ b/.agents/skills/ulthon-timer/SKILL.md @@ -0,0 +1,140 @@ +--- +name: "ulthon-timer" +description: "内置秒级定时器(php think timer)的使用与扩展规范;用于新增/调整定时任务(site/call、并发分片、TimerController 防刷、timer.mode normal/parallel)。" +--- + +# timer(内置秒级定时器) + +## 核心机制(你要记住的 3 件事) + +1) 任务配置统一从 `app_file_path('common/command/timer/config.php')` 读取 +2) 每个配置会按 `concurrency` 自动展开成多份任务实例,并自动注入 `concurrency_id` / `concurrency_count` +3) “是否该执行”主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护) + +相关实现入口: + +- 系统入口(优先从 app 层理解): + - 定时器命令入口:`app/common/command/Timer.php`(实现继承自 `extend/base/common/command/TimerBase.php`) + - 任务实例服务入口:`app/common/service/TimerService.php`(实现继承自 `extend/base/common/service/TimerServiceBase.php`) + - site 任务控制器基类入口:`app/common/controller/TimerController.php`(实现继承自 `extend/base/common/controller/TimerControllerBase.php`) +- 实现文件(需要深入机制时再看 Base): + - [Timer.php](../../../app/common/command/Timer.php) / [TimerBase.php](../../../extend/base/common/command/TimerBase.php) + - [TimerService.php](../../../app/common/service/TimerService.php) / [TimerServiceBase.php](../../../extend/base/common/service/TimerServiceBase.php) + - [TimerController.php](../../../app/common/controller/TimerController.php) / [TimerControllerBase.php](../../../extend/base/common/controller/TimerControllerBase.php) + - 运行模式配置:[timer.php](../../../config/timer.php) + +## 新增定时任务(默认规则) + +无特殊情况下,新增定时任务应当通过本机制实现:在 `timer/config.php` 注册任务 + 以 `site` 或 `call` 的方式实现目标逻辑。 + +### 1) 选择任务类型 + +- `site`:通过 HTTP 访问一个控制器地址(默认优先使用)。即使任务需要“长时间运行”,也尽量设计成 `site` 模式(分片/分批/可重入),以复用框架的页面/接口同体、鉴权、日志、事务、限流等能力。 +- `call`:直接执行一个 PHP callable(不推荐;除非万不得已)。仅在确实不适合走 HTTP 上下文、且不希望暴露为控制器入口时使用。 + +长时间运行任务的推荐写法(仍用 `site`): + +- 设计为“可重入”的短任务:每次 `do()` 只处理一小批数据,处理进度写入缓存/表,下次继续 +- 结合 `concurrency` 做分片:按 `$this->concurrencyId` 划分数据范围,多个实例并行推进 +- 结合 `frequency` 控制节奏:用调度频率限制整体吞吐,避免单次占用过久 + +### 2) 编写任务目标(target) + +#### A. site 类型(HTTP 任务) + +实现方式建议: + +- 框架使用者(做业务):仅在 `app/tools/controller/timer/` 新增控制器 `*.php` 即可;不需要、也不应该在 `extend/base/` 增加 `*Base.php` +- 框架作者(维护内核):在 `extend/base/tools/controller/timer/` 新增 `*Base.php` 作为默认实现,同时在 `app/tools/controller/timer/` 增加同名入口类 `*.php` 继承 Base(系统唯一调用入口) +- 控制器建议继承 `app\common\controller\TimerController`(以获得并发参数校验与可选的 `$frequency` 防刷);执行入口一般提供 `do()` + +示例(已有实现可参考):[ClearLog.php](../../../app/tools/controller/timer/ClearLog.php)、[ClearLogBase.php](../../../extend/base/tools/controller/timer/ClearLogBase.php) + +并发与防刷建议: + +- 如需并发分片处理,在控制器中设置: + - `protected $concurrency = N;` + - 使用 `$this->concurrencyId` 做分片编号(0 ~ N-1) +- 如希望防止外部重复请求(不仅是定时器自身节流),在控制器中设置: + - `protected $frequency = 秒数;` + - 这会启用 `TimerControllerBase::protectVisit()` 基于 URL 的防刷限制 + +#### B. call 类型(函数任务) + +目标形态: + +- `target` 为 `call_user_func` 可执行的 callable,例如:`[SomeService::class, 'method']`、闭包等 +- 由于 Base/App 双层机制的入口要求,类引用应指向 `app/` 下的入口类(由入口类继承 Base 实现) +- 长时间/重任务不建议用 `call`:它缺少 HTTP 上下文与控制器层通用能力,排错与复用成本更高 + +### 3) 注册到任务配置(timer/config.php) + +默认配置文件位置(支持分层覆盖): + +- 优先覆盖:`app/common/command/timer/config.php`(一旦存在,`app_file_path(...)` 将优先读取此文件) +- 框架默认:`extend/base/common/command/timer/config.php`(当 app 未提供时回落到该文件) + +字段说明(兜底默认值由 Base 层的 `initConfigItem()` 提供): + +- `name`:任务唯一名称(用于 Cache key),不可重复 +- `type`:`site` 或 `call` +- `target`: + - `site`:以 `/` 开头的相对路径(建议指向 `tools/timer.*` 控制器的 `do` 方法) + - `call`:callable +- `frequency`:执行频率(秒),小于 0 会被修正为 0 +- `concurrency`:并发数量(默认 1)。`site` 类型会自动把并发参数写入 query +- `run_type`:保留字段(当前未参与调度逻辑) + +示例(site): + +```php +return [ + [ + 'name' => 'clear_log', + 'type' => 'site', + 'target' => '/tools/timer.ClearLog/do', + 'frequency' => 600, + 'concurrency' => 1, + ], +]; +``` + +示例(call): + +```php +return [ + [ + 'name' => 'system_host_register', + 'type' => 'call', + 'target' => [\app\common\service\HostService::class, 'heartbeat'], + 'frequency' => 30, + ], +]; +``` + +## 运行与验证 + +### 运行命令 + +- 常规运行:`php think timer` +- 只跑一轮(便于验证):`php think timer --temp` +- 无任务时不输出“no request”:`php think timer --quit` + +### 本地调试(指定请求 Host) + +site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_domain')` 读取。 + +- 本地调试:`php think timer --local --local-host=http://localhost --local-port=8000` + +### 运行模式 + +配置在 [timer.php](../../../config/timer.php): + +- `normal`:单进程循环 + Guzzle async(默认) +- `parallel`:Workerman 多进程模式(并发更高,相关连接参数在 `timer.php` 中) + +## 常见坑位(快速自检) + +- `name` 重复:会导致 Cache key 冲突,表现为任务“莫名其妙不跑/跑得不对” +- `concurrency` 与控制器侧 `$concurrency` 不一致:会触发 `concurrency id/count error` +- 只依赖控制器侧 `$frequency`:它只是防刷,不是调度;调度频率以定时器侧 Cache 节流为准 diff --git a/.agents/skills/ulthon-tools-http-call/SKILL.md b/.agents/skills/ulthon-tools-http-call/SKILL.md new file mode 100644 index 0000000..5f5f4c9 --- /dev/null +++ b/.agents/skills/ulthon-tools-http-call/SKILL.md @@ -0,0 +1,60 @@ +--- +name: "ulthon-tools-http-call" +description: "命令行调用后台控制器/URL 进行联调与功能验证(含默认 super token 模拟登录)。" +--- + +# tools:http:call(命令行 HTTP 调用工具) + +## 何时使用 + +- 需要在命令行快速验证某个后台页面/接口是否正常返回。 +- 需要绕过浏览器环境,在 CLI 中模拟已登录管理员请求。 +- 需要用 `--app/--controller/--action` 直接拼控制器路径调用。 + +## 基本用法 + +### 1) 直接按 URL 调用 + +```bash +php think tools:http:call --url="/admin/system.admin/index" --method=GET +``` + +### 2) 按控制器参数调用 + +```bash +php think tools:http:call --app=admin --controller=system.admin --action=index --method=GET +``` + +## 请求参数 + +- `--url`:请求路径(支持以 `/` 开头的相对路径)。 +- `--method`:GET/POST/PUT/PATCH/DELETE。 +- `--data`:JSON 字符串,请求会自动带 `Content-Type: application/json`。 +- `--body`:原始请求体字符串。 +- `--headers`:JSON 字符串的请求头对象(例如 `{ "Accept":"application/json" }`)。 +- `--app --controller --action`:框架特性参数,用于拼控制器路径。 + +## super token(默认模拟登录) + +### 机制说明 + +- `tools:http:call` 默认会为每次请求生成一个新的 token,并写入 `Cache::store('login')`。 +- 请求会自动携带 `Authorization: Bearer `。 +- 服务端会按既有 Bearer token 机制从 `Cache::store('login')` 读取登录态,因此只要命令行与服务端使用同一套缓存即可生效。 + +### 指定模拟账号 + +```bash +php think tools:http:call --url="/admin/system.admin/index" --user-id=2 +``` + +### 关闭 super token(回到未登录行为) + +```bash +php think tools:http:call --url="/admin/system.admin/index" --super-token=false +``` + +## 常见现象与排查 + +- 仍提示“请先登录后台”:通常是命令行与服务端的缓存不共享,或服务端未运行/未走到同一环境配置。 +- 返回“无权限访问”:说明已经是登录态,但该账号对当前节点没有权限;可以换 `--user-id` 或调整权限节点。 diff --git a/.gitignore b/.gitignore index 9d90f48..5805bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,4 @@ extend/base/common/command/curd/migrate_output.php /source/**/.hbuilderx /source/**/.idea /source/**/.vscode -.trae/documents/新增 source 目录与 uni-app(CLI)基础工程.md -.trae/documents/uni-app 增加 env_request_utils 与用户状态机制.md +.trae/documents/ diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md deleted file mode 100644 index cbf7de3..0000000 --- a/.trae/rules/project_rules.md +++ /dev/null @@ -1,28 +0,0 @@ -## Ulthon Admin(ThinkPHP 8)项目级规则(Agent 必须遵守) - -### 核心红线 - -- 严禁修改 `extend/base/` 下任何文件。 -- 所有业务逻辑必须放在 `app/` 下(优先 `app/admin/`),通过继承与重写扩展框架能力。 -- 即使是框架层能力,也必须从 `app/` 下入口类调用(业务类继承 base 层基础类),不得直接从 `extend/base/` 调用。 - -### 环境与配置 - -- 不自行安装/变更基础环境(Docker、MySQL、Redis 等),除非用户明确要求。 -- 不修改环境配置文件(如 `.env`);发现配置缺失或不合理时,仅提示需要由开发者补齐。 -- 不主动启动或常驻运行项目(由开发者提前运行以便调试);仅在用户明确要求时执行相关命令。 - -### 数据库与结构 - -- 设计表结构优先采用 Scheme 机制(`app/admin/scheme/`),确保 Code 与 DB 一致后再生成 CURD。 -- 一般不要使用枚举类型(ENUM);优先使用 `tinyint/int` + 注释/字典表等方式表达状态。 -- 调试数据库数据可以使用内置 tools:db 命令,但不要用它来“设计表结构”(临时调试改动需注意还原)。 - -### 前端与视图组织 - -- 遵循视图与脚本同名配对:每个视图 `*.html` 配套同名 `*.js`,并按模块维护 `_common.js`。 - -### 代码风格与命名 - -- 严格遵循项目命名规范与 PSR 风格;需要格式化时以仓库根目录的 `.php-cs-fixer.php` 配置为准(不假设本机已安装相关工具)。 - diff --git a/.trae/skills/ulthon-core-extend-pattern/SKILL.md b/.trae/skills/ulthon-core-extend-pattern/SKILL.md deleted file mode 100644 index 4304380..0000000 --- a/.trae/skills/ulthon-core-extend-pattern/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: "ulthon-core-extend-pattern" -description: "指导如何在不修改 extend/base 的前提下扩展功能。需要新增/改造框架内置能力(权限、菜单、管理员等)时调用。" ---- - -# 核心层扩展模式(继承与重写) - -## 何时调用 - -- 需要修改或扩展框架内置模块(如管理员、菜单、权限、上传等)行为。 -- 看到代码位于 `extend/base/`,但业务需要调整其逻辑或返回数据。 - -## 必须遵守 - -- 不修改 `extend/base/` 下任何文件。 -- 业务实现放在 `app/` 下,并通过继承覆盖 base 层实现。 -- 所有调用从 `app/` 下入口类开始,不直接 new/调用 `extend/base/` 类。 - -## 推荐做法 - -1. 在 `extend/base/` 找到对应基础类(通常以 `*Base.php` 结尾)。 -2. 在 `app/` 下找到或创建同职责的业务类(不带 Base 后缀),让其继承基础类。 -3. 在业务类中重写需要变更的方法: - - 方法签名保持一致 - - 能复用就 `parent::methodName()`,必要时复制父类逻辑后改造 -4. 保持对外调用点不变:控制器/服务统一调用 `app/` 下的类。 - -## 示例定位思路 - -- 控制器:`extend/base/admin/controller/*/*Base.php` ↔ `app/admin/controller/*/*.php` -- 模型:`extend/base/admin/model/*Base.php` ↔ `app/admin/model/*.php` -- 服务:`extend/base/admin/service/*Base.php` ↔ `app/admin/service/*.php` - diff --git a/.trae/skills/ulthon-page-api-dual-mode/SKILL.md b/.trae/skills/ulthon-page-api-dual-mode/SKILL.md deleted file mode 100644 index e24f888..0000000 --- a/.trae/skills/ulthon-page-api-dual-mode/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: "ulthon-page-api-dual-mode" -description: "指导控制器实现“页面/接口同体”。需要同一路由同时返回 HTML 与 JSON 时调用。" ---- - -# 页面 / 接口同体(Controller Dual Mode) - -## 何时调用 - -- 希望同一个控制器方法既能渲染页面(HTML),也能作为接口返回 JSON。 -- 需要让现有页面接口在移动端/脚本调用时返回结构化数据。 - -## 触发与认证 - -- 触发:请求头 `Accept` 包含 `application/json`。 -- 认证:Header 传 `Authorization: Bearer `。 - -## 实现要点 - -- 控制器方法必须调用 `$this->fetch()`,不要使用 `View::fetch()`。 -- 无论使用 `$this->assign()` 还是 `View::assign()`,其数据都会被转换为 JSON 返回。 -- 若不希望某个 assign 字段出现在 JSON 中,可使用: - -```php -$this->assign('name', 'value', -1); -``` - -## 特殊行为 - -- `index` 方法 GET 默认返回分页数据;如果需要返回 assign 数据,URL 增加参数 `get_page_data=1`。 -- 仅在控制器模式(Controller Mode)生效;路由模式(Route Mode)不生效。 - diff --git a/AGENTS.md b/AGENTS.md index 0e194ba..acfd789 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,26 +1,198 @@ -## Agent 工作约束(Ulthon Admin) +# 智能体协作规范(Ulthon Admin) -本仓库的开发规范以两类信息为准: +- `AGENTS.md`(本文件):协作规则入口与导航 +- `.agent/`:工作流(skills)与补充文档(含业务侧规则:`.agent/AGENTS.md`) +- 开发规范与标准流程:已整合到本文件「项目级规则」中 -1. **规则(必须始终遵守)**:见 [.trae/rules/project_rules.md](./.trae/rules/project_rules.md) 与 [CODERULE.md](./CODERULE.md)。 -2. **技能(按场景调用的工作流)**:见 `.trae/skills/*/SKILL.md`。 +## 项目级规则(最高优先级 / 唯一权威) -在线文档(参考):https://doc.ulthon.com/read/augushong/ulthon_admin/home/zh-cn/2.x.html +与其他文档、聊天内容、或智能体建议冲突时,一律以本节为准。 +除非明确要求,按照框架使用者的规则进行开发。 -## 不应该做的事情 +### 通用基础规范(所有开发者必须遵守) -1. 一般不需要直接修改开发环境(除非安装 composer 依赖或开发相关资源)。不要自行安装 Docker、MySQL、Redis 等基础环境,这些由开发者维护。 -2. 一般不需要修改环境配置文件(如 `.env`)。发现配置缺失或错误时,应提示开发者完善相关配置。 -3. 一般不需要自行启动或常驻运行项目程序(开发者会提前运行以便调试);除非用户明确要求执行运行/调试命令。 +- 技术栈:ThinkPHP 8.x;PHP 8+;MySQL 8+;Layui 2.x;模板引擎:ThinkPHP 内置模板引擎 +- 命名规范(必读): +- 代码风格(必读,遵循 `.php-cs-fixer.php`): +- 表结构设计规范(必读): +- 项目文档主页: -## 框架内置能力速查 +### 代码分层(3 层) -本段只保留入口与触发条件,细节以对应技能为准,包括但不限于以下内容: +- ThinkPHP 层:框架本身;通过 Provider 机制可以替换/扩展 ThinkPHP 的行为(配置通常在 `app/provider.php`,例如绑定核心类实现、注册中间件等) +- ulthon\_admin 层:框架内核与默认实现,主要位于 `extend/base/`(\*Base)与 `extend/think/`(对 ThinkPHP 行为的适配/替换) +- App 层:业务代码与系统唯一调用入口,位于 `app/`(包含:用于覆盖框架默认实现的入口类;以及:业务自行新增的普通类) -- 扩展内置能力(不改 base):[ulthon-core-extend-pattern](./.trae/skills/ulthon-core-extend-pattern/SKILL.md) -- Scheme + CURD 工作流:[ulthon-scheme-curd-workflow](./.trae/skills/ulthon-scheme-curd-workflow/SKILL.md) -- 数据库调试命令(tools:db):[ulthon-db-tools-debug](./.trae/skills/ulthon-db-tools-debug/SKILL.md) -- 页面 / 接口同体(同一控制器 HTML+JSON):[ulthon-page-api-dual-mode](./.trae/skills/ulthon-page-api-dual-mode/SKILL.md) -- 登录认证(Session + Token):[ulthon-auth-session-token](./.trae/skills/ulthon-auth-session-token/SKILL.md) +### 身份与职责(2 种) + +一句话讲清楚: + +- 框架作者:代码写在 `extend/base/`,但系统调用入口必须在 `app/`(保证使用者可在 `app/` 继承重写) +- 框架使用者:内置能力要改就改 `app/` 的对应入口类;自己新增的业务代码直接写 `app/`,不需要关心 Base/App 双层机制 + +#### ulthon\_admin 框架作者(维护框架/内核) + +- 新增通用能力:实现写到 `extend/base/`(必要时通过 Provider 机制扩展 ThinkPHP 行为) +- 同时在 `app/` 下建立对应入口类(可为空类继承 Base),作为系统唯一调用入口 +- Base 内部引用模型/服务/控制器时,一律引用 `app/` 下的类(保证业务层扩展始终生效) +- 静态文件/模板/配置支持分层加载:优先加载 `app/`,不存在时再回落到框架默认实现(例如 `app_file_path`) +- Base层代码一般以 `*Base.php` 命名,放在 `extend/base/` 下 +- Base层代码和app层代码的文件路径和名称一般是对应的。 +- 核心层维护原则:稳定性优先(保证向下兼容);通用性优先(不引入具体业务逻辑) + +**Base 层文件分类**: + +- `*Base.php`:控制器/模型/服务/命令基类(被 App 层继承) +- `helper.php`:全局辅助函数(通过 app/common.php 引用) +- `adminInitData/`:框架核心初始化数据(带 @internal-framework 注解标记) +- `adminUpdateCodeData/`:框架版本更新代码(带 @internal-framework 注解标记) + +#### 使用框架的开发者(做业务) + +- 严禁修改 `extend/base/` 下任何文件 +- 业务逻辑一般放在 `app/` 下(优先 `app/admin/`) +- 修改/扩展框架内置能力:只改 `app/` 下对应入口类(必要时 `extends` 对应 `*Base`);仍严禁直接改动 `extend/base/` +- 新写业务能力:直接在 `app/` 下实现即可;不需要、也不应该在 `extend/base/` 新建任何 `*Base.php` +- 公共代码放在 `app/common/`(工具类、基础类等) +- 命令类特殊一些,放在 `app/common/command/`(不放在 `app/admin/command/`) +- 重写规范:保持方法签名一致;可用 `parent::method()` 复用父类逻辑,或复制父类代码后自行实现 +- 其他无任何特殊要求,可以按照任意方式写代码,以上只是对base层作说明。但框架仍然规定了大量的开发规范,需要参考在线文档。 +- 尽量按框架的规范、内置规则、内置技能进行开发,避免违反框架的设计初衷。 + +### Base/App 双层机制调用红线(内核开发约束) + +- `extend/base/` 为框架内核实现(`*Base`),仅作为 `app/` 的继承基类使用,不作为系统调用入口 +- 在实现通用能力/内核代码时,禁止直接引用或调用 `extend/base/` 下的类(包括 `extend/base/` 内部互相调用) +- 模型/服务/控制器/命令的引用与调用必须指向 `app/` 下的对应入口类(保证覆盖/重写始终生效) +- 例外: + 1. 允许在 `app/` 下类定义中使用 `extends` 继承对应 `*Base` 类 + 2. `app/common.php` 中包含 `include App::getRootPath() . '/extend/base/helper.php';`,这是全局辅助函数文件的引用特例 + - `extend/base/helper.php` 仅包含**全局辅助函数**(非类),属于框架内核能力的一部分 + - 这些函数需要在整个应用范围内可用(如 `__url()`, `password()`, `sysconfig()`, `auth()`, `app_file_path()` 等) + - 该引用为框架设计要求的合理例外,不影响 Base/App 双层机制原则 + +### Base/app 双层机制目录映射示例(常见) + +- `extend/base/admin/controller/system/AdminBase.php` → `app/admin/controller/system/Admin.php` +- `extend/base/admin/model/SystemAdminBase.php` → `app/admin/model/SystemAdmin.php` +- `extend/base/common/service/SmsBase.php` → `app/common/service/Sms.php` +- `extend/base/common/command/admin/role/AdminRoleCreateBase.php` → `app/common/command/admin/role/AdminRoleCreate.php` + +### 其他规则 + +- 数据库:表结构优先 Scheme(`app/admin/scheme/`);避免 ENUM;tools:db 用于调试,不用于“设计表结构” +- 后端:代码优先使用CURD机制生成。 +- 前端:视图与脚本同名配对(`*.html` + 同名 `*.js`),并按模块维护 `_common.js` +- 配套资源与多端代码:统一放在 `source/`;不影响现有 PHP/ThinkPHP 主工程运行与发布;目录约定与安全要求见 `source/README.md`(禁止提交构建产物、依赖目录等) +- 接口:如果需要实现接口能力,需要利用框架的“页面接口同体机制”,框架支持直接所有“页面输出”改为“json输出”。 +- 风格:遵循项目命名规范与 PSR;格式化以仓库根目录 `.php-cs-fixer.php` 配置为准(不假设本机已安装工具) +- 权限:基于 `auth` 注解生成节点与鉴权;以角色为中心管理(角色、角色权限、用户角色);命令行使用见技能:[ulthon-permission-cli](./.agent/skills/ulthon-permission-cli/SKILL.md) +- 临时文件:智能体在任务中产生的临时文件(脚本、日志、缓存、产物等)统一输出到 `runtime/agent/`(可按智能体/任务再分子目录),不要放在仓库根目录;除非任务明确要求或框架约定位置属于根目录 +- 调试与验证:框架内置了完善的功能验证能力,可以通过命令行实现数据库操作、控制器的请求(页面接口同体机制)、模拟用户请求(直接获得用户登录状态)、日志管理、菜单管理、权限管理等等,具体查看命令说明和agents技能。你可以利用这些机制直接实现功能的测试和验证,无需借助各类数据库MCP、命令行脚本等方式。 + +### 标准开发流程(Scheme + CURD,默认必须执行) + +1. 初始化开发环境(必须提前完成) + - 按项目文档完成:依赖安装、`.env` 配置、数据库连接与基础数据初始化、确保 `php think` 可用 + - 需要命令行联调与验证时,确保本地 CLI 与服务端使用同一套环境配置(尤其是缓存与数据库) +2. 设计/调整表结构(Scheme) + - 严格遵循表结构设计规范 + - 优先通过 Scheme 机制在 `app/admin/scheme/` 代码化维护表结构(避免 ENUM) +3. 同步并确保一致(Scheme <-> DB) + - 以代码为准(推荐):`php think scheme:sync` + - 以数据库为准:`php think scheme:make -t {table}` + - 生成 CURD 前,Scheme 与数据库结构必须完全一致,否则会被拒绝 + - 数据调试仅用于排错:`php think tools:db:*`(不要用来“设计表结构”) +4. 生成基础代码(CURD),重要,只要是数据库表的功能开发,必须使用CURD机制生成代码。 + - 推荐先生成到临时目录确认:`php think curd -t {table} -r` + - 确认无误后再正式生成/覆盖:`php think curd -t {table}` / `php think curd -t {table} -f` + - 一旦开始在生成代码上做业务改造,后续结构变更默认走“临时生成 + 按需合并”,避免 `-f` 覆盖丢失业务修改 + - CURD 命令详解: +5. 业务代码定制(仅改 `app/`) + - 在生成代码基础上进行修改,仅操作 `app/` 目录 + - CURD 页面不仅包含列表表格页,还会生成若干配套页面/脚本;需要结合业务逐页检查与调整:按钮、字段展示、搜索项、表单校验、交互流程等 + - 如涉及接口输出或客户端(uni-app / Vue 等)调用,优先使用“页面 / 接口同体机制”,并确保同一路由在 HTML 与 JSON 两种模式下都符合预期 +6. 用内置 tools 与 CLI 完成功能验证(把增删改查跑通) + - 用 `php think tools:http:call` 在命令行模拟已登录管理员请求,验证列表/详情/新增/编辑/删除链路与返回结构 + - 必要时用 `php think tools:log:*` 定位异常、追踪请求链路与数据变化 +7. 完成功能配套(按需) + - 菜单管理:新增/调整菜单(`php think admin:menu:*`),并与页面路径/节点保持一致 + - 权限管理:基于 `auth` 注解生成/核对节点(`php think admin:permission:nodes`),为角色分配节点并为用户分配角色(`php think admin:role:*` / `php think admin:user:role:*`) + - 数据库排错:必要时使用 `php think tools:db:*` 做只读检查或最小化变更 +8. 最终检查与交付(完成“新功能开发”的定义) + - 后台页面:列表/表单/详情/删除等核心路径可用,交互与权限符合预期 + - 命令行验证:接口增删改查已通过 tools 命令跑通,关键异常可通过日志命令回溯 + - 代码规范:按仓库根目录 `.php-cs-fixer.php` 约束自查,避免引入不符合规范的写法 + +## 规则维护机制(框架基础 / 使用者补充) + +本仓库的规则分两类: + +- **框架基础规则(稳定)**:根目录 `AGENTS.md`(本文件)中的「项目级规则」为唯一权威;默认不随任务动态增长。 +- **使用者补充规则(业务侧)**:开发中用户/开发者补充的“项目规则/团队偏好/临时约束”,统一记录到 [.agent/AGENTS.md](./.agent/AGENTS.md)。 + +维护约束(必须遵守): + +- 智能体以“框架作者”身份开发时,如需新增/调整规则,必须先与开发者确认是否记录、记录位置与具体写法,并按确认结果落到对应规则文件。 +- 智能体以“使用框架的开发者”身份执行任务时,如发现需要记录或调整的项目约束,应更新到对应规则文件(业务侧约束优先记录到 `.agent/AGENTS.md`),并可按开发者要求随时调整。 + +## 工作流(Skills) + +Skills 是“按场景调用的工作流说明”,统一以 `.agent/skills/*/SKILL.md` 为准;`.trae/skills/` 为 Trae 集成的镜像目录(内容保持同步)。 + +- 扩展内置能力(继承与重写):[ulthon-core-extend-pattern](./.agent/skills/ulthon-core-extend-pattern/SKILL.md) +- Base/App 架构指南:[ulthon-base-app-architecture](./.agent/skills/ulthon-base-app-architecture/SKILL.md) +- CLI 命令参考文档:[ulthon-cli-reference](./.agent/skills/ulthon-cli-reference/SKILL.md) +- Scheme + CURD 工作流:[ulthon-scheme-curd-workflow](./.agent/skills/ulthon-scheme-curd-workflow/SKILL.md) +- Scheme 定义指南:[ulthon-scheme-definition](./.agent/skills/ulthon-scheme-definition/SKILL.md) +- 数据库调试命令(tools:db):[ulthon-db-tools-debug](./.agent/skills/ulthon-db-tools-debug/SKILL.md) +- HTTP 调用工具(tools:http:call):[ulthon-tools-http-call](./.agent/skills/ulthon-tools-http-call/SKILL.md) +- 日志命令(tools:log):[ulthon-tools-log](./.agent/skills/ulthon-tools-log/SKILL.md) +- 内置定时器与定时任务扩展:[ulthon-timer](./.agent/skills/ulthon-timer/SKILL.md) +- 页面 / 接口同体:[ulthon-page-api-dual-mode](./.agent/skills/ulthon-page-api-dual-mode/SKILL.md) +- 登录认证(Session + Token):[ulthon-auth-session-token](./.agent/skills/ulthon-auth-session-token/SKILL.md) +- 权限与角色管理(RBAC CLI):[ulthon-permission-cli](./.agent/skills/ulthon-permission-cli/SKILL.md) +- 菜单管理(admin:menu:\* CLI):[ulthon-admin-menu-cli](./.agent/skills/ulthon-admin-menu-cli/SKILL.md) +- ThinkPHP 控制器 URL 规则:[tp-controller-url-rules](./.agent/skills/tp-controller-url-rules/SKILL.md) + +## 智能体指导 + +使用命令可以将内置智能体规则应用到各类智能体中。设计规则时以 AGENTS.md 和 `.agent/` 目录为主。 + +php think tools:agent:publish + +## 项目结构速览 + +``` +ulthon_admin/ +├── app/ # 应用层(业务代码唯一入口) +│ ├── admin/ # 后台管理模块(控制器、模型、视图、Scheme) +│ ├── common/ # 公共代码(命令、服务、工具类) +│ └── tools/ # 工具控制器(定时任务等) +├── extend/ +│ ├── base/ # 框架内核(*Base.php,禁止业务修改) +│ └── think/ # ThinkPHP 扩展(存储驱动、日志、迁移) +├── config/ # ThinkPHP 配置 +├── public/ # Web 入口 + 静态资源 +├── view/ # 视图覆盖层 +├── route/ # 路由定义 +├── database/ # 数据库迁移与种子 +├── source/ # 多端客户端代码(uni-app、Vue) +│ └── clients/uniapp/ # uni-app 前端工程 +└── .agent/ # 智能体技能与规则 +``` + +## 快速命令参考 + +| 场景 | 命令 | +| ---------------- | --------------------------------------- | +| 同步表结构(代码→DB) | `php think scheme:sync` | +| 生成 Scheme(DB→代码) | `php think scheme:make -t {table}` | +| CURD 代码生成 | `php think curd -t {table}` | +| 模拟 HTTP 请求 | `php think tools:http:call` | +| 查看日志 | `php think tools:log:show` | +| 数据库查询 | `php think tools:db:query "SELECT ..."` | +| 权限节点生成 | `php think admin:permission:nodes` | +| 菜单管理 | `php think admin:menu:*` | +| 框架更新 | `php think admin:update` | -命令行通用参数:多数命令支持 `--force-force`(`-ff`)跳过交互确认。 diff --git a/CODERULE.md b/CODERULE.md deleted file mode 100644 index f2edc56..0000000 --- a/CODERULE.md +++ /dev/null @@ -1,141 +0,0 @@ -# Ulthon Admin 开发规则手册 - -本文档分为三部分: -1. **通用基础规范**:所有开发者(框架作者与业务开发者)均需遵守的基础准则。 -2. **框架开发规则**:针对框架本身的维护者和贡献者。 -3. **业务开发规则**:针对使用框架开发具体业务系统的开发者。 - ---- - -## 第一部分:通用基础规范 -> 适用对象:所有开发者(必须遵守) - -### 1. 技术栈标准 - -- **核心框架**: ThinkPHP 8.0 -- **开发语言**: PHP 8+ -- **数据库**: MySQL 8+ -- **前端框架**: Layui 2.8.6 -- **模板引擎**: ThinkPHP 内置模板引擎 -- **代码生成器**: 自定义命令行工具 - -### 2. 命名与代码规范 - -所有代码(无论是核心框架还是业务代码)必须严格遵守以下规范: - -- **命名规范**: [官方命名规范文档](https://doc.ulthon.com/read/augushong/ulthon_admin/64fbcf8830640/zh-cn/2.x.html) - - 涵盖:类名、方法名、变量名、目录名等。 -- **代码风格**: [官方代码规范文档 (PHP-CS-Fixer)](https://doc.ulthon.com/read/augushong/ulthon_admin/64360c249d66a/zh-cn/2.x.html) - - 统一使用 PSR 标准,建议配置自动格式化工具。 - -### 3. 数据库设计规范 - -- **设计文档**: [官方表结构设计规范](https://doc.ulthon.com/read/augushong/ulthon_admin/619efc9d7af62/zh-cn/2.x.html) -- **核心要求**: - - 表名小写,使用下划线分隔。 - - 按照前缀、模块、功能的顺序命名,例如:`ul_mall_goods`。 - - 必须为所有字段编写清晰、完整的注释。 - - 严格遵循文档中的字段类型定义。 - -### 4.在线文档 - -- **项目文档**: [Ulthon Admin 项目文档](https://doc.ulthon.com/read/augushong/ulthon_admin/home/zh-cn/2.x.html) - ---- - -## 第二部分:框架开发规则 -> 适用对象:框架作者、核心贡献者 - -### 1. 架构设计理念 - -#### 1.1 双层架构核心 -Ulthon Admin 采用独特的双层架构设计,其中 **基础核心层 (`extend/base/`)** 是框架的基石: - -- **职责**: 存放系统内置的所有核心功能代码,提供标准化的类和接口。 -- **原则**: 保持高度稳定,**严禁依赖具体的业务逻辑**。 -- **框架代码写到 `extend/base/` 目录下**。但所有的调用都是从app目录下开始的。即便是纯框架要用到的代码,也是从app目录下开始调用的。 - - 比如base层有一个 AuthServiceBase 类,在app目录下有一个 AuthService 类,它继承了 AuthServiceBase 类。实际调用时,是从app目录下调用 AuthService 类的方法,而不是从base目录下调用 AuthServiceBase 类的方法。 - - 这样做的好处是,业务代码可以根据需要自由扩展和定制,而不会受到框架核心的影响。 - -### 2. 核心层维护原则 - -- **稳定性优先**: 核心层的任何修改都必须保证向下兼容。 -- **通用性**: 核心代码应具有高度的抽象性,不应包含特定项目的业务逻辑。 - ---- - -## 第三部分:业务系统开发规则 -> 适用对象:业务功能开发者 - -### 1. 开发红线与原则 - -- **核心保护**: 严禁修改 `extend/base/` 目录下的任何文件。 -- **业务隔离**: 所有业务逻辑必须放在 `app/` 目录下。 - -### 2. 扩展机制:继承与重写 - -当需要修改或扩展框架内置功能(如管理员管理、菜单权限)时,请遵循以下机制: - -- **继承**: 业务类继承 `extend/base/` 中的基础类。 -- **重写**: 在业务类中重写父类方法,保持方法签名一致。 -- **调用父类**: 使用 `parent::methodName()` 复用原有逻辑,或者从父类复制代码重新实现。 -- **注意**: 全新开发的业务模块不受此限制,直接在 `app/` 下开发即可。 - -### 3. 标准开发流程 - -1. **设计/调整表结构** - - 严格遵循【第一部分:通用基础规范】中的数据库设计规范。 - - 优先通过 Scheme 机制在 `app/admin/scheme/` 代码化维护表结构。 - -2. **同步并确保一致** - - 以代码为准:`scheme:sync` - - 以数据库为准:`scheme:make -t {table}` - - **注意**:生成 CURD 前,Scheme 与数据库结构必须完全一致,否则会被拒绝。 - -3. **生成基础代码(CURD)** - - 推荐先生成到临时目录确认:`php think curd -t {table} -r` - - 确认无误后再正式生成/覆盖:`php think curd -t {table}` / `php think curd -t {table} -f` - -4. **业务代码定制** - - 在生成代码基础上进行修改,仅操作 `app/` 目录。 - -5. **测试与验证** - - 功能测试、数据完整性检查、代码规范检查(遵循项目 PHP-CS-Fixer 配置)。 - -### 4. 常用开发工具 - -#### 4.1 代码生成命令 (CURD) -> 文档: [CURD 命令详解](https://doc.ulthon.com/read/augushong/ulthon_admin/curd-command/zh-cn/2.x.html) - -更完整的命令组合与参数说明,优先以在线文档为准;仓库内的工作流摘要可参考 `.trae/skills/ulthon-scheme-curd-workflow/`。 - -### 5. 前端开发规范 - -#### 5.1 文件组织 -每个视图 HTML 文件应搭配一个同名的 JS 文件,并按模块维护 `_common.js`,结构示例: - -```text -goods/ -├── add.html <-- 视图 -├── add.js <-- 对应逻辑 -├── index.html -├── index.js -└── _common.js <-- 模块通用逻辑 -``` - -这种结构确保了逻辑与视图的解耦,便于维护。 - -### 6. Scheme 机制(数据库代码化) - -Ulthon Admin 引入了 Scheme 层,实现数据库结构与 PHP 代码的双向同步,便于版本控制和快速迁移。 - -#### 6.1 核心概念 -- **Code to DB (`scheme:sync`)**: 通过编写 PHP 类定义表结构,自动同步到数据库(支持备份原表)。 -- **DB to Code (`scheme:make`)**: 读取现有数据库表结构,反向生成 PHP Scheme 类。 - -#### 6.2 目录规范 -- **业务 Scheme**: `app/admin/scheme/` (所有生成的业务表结构类存放在此) - -#### 6.3 参考资料 - -- 标准工作流摘要:`.trae/skills/ulthon-scheme-curd-workflow/SKILL.md` diff --git a/app/admin/controller/system/Config.php b/app/admin/controller/system/Config.php index cd221a2..0af05ed 100644 --- a/app/admin/controller/system/Config.php +++ b/app/admin/controller/system/Config.php @@ -2,12 +2,10 @@ namespace app\admin\controller\system; -use app\admin\service\annotation\ControllerAnnotation; use base\admin\controller\system\ConfigBase; /** * Class Config. - * @ControllerAnnotation(title="系统配置管理",module="系统") */ class Config extends ConfigBase { diff --git a/app/admin/model/SystemConfig.php b/app/admin/model/SystemConfig.php index c3fea94..bc4efcf 100644 --- a/app/admin/model/SystemConfig.php +++ b/app/admin/model/SystemConfig.php @@ -7,3 +7,4 @@ use base\admin\model\SystemConfigBase; class SystemConfig extends SystemConfigBase { } + diff --git a/app/admin/scheme/MallCate.php b/app/admin/scheme/MallCate.php index 336a364..1921e38 100644 --- a/app/admin/scheme/MallCate.php +++ b/app/admin/scheme/MallCate.php @@ -8,7 +8,7 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_mall_cate', comment: '')] +#[Table(name: 'ul_mall_cate', comment: '商品分类')] #[Index(columns: ['title'], name: 'title', type: 'NORMAL')] #[Index(columns: ['delete_time'], name: 'delete_time', type: 'NORMAL')] class MallCate extends BaseScheme @@ -23,7 +23,7 @@ class MallCate extends BaseScheme #[Component(type: 'image', options: [])] public $image; - #[Field(type: 'int', length: 11, default: '0', comment: '排序')] + #[Field(type: 'int', length: 11, default: '100', comment: '排序')] public $sort; #[Field(type: 'int', length: 11, default: '2', comment: '状态')] diff --git a/app/admin/scheme/MallGoods.php b/app/admin/scheme/MallGoods.php index 8fdf352..c52f99f 100644 --- a/app/admin/scheme/MallGoods.php +++ b/app/admin/scheme/MallGoods.php @@ -8,7 +8,7 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_mall_goods', comment: '')] +#[Table(name: 'ul_mall_goods', comment: '商品列表')] #[Index(columns: ['cate_id'], name: 'cate_id', type: 'NORMAL')] #[Index(columns: ['delete_time'], name: 'delete_time', type: 'NORMAL')] class MallGoods extends BaseScheme @@ -25,7 +25,7 @@ class MallGoods extends BaseScheme #[Field(type: 'char', length: 100, precision: 100, default: '', comment: '商品标签')] public $tag; - #[Field(type: 'char', length: 255, precision: 255, comment: '商品logo')] + #[Field(type: 'char', length: 255, precision: 255, default: '', comment: '商品logo')] #[Component(type: 'image', options: [])] public $logo; @@ -40,10 +40,10 @@ class MallGoods extends BaseScheme #[Field(type: 'text', comment: '商品属性')] public $property; - #[Field(type: 'decimal', length: 8, precision: 8, default: '0', comment: '市场价')] + #[Field(type: 'decimal', length: 8, precision: 8, scale: 2, default: '0.00', comment: '市场价')] public $market_price; - #[Field(type: 'decimal', length: 8, precision: 8, default: '0', comment: '折扣价')] + #[Field(type: 'decimal', length: 8, precision: 8, scale: 2, default: '0.00', comment: '折扣价')] public $discount_price; #[Field(type: 'int', length: 11, default: '0', comment: '销量', unsigned: true)] @@ -58,7 +58,7 @@ class MallGoods extends BaseScheme #[Field(type: 'int', length: 11, default: '0', comment: '总库存', unsigned: true)] public $total_stock; - #[Field(type: 'int', length: 11, default: '0', comment: '排序', unsigned: true)] + #[Field(type: 'int', length: 11, default: '100', comment: '排序', unsigned: true)] public $sort; #[Field(type: 'int', length: 11, default: '0', comment: '状态', unsigned: true)] @@ -77,10 +77,10 @@ class MallGoods extends BaseScheme #[Field(type: 'int', length: 11, default: '0', unsigned: true)] public $delete_time; - #[Field(length: 300, precision: 300, nullable: false, default: '')] + #[Field(length: 100, precision: 100, nullable: false, default: '')] public $license; - #[Field(length: 300, precision: 300, nullable: false, default: '')] + #[Field(length: 100, precision: 100, nullable: false, default: '')] public $license_name; #[Field(type: 'text', comment: '属性(静态字段)')] diff --git a/app/admin/scheme/MallTag.php b/app/admin/scheme/MallTag.php index f7d1670..96ce29d 100644 --- a/app/admin/scheme/MallTag.php +++ b/app/admin/scheme/MallTag.php @@ -8,7 +8,7 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_mall_tag', comment: '')] +#[Table(name: 'ul_mall_tag', comment: '商品标签')] #[Index(columns: ['delete_time'], name: 'delete_time', type: 'NORMAL')] class MallTag extends BaseScheme { diff --git a/app/admin/scheme/SystemAdmin.php b/app/admin/scheme/SystemAdmin.php index b626705..52e101f 100644 --- a/app/admin/scheme/SystemAdmin.php +++ b/app/admin/scheme/SystemAdmin.php @@ -8,7 +8,7 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_system_admin', comment: '')] +#[Table(name: 'ul_system_admin', comment: '系统用户表')] #[Index(columns: ['username'], name: 'username', type: 'NORMAL')] #[Index(columns: ['phone'], name: 'phone', type: 'NORMAL')] #[Index(columns: ['delete_time'], name: 'delete_time', type: 'NORMAL')] @@ -38,7 +38,7 @@ class SystemAdmin extends BaseScheme #[Field(type: 'bigint', length: 11, default: '0', comment: '登录次数', unsigned: true)] public $login_num; - #[Field(type: 'int', length: 11, default: '0', comment: '排序')] + #[Field(type: 'int', length: 11, default: '100', comment: '排序')] public $sort; #[Field(type: 'int', length: 11, default: '1', comment: '状态', unsigned: true)] diff --git a/app/admin/scheme/SystemAuth.php b/app/admin/scheme/SystemAuth.php index a8249c6..64955d9 100644 --- a/app/admin/scheme/SystemAuth.php +++ b/app/admin/scheme/SystemAuth.php @@ -8,9 +8,8 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_system_auth', comment: '')] +#[Table(name: 'ul_system_auth', comment: '系统权限表')] #[Index(columns: ['title'], name: 'title', type: 'UNIQUE')] -#[Index(columns: ['title'], name: 'title_2', type: 'NORMAL')] #[Index(columns: ['delete_time'], name: 'delete_time', type: 'NORMAL')] class SystemAuth extends BaseScheme { @@ -20,7 +19,7 @@ class SystemAuth extends BaseScheme #[Field(type: 'char', length: 20, precision: 20, comment: '权限名称')] public $title; - #[Field(type: 'int', length: 11, default: '0', comment: '排序')] + #[Field(type: 'int', length: 11, default: '100', comment: '排序')] public $sort; #[Field(type: 'int', length: 11, default: '0', comment: '状态')] diff --git a/app/admin/scheme/SystemAuthNode.php b/app/admin/scheme/SystemAuthNode.php index c9f05f6..c7f0eb2 100644 --- a/app/admin/scheme/SystemAuthNode.php +++ b/app/admin/scheme/SystemAuthNode.php @@ -8,9 +8,8 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_system_auth_node', comment: '')] +#[Table(name: 'ul_system_auth_node', comment: '角色与节点关系表')] #[Index(columns: ['auth_id'], name: 'auth_id', type: 'NORMAL')] -#[Index(columns: ['node_id'], name: 'node_id', type: 'NORMAL')] class SystemAuthNode extends BaseScheme { #[Field(type: 'int', length: 11, nullable: false, unsigned: true, autoIncrement: true, primary: true)] @@ -19,9 +18,6 @@ class SystemAuthNode extends BaseScheme #[Field(type: 'bigint', length: 11, comment: '角色ID', unsigned: true)] public $auth_id; - #[Field(type: 'bigint', length: 11, comment: '节点ID', unsigned: true)] - public $node_id; - #[Field(type: 'char', length: 100, precision: 100, default: '')] public $node; } \ No newline at end of file diff --git a/app/admin/scheme/SystemConfig.php b/app/admin/scheme/SystemConfig.php index 403ae85..47a1f3a 100644 --- a/app/admin/scheme/SystemConfig.php +++ b/app/admin/scheme/SystemConfig.php @@ -8,7 +8,7 @@ use app\common\scheme\attribute\Field; use app\common\scheme\attribute\Component; use app\common\scheme\attribute\Index; -#[Table(name: 'ul_system_config', comment: '')] +#[Table(name: 'ul_system_config', comment: '系统配置表')] #[Index(columns: ['name'], name: 'name', type: 'NORMAL')] #[Index(columns: ['group'], name: 'group', type: 'NORMAL')] class SystemConfig extends BaseScheme @@ -28,7 +28,7 @@ class SystemConfig extends BaseScheme #[Field(type: 'char', length: 100, precision: 100, default: '', comment: '备注信息')] public $remark; - #[Field(type: 'int', length: 11, default: '0', comment: '排序')] + #[Field(type: 'int', length: 11, default: '100', comment: '排序')] public $sort; #[Field(type: 'int', length: 11, default: '0', comment: '创建时间', unsigned: true)] diff --git a/app/admin/scheme/SystemHost.php b/app/admin/scheme/SystemHost.php new file mode 100644 index 0000000..7b1ccf8 --- /dev/null +++ b/app/admin/scheme/SystemHost.php @@ -0,0 +1,65 @@ + 'mall_cate', 'relationBindSelect' => 'title'])] + public $cate_id; + + #[Field(type: 'char', length: 20, precision: 20, nullable: false, default: '', comment: '商品名称')] + public $title; + + #[Field(type: 'char', length: 255, precision: 255, nullable: false, comment: '商品logo')] + #[Component(type: 'image', options: [])] + public $logo; + + #[Field(type: 'text', nullable: false, comment: '商品图片')] + #[Component(type: 'images', options: [])] + public $images; + + #[Field(type: 'text', nullable: false, comment: '商品描述')] + #[Component(type: 'editor', options: [])] + public $describe; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '总库存', unsigned: true)] + public $total_stock; + + #[Field(type: 'int', length: 11, nullable: false, default: '100', comment: '排序', unsigned: true)] + public $sort; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '状态', unsigned: true)] + #[Component(type: 'radio', options: ['正常', '禁用'])] + public $status; + + #[Field(length: 100, precision: 100, nullable: false, comment: '合格证')] + #[Component(type: 'file', options: [])] + public $cert_file; + + #[Field(type: 'text', nullable: false, comment: '检测报告')] + #[Component(type: 'files', options: [])] + public $verfiy_file; + + #[Field(type: 'char', length: 255, precision: 255, nullable: false, default: '', comment: '备注说明')] + public $remark; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $create_time; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $update_time; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $delete_time; + + #[Field(type: 'int', length: 11, nullable: false, comment: '发布日期', unsigned: true)] + #[Component(type: 'date', options: ['date'])] + public $publish_time; + + #[Field(type: 'bigint', length: 11, nullable: false, comment: '售卖日期', unsigned: true)] + #[Component(type: 'date', options: ['datetime'])] + public $sale_time; + + #[Field(length: 100, precision: 100, nullable: false, comment: '简介')] + #[Component(type: 'textarea', options: [])] + public $intro; + + #[Field(type: 'int', length: 11, nullable: false, comment: '秒杀状态', unsigned: true)] + #[Component(type: 'select', options: [0 => '未参加', 1 => '已开始', 3 => '已结束'])] + public $time_status; + + #[Field(type: 'int', length: 11, nullable: false, comment: '是否推荐')] + #[Component(type: 'switch', options: ['不推荐', '推荐'])] + public $is_recommend; + + #[Field(length: 100, precision: 100, nullable: false, comment: '商品类型')] + #[Component(type: 'checkbox', options: ['taobao' => '淘宝', 'jd' => '京东'])] + public $shop_type; + + #[Field(length: 100, precision: 100, nullable: false, comment: '商品标签')] + #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'checkbox', 'valueField' => 'id', 'fieldName' => 'title'])] + public $tag; + + #[Field(length: 100, precision: 100, comment: '商品标签(单选)')] + #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'radio', 'valueField' => 'id', 'fieldName' => 'title'])] + public $tag_backup; + + #[Field(length: 100, precision: 100, nullable: false, comment: '产地')] + #[Component(type: 'city', options: ['name-province' => '0', 'code' => '0'])] + public $from_area; + + #[Field(length: 100, precision: 100, nullable: false, default: '山东省/临沂市', comment: '仓库')] + #[Component(type: 'city', options: ['level' => 'city'])] + public $store_city; + + #[Field(length: 100, precision: 100, nullable: false, comment: '商品标签 (输入)')] + #[Component(type: 'tag', options: [])] + public $tag_input; + + #[Field(length: 100, precision: 100, nullable: false, comment: '唯一id')] + public $uid; + + #[Field(type: 'decimal', length: 10, precision: 10, scale: 2, comment: '价格')] + public $price; + + #[Field(type: 'text', comment: '详情')] + public $detail; +} \ No newline at end of file diff --git a/app/admin/scheme/TreeTree.php b/app/admin/scheme/TreeTree.php new file mode 100644 index 0000000..d4288d9 --- /dev/null +++ b/app/admin/scheme/TreeTree.php @@ -0,0 +1,55 @@ + 'mall_cate', 'relationBindSelect' => 'title'])] + public $cate_id; + + #[Field(type: 'char', length: 20, precision: 20, nullable: false, default: '', comment: '商品名称')] + public $title; + + #[Field(type: 'char', length: 255, precision: 255, nullable: false, comment: '商品logo')] + #[Component(type: 'image', options: [])] + public $logo; + + #[Field(type: 'text', comment: '商品图片')] + #[Component(type: 'images', options: [])] + public $images; + + #[Field(type: 'text', comment: '商品描述')] + #[Component(type: 'editor', options: [])] + public $describe; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '总库存', unsigned: true)] + public $total_stock; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '排序', unsigned: true)] + public $sort; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '状态', unsigned: true)] + #[Component(type: 'radio', options: ['正常', '禁用'])] + public $status; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '合格证')] + #[Component(type: 'file', options: [])] + public $cert_file; + + #[Field(type: 'text', comment: '检测报告')] + #[Component(type: 'files', options: [])] + public $verfiy_file; + + #[Field(type: 'char', length: 255, precision: 255, nullable: false, default: '', comment: '备注说明')] + public $remark; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $create_time; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $update_time; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', unsigned: true)] + public $delete_time; + + #[Field(type: 'int', length: 11, nullable: false, comment: '发布日期', unsigned: true)] + #[Component(type: 'date', options: ['date'])] + public $publish_time; + + #[Field(type: 'bigint', length: 11, nullable: false, default: '0', comment: '售卖日期', unsigned: true)] + #[Component(type: 'date', options: ['datetime'])] + public $sale_time; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '简介')] + #[Component(type: 'textarea', options: [])] + public $intro; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '秒杀状态', unsigned: true)] + #[Component(type: 'select', options: [0 => '未参加', 1 => '已开始', 3 => '已结束'])] + public $time_status; + + #[Field(type: 'int', length: 11, nullable: false, default: '0', comment: '是否推荐')] + #[Component(type: 'switch', options: ['不推荐', '推荐'])] + public $is_recommend; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '商品类型')] + #[Component(type: 'checkbox', options: ['taobao' => '淘宝', 'jd' => '京东'])] + public $shop_type; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '商品标签')] + #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'checkbox', 'valueField' => 'id', 'fieldName' => 'title'])] + public $tag; + + #[Field(length: 100, precision: 100, default: '', comment: '商品标签(单选)')] + #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'radio', 'valueField' => 'id', 'fieldName' => 'title'])] + public $tag_backup; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '产地')] + #[Component(type: 'city', options: ['name-province' => '0', 'code' => '0'])] + public $from_area; + + #[Field(length: 100, precision: 100, nullable: false, default: '山东省/临沂市', comment: '仓库')] + #[Component(type: 'city', options: ['level' => 'city'])] + public $store_city; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '商品标签 (输入)')] + #[Component(type: 'tag', options: [])] + public $tag_input; + + #[Field(length: 100, precision: 100, nullable: false, default: '', comment: '唯一id')] + public $uid; + + #[Field(type: 'decimal', length: 10, precision: 10, default: '0', comment: '价格')] + public $price; + + #[Field(type: 'text', comment: '详情')] + public $detail; +} \ No newline at end of file diff --git a/app/admin/scheme/UlthonDemoGoodsBatch.php b/app/admin/scheme/UlthonDemoGoodsBatch.php new file mode 100644 index 0000000..5a03b2f --- /dev/null +++ b/app/admin/scheme/UlthonDemoGoodsBatch.php @@ -0,0 +1,52 @@ +Console::class + // 注意,必须在此处注册ConsoleProvider,否则命令行控制台无法正常工作 + 'think\Console'=>Console::class ]; diff --git a/config/console.php b/config/console.php index 4e5c113..c117e52 100644 --- a/config/console.php +++ b/config/console.php @@ -3,32 +3,8 @@ // | 控制台配置 // +---------------------------------------------------------------------- -use app\common\command\admin\Clear; -use app\common\command\admin\Version; -use app\common\command\admin\ResetPassword; -use app\common\command\admin\Update; -use app\common\command\admin\UpdateCode; -use app\common\command\curd\Migrate; -use app\common\command\Timer; -use app\common\command\scheme\Make; -use app\common\command\scheme\Sync; -use app\common\command\scheme\Backup; - return [ // 指令定义 'commands' => [ - 'curd' => 'app\common\command\Curd', - 'node' => 'app\common\command\Node', - 'OssStatic' => 'app\common\command\OssStatic', - ResetPassword::class, - Timer::class, - Version::class, - Migrate::class, - Clear::class, - Update::class, - UpdateCode::class, - Make::class, - Sync::class, - Backup::class, ], ]; diff --git a/extend/base/admin/controller/AjaxBase.php b/extend/base/admin/controller/AjaxBase.php index 6d7dcd7..afb4142 100644 --- a/extend/base/admin/controller/AjaxBase.php +++ b/extend/base/admin/controller/AjaxBase.php @@ -21,11 +21,12 @@ class AjaxBase extends AdminController */ public function initAdmin() { - $cacheData = Cache::get('initAdmin_' . $this->sessionAdmin->id); + $adminId = $this->getAdminId(); + $cacheData = Cache::get('initAdmin_' . $adminId); if (!empty($cacheData)) { return json($cacheData); } - $menuService = new MenuService($this->sessionAdmin->id); + $menuService = new MenuService($adminId); $data = [ 'logoInfo' => [ 'title' => sysconfig('site', 'logo_title'), @@ -35,7 +36,7 @@ class AjaxBase extends AdminController 'homeInfo' => $menuService->getHomeInfo(), 'menuInfo' => $menuService->getMenuTree(), ]; - Cache::tag('initAdmin')->set('initAdmin_' . $this->sessionAdmin->id, $data); + Cache::tag('initAdmin')->set('initAdmin_' . $adminId, $data); return json($data); } diff --git a/extend/base/admin/controller/IndexBase.php b/extend/base/admin/controller/IndexBase.php index 30d517e..cfcb992 100644 --- a/extend/base/admin/controller/IndexBase.php +++ b/extend/base/admin/controller/IndexBase.php @@ -50,7 +50,7 @@ class IndexBase extends AdminController */ public function editAdmin() { - $id = $this->sessionAdmin->id; + $id = $this->getAdminId(); $row = (new SystemAdmin()) ->withoutField('password') ->find($id); @@ -96,7 +96,7 @@ class IndexBase extends AdminController */ public function editPassword() { - $id = $this->sessionAdmin->id; + $id = $this->getAdminId(); $row = (new SystemAdmin()) ->withoutField('password') ->find($id); @@ -140,7 +140,7 @@ class IndexBase extends AdminController { $pid = $this->request->param('pid', 0); - $menuService = new MenuService($this->sessionAdmin->id); + $menuService = new MenuService($this->getAdminId()); $home_info = $menuService->getHomeInfo(); diff --git a/extend/base/admin/service/adminInitData/MallCate.php b/extend/base/admin/service/adminInitData/MallCate.php index 389f965..49f4c67 100644 --- a/extend/base/admin/service/adminInitData/MallCate.php +++ b/extend/base/admin/service/adminInitData/MallCate.php @@ -1,4 +1,15 @@ "手机", diff --git a/extend/base/admin/service/adminInitData/MallGoods.php b/extend/base/admin/service/adminInitData/MallGoods.php index f6f74a0..e259484 100644 --- a/extend/base/admin/service/adminInitData/MallGoods.php +++ b/extend/base/admin/service/adminInitData/MallGoods.php @@ -1,4 +1,15 @@ "落地-风扇", diff --git a/extend/base/admin/service/adminInitData/MallTag.php b/extend/base/admin/service/adminInitData/MallTag.php index 771971e..15546d9 100644 --- a/extend/base/admin/service/adminInitData/MallTag.php +++ b/extend/base/admin/service/adminInitData/MallTag.php @@ -1,4 +1,15 @@ 1, diff --git a/extend/base/admin/service/adminInitData/SystemAuth.php b/extend/base/admin/service/adminInitData/SystemAuth.php index cfb0567..745ab1d 100644 --- a/extend/base/admin/service/adminInitData/SystemAuth.php +++ b/extend/base/admin/service/adminInitData/SystemAuth.php @@ -1,4 +1,15 @@ 1, diff --git a/extend/base/admin/service/adminInitData/SystemAuthNode.php b/extend/base/admin/service/adminInitData/SystemAuthNode.php index 5e63e29..4c948bc 100644 --- a/extend/base/admin/service/adminInitData/SystemAuthNode.php +++ b/extend/base/admin/service/adminInitData/SystemAuthNode.php @@ -1,4 +1,15 @@ "alisms_access_key_id", diff --git a/extend/base/admin/service/adminInitData/SystemMenu.php b/extend/base/admin/service/adminInitData/SystemMenu.php index a3caf84..af22cf5 100644 --- a/extend/base/admin/service/adminInitData/SystemMenu.php +++ b/extend/base/admin/service/adminInitData/SystemMenu.php @@ -1,4 +1,15 @@ 227, diff --git a/extend/base/admin/service/adminInitData/SystemQuick.php b/extend/base/admin/service/adminInitData/SystemQuick.php index 622493b..4c0f60e 100644 --- a/extend/base/admin/service/adminInitData/SystemQuick.php +++ b/extend/base/admin/service/adminInitData/SystemQuick.php @@ -1,4 +1,15 @@ "管理员管理", diff --git a/extend/base/admin/service/adminUpdateCodeData/v2.0.74.php b/extend/base/admin/service/adminUpdateCodeData/v2.0.74.php index 1d902fa..a306669 100644 --- a/extend/base/admin/service/adminUpdateCodeData/v2.0.74.php +++ b/extend/base/admin/service/adminUpdateCodeData/v2.0.74.php @@ -1,4 +1,14 @@ table = $table; + // 自动检测并处理表前缀 + // 支持用户输入带前缀(ul_daka_record)或不带前缀(daka_record)两种方式 + // 注意:tablePrefix 可能已包含下划线(如 'ul_'),需要先检测 + $this->table = $this->stripTablePrefix($this->table); + $schemeClass = 'app\\admin\\scheme\\' . Str::studly($this->table); if (!class_exists($schemeClass)) { throw new TableException("未找到 {$schemeClass},请先执行:php think scheme:make -t {$this->table} 或手动创建 Scheme"); @@ -352,6 +357,28 @@ class BuildCurdServiceBase return $controllerFilename; } + /** + * 去除表前缀. + * @param string $tableName + * @return string + */ + protected function stripTablePrefix($tableName) + { + $prefixToStrip = $this->tablePrefix; + + // 如果 tablePrefix 不以 _ 结尾,加上下划线 + if (!str_ends_with($prefixToStrip, '_')) { + $prefixToStrip .= '_'; + } + + if (str_starts_with($tableName, $prefixToStrip)) { + // 用户输入了带前缀的表名,自动去除前缀 + return substr($tableName, strlen($prefixToStrip)); + } + + return $tableName; + } + /** * 设置关联表. * @param $relationTable @@ -365,6 +392,9 @@ class BuildCurdServiceBase */ public function setRelation($relationTable, $foreignKey, $primaryKey = null, $modelFilename = null, $onlyShowFileds = [], $bindSelectField = null) { + // 自动检测并处理关联表前缀(与 setTable 保持一致) + $relationTable = $this->stripTablePrefix($relationTable); + if (!isset($this->tableColumns[$foreignKey])) { throw new TableException("主表不存在外键字段:{$foreignKey}"); } diff --git a/extend/base/common/command/CommandBase.php b/extend/base/common/command/CommandBase.php new file mode 100644 index 0000000..2eae511 --- /dev/null +++ b/extend/base/common/command/CommandBase.php @@ -0,0 +1,23 @@ +setName('curd') ->addOption('table', 't', Option::VALUE_REQUIRED, '主表名', null) ->addOption('controllerFilename', 'c', Option::VALUE_REQUIRED, '控制器文件名', null) @@ -22,12 +24,55 @@ class CurdBase extends Command // ->addOption('force', 'f', Option::VALUE_NONE, '强制覆盖模式') ->addOption('delete', 'd', Option::VALUE_NONE, '删除模式') - ->addOption('runtime', 'r', Option::VALUE_NONE, '临时生成') - ->setDescription('一键curd命令服务'); + ->addOption('runtime', 'r', Option::VALUE_NONE, '临时生成(生成到 runtime 目录)') + ->addOption('examples', null, Option::VALUE_NONE, '显示使用示例') + ->setDescription('一键生成 CURD 代码(控制器、模型、视图、JS)'); + } + + protected function getExamplesText(): string + { + return <<<'EXAMPLES' +CURD 命令使用示例 + +示例 1:首次生成(基本用法) + php think curd -t daka_record + 说明:直接生成到项目目录,如文件已存在会跳过 + +示例 2:增量更新(已有业务代码) + php think curd -t daka_record -r + 说明:生成到 runtime 临时目录,用于手动对比并合并新增字段代码(避免覆盖业务逻辑) + +示例 3:带前缀的表名 + php think curd -t ul_daka_record + 说明:自动识别并去除前缀 + +示例 4:强制覆盖(谨慎使用) + php think curd -t daka_record -f + 说明:覆盖已存在的文件,会丢失手动修改的内容 + +示例 5:删除已生成的文件 + php think curd -t daka_record -d + 说明:删除之前生成的所有文件(会要求确认) + 注意:这是删除文件,不是删除数据库记录 + +常见错误及解决: + 错误:表不存在 + → 先创建表:php think scheme:sync + → 或使用迁移:php think migrate:run + + 错误:Scheme 与数据库不一致 + → 同步 Scheme:php think scheme:sync +EXAMPLES; } protected function execute(Input $input, Output $output) { + // 显示使用示例 + if ($input->hasOption('examples')) { + $output->writeln($this->getExamplesText()); + return true; + } + $table = $input->getOption('table'); $controllerFilename = $input->getOption('controllerFilename'); $modelFilename = $input->getOption('modelFilename'); @@ -49,11 +94,15 @@ class CurdBase extends Command return false; } + // 收集警告信息 + $warnings = []; + try { $build = (new BuildCurdService()) ->setTable($table) ->setForce($force); + $runtime_path = ''; if ($input->hasOption('runtime')) { $runtime_path = App::getRuntimePath() . 'source' . DS . 'build' . DS . date('YmdHis') . DS; PathTools::intiDir($runtime_path . 'a.temp'); @@ -70,8 +119,9 @@ class CurdBase extends Command $define = $column['define']; if (!isset($define['table'])) { - $output->error("关联字段{$field}没有设置关联表名称"); - + $error = "关联字段{$field}没有设置关联表名称"; + $output->error($error); + $warnings[] = ['message' => $error]; return false; } @@ -96,6 +146,7 @@ class CurdBase extends Command $build = $build->render(); $fileList = $build->getFileList(); + $result = []; if (!$delete) { if ($force) { $output->writeln('>>>>>>>>>>>>>>>'); @@ -104,10 +155,9 @@ class CurdBase extends Command } $output->writeln('>>>>>>>>>>>>>>>'); - $ask_force_delete_result = $output->confirm($input, '确定强制生成上方所有文件? 如果文件存在会直接覆盖。'); - - if (!$ask_force_delete_result) { - throw new Exception('取消文件CURD生成操作'); + if (!$output->confirm($input, '确定强制生成上方所有文件? 如果文件存在会直接覆盖。', true)) { + $output->comment('已取消。'); + return false; } } $result = $build->create(); @@ -119,10 +169,9 @@ class CurdBase extends Command } $output->writeln('>>>>>>>>>>>>>>>'); - $ask_force_delete_result = $output->confirm($input, '确定删除上方所有文件? '); - - if (!$ask_force_delete_result) { - throw new Exception('取消删除文件操作'); + if (!$output->confirm($input, '确定删除上方所有文件? ', true)) { + $output->comment('已取消。'); + return false; } $result = $build->delete(); $output->info('>>>>>>>>>>>>>>>'); diff --git a/extend/base/common/command/OssStaticBase.php b/extend/base/common/command/OssStaticBase.php index 67c6949..3cc3296 100644 --- a/extend/base/common/command/OssStaticBase.php +++ b/extend/base/common/command/OssStaticBase.php @@ -2,8 +2,8 @@ namespace base\common\command; +use app\common\console\Command; use app\common\service\UploadService; -use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Filesystem; @@ -13,37 +13,53 @@ class OssStaticBase extends Command { protected function configure() { + parent::configure(); + $this->setName('OssStatic') ->setDescription('将静态资源上传到oss上'); } protected function execute(Input $input, Output $output) { - $output->writeln('========正在上传静态资源到OSS上:========' . date('Y-m-d H:i:s')); + try { + $start_time = date('Y-m-d H:i:s'); + $list = Filesystem::disk('local_static')->listContents('/', true); + $upload_service = new UploadService(); + $uploadPrefix = config('app.oss_static_prefix', 'oss_static_prefix'); - $list = Filesystem::disk('local_static')->listContents('/', true); - $upload_service = new UploadService(); - $uploadPrefix = config('app.oss_static_prefix', 'oss_static_prefix'); + $success_count = 0; + $failed_count = 0; - foreach ($list as $file_item) { - if ($file_item['type'] != 'file') { - continue; + foreach ($list as $file_item) { + if ($file_item['type'] != 'file') { + continue; + } + + $file_path = $file_item['path']; + + $file_path = Filesystem::disk('local_static')->path($file_path); + + $file = new File($file_path, false); + + $save_name = $file_item['path']; + try { + $model_file = $upload_service->save($file, $save_name, true, $uploadPrefix, true); + $success_count++; + $output->info('文件上传成功:' . $save_name . '。上传地址:' . $model_file['url']); + } catch (\Throwable $th) { + $failed_count++; + $output->error('文件上传失败:' . $save_name . '。错误信息:' . $th->getMessage()); + } } - $file_path = $file_item['path']; + // 文本模式输出 + $output->writeln('========正在上传静态资源到OSS上:========' . $start_time); + $output->writeln('========已完成静态资源上传到OSS上:========' . date('Y-m-d H:i:s')); + $output->writeln('总计: ' . ($success_count + $failed_count) . ' 个文件,成功: ' . $success_count . ' 个,失败: ' . $failed_count . ' 个'); - $file_path = Filesystem::disk('local_static')->path($file_path); - - $file = new File($file_path, false); - - $save_name = $file_item['path']; - try { - $model_file = $upload_service->save($file, $save_name, true, $uploadPrefix, true); - $output->info('文件上传成功:' . $save_name . '。上传地址:' . $model_file['url']); - } catch (\Throwable $th) { - $output->error('文件上传失败:' . $save_name . '。错误信息:' . $th->getMessage()); - } + return $failed_count > 0 ? 1 : 0; + } catch (\Throwable $e) { + throw $e; } - $output->writeln('========已完成静态资源上传到OSS上:========' . date('Y-m-d H:i:s')); } } diff --git a/extend/base/common/command/TestBase.php b/extend/base/common/command/TestBase.php index 28c4ff8..e306a8c 100644 --- a/extend/base/common/command/TestBase.php +++ b/extend/base/common/command/TestBase.php @@ -2,9 +2,9 @@ namespace base\common\command; +use app\common\console\Command; use app\common\interface\test\CommandTestInterface; use app\common\service\test\LogTestService; -use think\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; diff --git a/extend/base/common/command/TimerBase.php b/extend/base/common/command/TimerBase.php index 7c68e06..ddb599b 100644 --- a/extend/base/common/command/TimerBase.php +++ b/extend/base/common/command/TimerBase.php @@ -4,11 +4,11 @@ declare(strict_types=1); namespace base\common\command; +use app\common\console\Command; use app\common\service\HostService; use app\common\service\TimerService; use GuzzleHttp\Client; use GuzzleHttp\Promise\Utils; -use think\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -29,6 +29,8 @@ class TimerBase extends Command protected function configure() { + parent::configure(); + // 指令配置 $this->setName('timer') ->addOption('temp', null, Option::VALUE_NONE) @@ -41,52 +43,57 @@ class TimerBase extends Command protected function execute(Input $input, Output $output) { - // 指令输出 - $output->writeln('start timer'); + try { + // 指令输出 + $output->writeln('start timer'); - $site_domain = sysconfig('site', 'site_domain'); - if (empty($site_domain)) { - $output->writeln('请前往后台设置站点域名(site_domain)配置项'); + $site_domain = sysconfig('site', 'site_domain'); + if (empty($site_domain)) { + $output->writeln('请前往后台设置站点域名(site_domain)配置项'); - return; - } + return; + } - $host = $site_domain; + $host = $site_domain; - if ($input->hasOption('local')) { - $host = $input->getOption('local-host') . ':' . $input->getOption('local-port'); - } + if ($input->hasOption('local')) { + $host = $input->getOption('local-host') . ':' . $input->getOption('local-port'); + } - $output->writeln('站点域名:' . $host); - $site_host = parse_url($host, PHP_URL_HOST); + $output->writeln('站点域名:' . $host); + $site_host = parse_url($host, PHP_URL_HOST); - // 设置配置的任务 - $timer_service = new TimerService(); - $request_list = $timer_service->generateAllRequestList(); - $call_list = $timer_service->generateAllCallList(); + // 设置配置的任务 + $timer_service = new TimerService(); + $request_list = $timer_service->generateAllRequestList(); + $call_list = $timer_service->generateAllCallList(); - // 内置的节点注册任务 - $system_host_register = - [ - 'name' => 'system_host_register', // 定时任务的名称,不能重复 - 'type' => 'call', // 定时任务的类型,默认只支持site,你也可以重写定时器命令行以支持其他命令 - 'target' => [HostService::class, 'heartbeat'], // 要访问的地址,如果不是以https开头,那么以后台的系统配置中读取相关配置,如果没有配置则不执行 - 'frequency' => 30, // 执行频率,单位:秒,填写10,则每10秒过后执行一次 - ]; - $system_host_call_list = TimerService::generateTaskInstanceFromConfig($system_host_register); - $call_list = array_merge($call_list, $system_host_call_list); + // 内置的节点注册任务 + $system_host_register = + [ + 'name' => 'system_host_register', // 定时任务的名称,不能重复 + 'type' => 'call', // 定时任务的类型,默认只支持site,你也可以重写定时器命令行以支持其他命令 + 'target' => [HostService::class, 'heartbeat'], // 要访问的地址,如果不是以https开头,那么以后台的系统配置中读取相关配置,如果没有配置则不执行 + 'frequency' => 30, // 执行频率,单位:秒,填写10,则每10秒过后执行一次 + ]; + $system_host_call_list = TimerService::generateTaskInstanceFromConfig($system_host_register); + $call_list = array_merge($call_list, $system_host_call_list); - $this->host = $host; - $this->siteDomain = $site_domain; - $this->siteHost = $site_host; - $this->requestList = $request_list; - $this->callList = $call_list; + $this->host = $host; + $this->siteDomain = $site_domain; + $this->siteHost = $site_host; + $this->requestList = $request_list; + $this->callList = $call_list; - $timer_mode = Config::get('timer.mode', 'normal'); - if ($timer_mode == 'normal') { - $this->runNormal(); - } else { - $this->runParallel(); + // 文本模式:正常运行定时器 + $timer_mode = Config::get('timer.mode', 'normal'); + if ($timer_mode == 'normal') { + $this->runNormal(); + } else { + $this->runParallel(); + } + } catch (\Throwable $e) { + throw $e; } } diff --git a/extend/base/common/command/admin/ClearBase.php b/extend/base/common/command/admin/ClearBase.php index d272eb3..b2e362c 100644 --- a/extend/base/common/command/admin/ClearBase.php +++ b/extend/base/common/command/admin/ClearBase.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace base\common\command\admin; -use think\console\Command; +use app\common\console\Command; use think\console\Input; use think\console\Output; use think\facade\App; @@ -13,6 +13,8 @@ class ClearBase extends Command { protected function configure() { + parent::configure(); + // 指令配置 $this->setName('admin:clear') ->setDescription('删除开发临时生成目录'); @@ -20,31 +22,34 @@ class ClearBase extends Command protected function execute(Input $input, Output $output) { - // 指令输出 - $output->writeln('删除测试目录'); - + $dir = App::getRootPath() . '/runtime/source/'; + $deleted = false; + $message = ''; $command_line = ''; - $dir = App::getRootPath() . '/runtime/source/'; - if (!is_dir($dir)) { - $output->writeln('删除成功'); - - return; - } - - if (strpos(strtolower(PHP_OS), 'win') === 0) { - $command_line = implode(' ', ['rd', '/s', '/q', str_replace('/', '\\', $dir)]); + $deleted = true; + $message = '目录不存在,无需删除'; } else { - $command_line = implode(' ', ['rm', '-rf', $dir]); + if (strpos(strtolower(PHP_OS), 'win') === 0) { + $command_line = implode(' ', ['rd', '/s', '/q', str_replace('/', '\\', $dir)]); + } else { + $command_line = implode(' ', ['rm', '-rf', $dir]); + } + + exec($command_line); + $deleted = !is_dir($dir); + $message = $deleted ? '删除成功' : '删除失败'; } - $output->info('删除目录:' . $command_line); + // 文本模式输出 + $output->writeln('删除测试目录'); + if ($command_line) { + $output->info('删除目录:' . $command_line); + $output->info('run command: ' . $command_line); + } + $output->info($message); - $output->info('run command: ' . $command_line); - - exec($command_line); - - $output->info('删除成功'); + return $deleted ? 0 : 1; } } diff --git a/extend/base/common/command/admin/UpdateBase.php b/extend/base/common/command/admin/UpdateBase.php index 3a66bc4..72d7f8f 100644 --- a/extend/base/common/command/admin/UpdateBase.php +++ b/extend/base/common/command/admin/UpdateBase.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace base\common\command\admin; use app\admin\service\AdminUpdateService; -use think\console\Command; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -17,11 +17,13 @@ class UpdateBase extends Command protected function configure() { + parent::configure(); + // 指令配置 $this->setName('admin:update') ->addOption('reinstall', null, Option::VALUE_NONE, '重装版本') - ->addOption('update-ulthon', null, Option::VALUE_NONE, '重装版本') - ->setDescription('the admin:update command'); + ->addOption('update-ulthon', null, Option::VALUE_NONE, '更新 ulthon_admin') + ->setDescription('更新系统代码'); } protected function execute(Input $input, Output $output) @@ -38,6 +40,7 @@ class UpdateBase extends Command $update_service = new AdminUpdateService($repo); $update_service->input = $input; $update_service->output = $output; + $update_service->update(); } } diff --git a/extend/base/common/command/admin/VersionBase.php b/extend/base/common/command/admin/VersionBase.php index 483b3cd..d670f16 100644 --- a/extend/base/common/command/admin/VersionBase.php +++ b/extend/base/common/command/admin/VersionBase.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace base\common\command\admin; +use app\common\console\Command; use app\common\tools\PathTools; use think\App as ThinkApp; -use think\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -85,6 +85,8 @@ class VersionBase extends Command protected function configure() { + parent::configure(); + // 指令配置 $this->setName('admin:version') ->addOption('generate-comment', null, Option::VALUE_NONE, '使用git命令生成说明文件') @@ -96,6 +98,7 @@ class VersionBase extends Command protected function execute(Input $input, Output $output) { + // 文本模式输出 // 指令输出 if (!empty(static::PRODUCT_VERSION)) { $output->info('当前版本号为:' . static::PRODUCT_VERSION); diff --git a/extend/base/common/command/admin/menu/AdminMenuCreateBase.php b/extend/base/common/command/admin/menu/AdminMenuCreateBase.php new file mode 100644 index 0000000..5b6e946 --- /dev/null +++ b/extend/base/common/command/admin/menu/AdminMenuCreateBase.php @@ -0,0 +1,106 @@ +setName('admin:menu:create') + ->addOption('title', null, Option::VALUE_REQUIRED, '菜单标题(必填)') + ->addOption('path', null, Option::VALUE_OPTIONAL, '菜单路径', '') + ->addOption('icon', null, Option::VALUE_OPTIONAL, '菜单图标', '') + ->addOption('parent-id', null, Option::VALUE_OPTIONAL, '父菜单ID(默认为0)', 0) + ->addOption('sort', null, Option::VALUE_OPTIONAL, '排序(默认为100)', 100) + ->addOption('node', null, Option::VALUE_OPTIONAL, '权限节点', '') + ->addOption('remark', null, Option::VALUE_OPTIONAL, '备注说明', '') + ->setDescription('创建菜单'); + } + + protected function execute(Input $input, Output $output) + { + // 获取参数 + $title = $input->getOption('title'); + $path = $input->getOption('path'); + $icon = $input->getOption('icon'); + $parentId = $input->getOption('parent-id'); + $sort = $input->getOption('sort'); + $node = $input->getOption('node'); + $remark = $input->getOption('remark'); + + // 验证必填参数 + if (empty($title)) { + $output->error('菜单标题不能为空'); + return false; + } + + try { + // 准备菜单数据 + $menuData = [ + 'title' => $title, + 'path' => $path, + 'icon' => $icon, + 'parent_id' => (int)$parentId, + 'sort' => (int)$sort, + 'node' => $node, + 'remark' => $remark, + ]; + + // 实例化菜单服务(使用 app\admin\service\MenuService,它继承自 base\common\service\MenuServiceBase) + // 由于 MenuServiceBase 需要 adminId 参数,我们使用 0 表示命令行操作 + $menuService = new \app\common\service\MenuService(0); + + // 创建菜单 + $menuId = $menuService->create($menuData); + + // 获取创建的菜单详情 + $menuModel = SystemMenu::find($menuId); + $menu = $menuModel ? $menuModel->toArray() : null; + + if (empty($menu)) { + throw new \Exception('菜单创建成功,但无法获取菜单详情'); + } + + $outputData = [ + 'id' => (int)$menu['id'], + 'title' => $menu['title'] ?? '', + 'path' => $menu['href'] ?? '', + 'icon' => $menu['icon'] ?? '', + 'parent_id' => (int)($menu['pid'] ?? 0), + 'sort' => (int)($menu['sort'] ?? 0), + 'node' => $menu['auth_node'] ?? '', + ]; + + // 输出结果 + $output->info('菜单创建成功'); + $output->info('菜单ID: ' . $outputData['id']); + $output->info('菜单标题: ' . $outputData['title']); + $output->info('菜单路径: ' . $outputData['path']); + $output->info('菜单图标: ' . $outputData['icon']); + $output->info('父菜单ID: ' . $outputData['parent_id']); + $output->info('排序: ' . $outputData['sort']); + if (!empty($outputData['node'])) { + $output->info('权限节点: ' . $outputData['node']); + } + } catch (\Throwable $e) { + $output->error('创建菜单失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/menu/AdminMenuDeleteBase.php b/extend/base/common/command/admin/menu/AdminMenuDeleteBase.php new file mode 100644 index 0000000..6aeea08 --- /dev/null +++ b/extend/base/common/command/admin/menu/AdminMenuDeleteBase.php @@ -0,0 +1,84 @@ +setName('admin:menu:delete') + ->addOption('id', null, Option::VALUE_REQUIRED, '菜单ID') + ->setDescription('删除菜单'); + } + + protected function execute(Input $input, Output $output) + { + // 获取参数 + $id = $input->getOption('id'); + + // 验证参数 + if (empty($id)) { + $output->error('菜单ID不能为空'); + return false; + } + + try { + // 1. 验证菜单是否存在 + $menu = SystemMenu::find($id); + if (empty($menu)) { + $output->error('菜单ID ' . $id . ' 不存在'); + return false; + } + + // 2. 检查是否有子菜单 + $childCount = SystemMenu::where('pid', $id) + ->where('delete_time', 0) + ->count(); + + if ($childCount > 0) { + $output->error('菜单ID ' . $id . ' 存在 ' . $childCount . ' 个子菜单,请先删除子菜单'); + return false; + } + + // 3. 保存菜单信息用于输出 + $menuInfo = [ + 'id' => (int)$menu->id, + 'title' => $menu->title, + 'path' => $menu->href ?? '', + 'icon' => $menu->icon ?? '', + 'parent_id' => (int)($menu->pid ?? 0), + 'sort' => (int)($menu->sort ?? 0), + 'node' => $menu->auth_node ?? '', + ]; + + // 4. 执行删除(软删除) + $menu->delete(); + + // 5. 输出结果 + $output->info('菜单删除成功'); + $output->info('菜单ID: ' . $menuInfo['id']); + $output->info('菜单名称: ' . $menuInfo['title']); + $output->info('菜单路径: ' . $menuInfo['path']); + + } catch (\Throwable $e) { + $output->error('删除菜单失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/menu/AdminMenuExportBase.php b/extend/base/common/command/admin/menu/AdminMenuExportBase.php new file mode 100644 index 0000000..2d841c7 --- /dev/null +++ b/extend/base/common/command/admin/menu/AdminMenuExportBase.php @@ -0,0 +1,127 @@ +setName('admin:menu:export') + ->setDescription('导出菜单数据') + ->addOption('format', null, Option::VALUE_OPTIONAL, '输出格式(text/json)', 'text') + ->addOption('output', null, Option::VALUE_OPTIONAL, '输出文件路径(可选)'); + } + + protected function execute(Input $input, Output $output) + { + // 获取参数 + $outputPath = $input->getOption('output'); + + try { + // 1. 查询所有菜单 + $menus = SystemMenu::where('delete_time', 0) + ->order(['sort' => 'desc', 'id' => 'asc']) + ->select() + ->hidden(['create_time', 'update_time', 'delete_time']); + + // 2. 格式化菜单数据 + $menuData = []; + foreach ($menus as $menu) { + $menuData[] = [ + 'id' => (int)$menu->id, + 'pid' => (int)($menu->pid ?? 0), + 'title' => $menu->title ?? '', + 'href' => $menu->href ?? '', + 'icon' => $menu->icon ?? '', + 'sort' => (int)($menu->sort ?? 0), + 'auth_node' => $menu->auth_node ?? '', + 'status' => (int)($menu->status ?? 0), + 'type' => (int)($menu->type ?? 0), + ]; + } + + // JSON 模式输出 + if ($input->getOption('format') === 'json') { + if (!empty($outputPath)) { + $jsonData = json_encode($menuData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + if ($jsonData === false) { + throw new \RuntimeException('JSON 编码失败'); + } + + $result = file_put_contents($outputPath, $jsonData); + if ($result === false) { + throw new \RuntimeException('无法写入文件: ' . $outputPath); + } + } + + $json = json_encode([ + 'success' => true, + 'data' => [ + 'menus' => $menuData, + 'output' => !empty($outputPath) ? $outputPath : null, + ], + 'warnings' => [], + 'metadata' => [ + 'count' => count($menuData), + 'exported_at' => date('c'), + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $output->writeln($json); + + return true; + } + + // 3. 如果指定了输出路径,写入文件 + if (!empty($outputPath)) { + $jsonData = json_encode($menuData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $result = file_put_contents($outputPath, $jsonData); + + if ($result === false) { + $output->error('无法写入文件: ' . $outputPath); + return false; + } + + $output->info('成功导出 ' . count($menuData) . ' 个菜单到文件: ' . $outputPath); + } else { + // 4. 输出结果到控制台 + $output->info('成功导出 ' . count($menuData) . ' 个菜单'); + + // 以表格形式输出菜单数据 + $output->writeln('菜单ID | 父菜单ID | 菜单标题 | 菜单路径 | 图标 | 排序 | 权限节点 | 状态 | 类型'); + $output->writeln('--------|----------|----------|----------|------|------|----------|------|------'); + + foreach ($menuData as $menu) { + $output->writeln($menu['id'] . ' | ' . + $menu['pid'] . ' | ' . + $menu['title'] . ' | ' . + $menu['href'] . ' | ' . + $menu['icon'] . ' | ' . + $menu['sort'] . ' | ' . + $menu['auth_node'] . ' | ' . + $menu['status'] . ' | ' . + $menu['type']); + } + } + + } catch (\Throwable $e) { + $output->error('导出菜单失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/menu/AdminMenuListBase.php b/extend/base/common/command/admin/menu/AdminMenuListBase.php new file mode 100644 index 0000000..a28644a --- /dev/null +++ b/extend/base/common/command/admin/menu/AdminMenuListBase.php @@ -0,0 +1,132 @@ +setName('admin:menu:list') + ->setDescription('列出所有菜单(树形结构)') + ->addOption('format', null, Option::VALUE_OPTIONAL, '输出格式(tree/table/json)', 'tree') + ->addOption('status', null, Option::VALUE_OPTIONAL, '筛选状态(0=禁用,1=启用,all=全部)', 'all') + ->addOption('pid', null, Option::VALUE_OPTIONAL, '指定父菜单ID(默认从根菜单开始)', null); + } + + protected function execute(Input $input, Output $output) + { + $format = $input->getOption('format'); + $statusFilter = $input->getOption('status'); + $pidFilter = $input->getOption('pid'); + + try { + $query = SystemMenu::where('delete_time', 0); + + if ($statusFilter !== 'all') { + $query->where('status', (int)$statusFilter); + } + + $menus = $query->order(['sort' => 'desc', 'id' => 'asc']) + ->select() + ->toArray(); + + if (empty($menus)) { + $output->comment('没有找到菜单数据'); + return true; + } + + $menuTree = $this->buildTree($menus, $pidFilter ? (int)$pidFilter : 0); + + switch ($format) { + case 'json': + $this->outputJson($output, $menuTree, count($menus)); + break; + case 'table': + $this->outputTable($output, $menus); + break; + case 'tree': + default: + $this->outputTree($output, $menuTree); + break; + } + + } catch (\Throwable $e) { + $output->error('获取菜单列表失败: ' . $e->getMessage()); + return false; + } + + return true; + } + + protected function buildTree(array $menus, int $pid = 0): array + { + $tree = []; + foreach ($menus as $menu) { + if ((int)$menu['pid'] === $pid) { + $children = $this->buildTree($menus, (int)$menu['id']); + if (!empty($children)) { + $menu['children'] = $children; + } + $tree[] = $menu; + } + } + return $tree; + } + + protected function outputTree(Output $output, array $tree, int $level = 0): void + { + $prefix = str_repeat(' ', $level); + $branch = $level > 0 ? '├─ ' : ''; + + foreach ($tree as $node) { + $status = $node['status'] ? '' : ''; + $href = $node['href'] ?? '-'; + $output->writeln("{$prefix}{$branch}{$status} [{$node['id']}] {$node['title']} ({$href})"); + + if (!empty($node['children'])) { + $this->outputTree($output, $node['children'], $level + 1); + } + } + } + + protected function outputTable(Output $output, array $menus): void + { + $output->writeln('ID | PID | 标题 | 路径 | 状态'); + $output->writeln('---|-----|------|------|------'); + + foreach ($menus as $menu) { + $status = $menu['status'] ? '启用' : '禁用'; + $output->writeln(sprintf( + '%d | %d | %s | %s | %s', + $menu['id'], + $menu['pid'], + $menu['title'], + $menu['href'] ?? '-', + $status + )); + } + + $output->writeln(''); + $output->info('共 ' . count($menus) . ' 个菜单'); + } + + protected function outputJson(Output $output, array $tree, int $total): void + { + $data = [ + 'success' => true, + 'data' => ['menus' => $tree], + 'metadata' => ['total' => $total], + ]; + $output->writeln(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } +} diff --git a/extend/base/common/command/admin/menu/AdminMenuUpdateBase.php b/extend/base/common/command/admin/menu/AdminMenuUpdateBase.php new file mode 100644 index 0000000..064cb61 --- /dev/null +++ b/extend/base/common/command/admin/menu/AdminMenuUpdateBase.php @@ -0,0 +1,129 @@ +setName('admin:menu:update') + ->addOption('id', null, Option::VALUE_REQUIRED, '菜单ID(必填)') + ->addOption('title', null, Option::VALUE_OPTIONAL, '菜单标题') + ->addOption('path', null, Option::VALUE_OPTIONAL, '菜单路径') + ->addOption('icon', null, Option::VALUE_OPTIONAL, '菜单图标') + ->addOption('parent-id', null, Option::VALUE_OPTIONAL, '父级菜单ID') + ->addOption('sort', null, Option::VALUE_OPTIONAL, '排序') + ->setDescription('编辑菜单'); + } + + protected function execute(Input $input, Output $output) + { + // 获取参数 + $id = (int)($input->getOption('id') ?? 0); + $title = $input->getOption('title'); + $path = $input->getOption('path'); + $icon = $input->getOption('icon'); + $parentId = $input->getOption('parent-id'); + $sort = $input->getOption('sort'); + + // 验证参数:id 是必填的 + if (empty($id)) { + $output->error('菜单ID不能为空'); + return false; + } + + try { + // 1. 验证菜单是否存在 + $menu = SystemMenu::find($id); + if (empty($menu)) { + $output->writeln('菜单ID ' . $id . ' 不存在'); + return false; + } + + // 2. 准备更新数据 + $updateData = []; + + if ($title !== null) { + $updateData['title'] = $title; + } + + if ($path !== null) { + $updateData['path'] = $path; + } + + if ($icon !== null) { + $updateData['icon'] = $icon; + } + + if ($parentId !== null) { + $updateData['parent_id'] = (int)$parentId; + } + + if ($sort !== null) { + $updateData['sort'] = (int)$sort; + } + + // 检查是否有需要更新的字段 + if (empty($updateData)) { + $output->error('没有提供需要更新的字段'); + return false; + } + + // 3. 调用 MenuService 更新菜单 + $menuService = new MenuService(0); + $success = $menuService->update($id, $updateData); + + if (!$success) { + $output->writeln('菜单更新失败'); + return false; + } + + // 4. 重新查询菜单数据 + $updatedMenu = SystemMenu::find($id); + if (empty($updatedMenu)) { + $output->writeln('菜单更新后查询失败'); + return false; + } + + $outputData = [ + 'id' => (int)$updatedMenu->id, + 'title' => $updatedMenu->title ?? '', + 'path' => $updatedMenu->href ?? '', + 'icon' => $updatedMenu->icon ?? '', + 'parent_id' => (int)($updatedMenu->pid ?? 0), + 'sort' => (int)($updatedMenu->sort ?? 0), + 'node' => $updatedMenu->auth_node ?? '', + ]; + + // 5. 输出结果 + $output->writeln('菜单编辑成功'); + $output->writeln('菜单ID: ' . $outputData['id'] . ''); + $output->writeln('菜单标题: ' . $outputData['title'] . ''); + $output->writeln('菜单路径: ' . $outputData['path'] . ''); + $output->writeln('菜单图标: ' . $outputData['icon'] . ''); + $output->writeln('父级菜单ID: ' . $outputData['parent_id'] . ''); + $output->writeln('排序: ' . $outputData['sort'] . ''); + + } catch (\Throwable $e) { + $output->error('编辑菜单失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/permission/AdminPermissionNodesBase.php b/extend/base/common/command/admin/permission/AdminPermissionNodesBase.php new file mode 100644 index 0000000..ddbadb1 --- /dev/null +++ b/extend/base/common/command/admin/permission/AdminPermissionNodesBase.php @@ -0,0 +1,99 @@ +setName('admin:permission:nodes') + ->setDescription('查看权限节点') + ->addOption('level', null, Option::VALUE_OPTIONAL, '按级别过滤 (1=控制器, 2=方法)') + ->addOption('module', null, Option::VALUE_OPTIONAL, '按模块过滤'); + } + + /** + * 执行命令 + * + * @param Input $input + * @param Output $output + * @return int + * @throws \Doctrine\Common\Annotations\AnnotationException + * @throws \ReflectionException + */ + protected function execute(Input $input, Output $output) + { + try { + // 获取所有权限节点 + $nodeService = new NodeService(); + $nodeList = $nodeService->getNodelist(); + + // 过滤条件 + $filterLevel = $input->getOption('level'); + $filterModule = $input->getOption('module'); + + // 应用过滤 + $filteredNodes = $nodeList; + if ($filterLevel !== null) { + $level = (int)$filterLevel; + $filteredNodes = array_filter($filteredNodes, function ($node) use ($level) { + return isset($node['type']) && $node['type'] === $level; + }); + } + + if ($filterModule !== null) { + $filteredNodes = array_filter($filteredNodes, function ($node) use ($filterModule) { + return isset($node['module']) && $node['module'] === $filterModule; + }); + } + + // 重新索引数组 + $filteredNodes = array_values($filteredNodes); + + // 输出表头 + $output->info('权限节点列表'); + $output->writeln('================================'); + + if (empty($filteredNodes)) { + $output->comment('没有找到符合条件的权限节点'); + return 0; + } + + // 输出权限节点信息 + foreach ($filteredNodes as $node) { + $levelText = $node['type'] == 1 ? '控制器' : '方法'; + $authText = isset($node['auth']) && $node['auth'] ? '是' : '否'; + + $output->info('节点: ' . ($node['node'] ?? '')); + $output->comment(' 标题: ' . ($node['title'] ?? '')); + $output->comment(' 级别: ' . $levelText . ' (' . ($node['type'] ?? 0) . ')'); + $output->comment(' 模块: ' . ($node['module'] ?? '')); + $output->comment(' 需要授权: ' . $authText); + $output->writeln('--------------------------------'); + } + + $output->info('总计: ' . count($filteredNodes) . ' 个权限节点'); + + } catch (\Exception $e) { + $output->error('获取权限节点失败: ' . $e->getMessage()); + return 1; + } + } +} \ No newline at end of file diff --git a/extend/base/common/command/admin/permission/AdminPermissionUserBase.php b/extend/base/common/command/admin/permission/AdminPermissionUserBase.php new file mode 100644 index 0000000..c1c8672 --- /dev/null +++ b/extend/base/common/command/admin/permission/AdminPermissionUserBase.php @@ -0,0 +1,95 @@ +setName('admin:permission:user') + ->addOption('user-id', 'u', Option::VALUE_OPTIONAL, '用户ID', null) + ->setDescription('查看用户权限'); + } + + protected function execute(Input $input, Output $output) + { + $userId = $input->getOption('user-id'); + + // 验证用户ID参数 + if (empty($userId)) { + $output->error('缺少必要参数 --user-id'); + return; + } + + // 查询用户 + $user = SystemAdmin::find($userId); + + if (empty($user)) { + $output->error('用户不存在'); + return; + } + + // 查询用户角色 + $roles = []; + $authIds = $user->auth_ids; + $authIdArray = []; + + if (!empty($authIds)) { + $authIdArray = array_filter(explode(',', $authIds)); + + if (!empty($authIdArray)) { + $roles = SystemAuth::whereIn('id', $authIdArray) + ->where('delete_time', 0) + ->field('id,title') + ->select() + ->toArray(); + } + } + + // 查询用户权限节点 + $nodes = []; + if (!empty($authIdArray)) { + $nodes = SystemAuthNode::whereIn('auth_id', $authIdArray) + ->column('node'); + } + + // 输出结果 + $output->info('用户权限信息'); + $output->info('用户ID: ' . $user->id); + $output->info('用户名: ' . $user->username); + + if (!empty($roles)) { + $output->info('角色列表:'); + foreach ($roles as $role) { + $output->comment(' - ID: ' . $role['id'] . ', 标题: ' . $role['title']); + } + } else { + $output->comment('用户没有分配任何角色'); + } + + if (!empty($nodes)) { + $output->info('权限节点列表:'); + foreach ($nodes as $node) { + $output->comment(' - ' . $node); + } + } else { + $output->comment('用户没有分配任何权限节点'); + } + + return; + } +} \ No newline at end of file diff --git a/extend/base/common/command/admin/role/AdminRoleCreateBase.php b/extend/base/common/command/admin/role/AdminRoleCreateBase.php new file mode 100644 index 0000000..fbbb119 --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRoleCreateBase.php @@ -0,0 +1,53 @@ +setName('admin:role:create') + ->addOption('title', null, Option::VALUE_REQUIRED, '角色名称') + ->addOption('remark', null, Option::VALUE_OPTIONAL, '角色备注') + ->setDescription('创建角色'); + } + + protected function execute(Input $input, Output $output) + { + $title = $input->getOption('title'); + $remark = $input->getOption('remark'); + + if (empty($title)) { + $output->error('角色名称不能为空'); + return false; + } + + try { + $auth = new SystemAuth(); + $auth->title = $title; + $auth->remark = $remark ?? ''; + $auth->sort = 0; + $auth->status = 1; + $auth->save(); + + $output->info('角色创建成功'); + $output->info('角色ID: ' . $auth->id); + $output->info('角色名称: ' . $auth->title); + } catch (\Throwable $e) { + $output->error('创建角色失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/role/AdminRoleDeleteBase.php b/extend/base/common/command/admin/role/AdminRoleDeleteBase.php new file mode 100644 index 0000000..bcf7a08 --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRoleDeleteBase.php @@ -0,0 +1,63 @@ +setName('admin:role:delete') + ->addOption('role-id', null, Option::VALUE_REQUIRED, '角色ID') + ->setDescription('删除角色'); + } + + protected function execute(Input $input, Output $output) + { + $roleId = $input->getOption('role-id'); + + if (empty($roleId)) { + $output->error('角色ID不能为空'); + return false; + } + + try { + $auth = SystemAuth::find($roleId); + if (empty($auth)) { + $output->error('角色ID ' . $roleId . ' 不存在'); + return false; + } + + $checkUsers = SystemAdmin::where('auth_ids', 'like', '%' . $roleId . '%') + ->where('delete_time', 0) + ->count(); + + if ($checkUsers > 0) { + $output->error('角色ID ' . $roleId . ' 已分配给 ' . $checkUsers . ' 个用户,无法删除'); + return false; + } + + SystemAuthNode::where('auth_id', $roleId)->delete(); + $auth->delete(); + + $output->info('角色删除成功'); + $output->info('角色ID: ' . $roleId); + } catch (\Throwable $e) { + $output->error('删除角色失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/role/AdminRoleInfoBase.php b/extend/base/common/command/admin/role/AdminRoleInfoBase.php new file mode 100644 index 0000000..676f429 --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRoleInfoBase.php @@ -0,0 +1,65 @@ +setName('admin:role:info') + ->addOption('role-id', null, Option::VALUE_REQUIRED, '角色ID') + ->setDescription('查看角色详情'); + } + + protected function execute(Input $input, Output $output) + { + $roleId = $input->getOption('role-id'); + + if (empty($roleId)) { + $output->error('角色ID不能为空'); + return false; + } + + try { + $role = SystemAuth::find($roleId); + if (empty($role)) { + $output->error('角色ID ' . $roleId . ' 不存在'); + return false; + } + + $nodes = SystemAuthNode::where('auth_id', $roleId) + ->column('node'); + + $output->info('角色详情'); + $output->writeln('================================'); + $output->info('角色ID: ' . $role->id); + $output->comment('角色名称: ' . $role->title); + $output->comment('状态: ' . ($role->status == 1 ? '启用' : '禁用')); + $output->comment('排序: ' . $role->sort); + if (!empty($role->remark)) { + $output->comment('备注: ' . $role->remark); + } + $output->writeln('--------------------------------'); + $output->info('权限节点列表 (' . count($nodes) . ' 个):'); + foreach ($nodes as $node) { + $output->comment(' - ' . $node); + } + } catch (\Throwable $e) { + $output->error('获取角色详情失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/role/AdminRoleListBase.php b/extend/base/common/command/admin/role/AdminRoleListBase.php new file mode 100644 index 0000000..6a07e84 --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRoleListBase.php @@ -0,0 +1,64 @@ +setName('admin:role:list') + ->setDescription('查看角色列表'); + } + + protected function execute(Input $input, Output $output) + { + try { + $roles = SystemAuth::where('delete_time', 0) + ->field('id,title,sort,status,remark') + ->order('sort', 'asc') + ->order('id', 'desc') + ->select() + ->toArray(); + + if (empty($roles)) { + $output->comment('没有找到任何角色'); + return true; + } + + $output->info('角色列表'); + $output->writeln('================================'); + + foreach ($roles as $role) { + $nodeCount = SystemAuthNode::where('auth_id', $role['id'])->count(); + $statusText = $role['status'] == 1 ? '启用' : '禁用'; + + $output->info('角色ID: ' . $role['id']); + $output->comment(' 角色名称: ' . $role['title']); + $output->comment(' 权限节点数: ' . $nodeCount); + $output->comment(' 状态: ' . $statusText); + $output->comment(' 排序: ' . $role['sort']); + if (!empty($role['remark'])) { + $output->comment(' 备注: ' . $role['remark']); + } + $output->writeln('--------------------------------'); + } + + $output->info('总计: ' . count($roles) . ' 个角色'); + } catch (\Throwable $e) { + $output->error('获取角色列表失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/role/AdminRolePermissionAssignBase.php b/extend/base/common/command/admin/role/AdminRolePermissionAssignBase.php new file mode 100644 index 0000000..d5cc31c --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRolePermissionAssignBase.php @@ -0,0 +1,127 @@ +setName('admin:role:permission:assign') + ->addOption('role-id', null, Option::VALUE_REQUIRED, '角色ID') + ->addOption('nodes', null, Option::VALUE_REQUIRED, '权限节点列表(逗号分隔)') + ->setDescription('给角色分配权限'); + } + + protected function execute(Input $input, Output $output) + { + $roleId = $input->getOption('role-id'); + $nodesParam = $input->getOption('nodes'); + + if (empty($roleId)) { + $output->error('角色ID不能为空'); + return false; + } + + if (empty($nodesParam)) { + $output->error('权限节点不能为空'); + return false; + } + + try { + $auth = SystemAuth::find($roleId); + if (empty($auth)) { + $output->error('角色ID ' . $roleId . ' 不存在'); + return false; + } + + $nodes = $this->parseNodes($nodesParam); + if (empty($nodes)) { + $output->error('权限节点列表为空'); + return false; + } + + $validNodes = $this->validateNodes($nodes); + $invalidNodes = array_diff($nodes, $validNodes); + + if (!empty($invalidNodes)) { + $invalidCount = count($invalidNodes); + $output->error("[错误] 发现 {$invalidCount} 个无效的权限节点:"); + foreach (array_values($invalidNodes) as $node) { + $output->error(" - {$node}"); + } + $output->writeln(''); + } + + if (empty($validNodes)) { + $output->error("有效权限节点数: 0"); + $output->error("分配失败"); + return false; + } + + $existingNodes = SystemAuthNode::where('auth_id', $roleId) + ->column('node'); + $newNodes = array_diff($validNodes, $existingNodes); + + if (empty($newNodes)) { + $output->info('所有权限节点已存在,无需重复分配'); + return true; + } + + $authNodes = []; + foreach ($newNodes as $node) { + $authNodes[] = [ + 'auth_id' => $roleId, + 'node' => $node, + ]; + } + + (new SystemAuthNode())->saveAll($authNodes); + + $output->info('权限分配成功'); + $output->info('角色ID: ' . $roleId); + $output->info('新增的权限节点: ' . implode(', ', $newNodes)); + $output->info('该角色总权限节点数: ' . (count($existingNodes) + count($newNodes))); + } catch (\Throwable $e) { + $output->error('分配权限失败: ' . $e->getMessage()); + return false; + } + + return true; + } + + protected function parseNodes(string $nodesParam): array + { + $nodes = explode(',', $nodesParam); + $nodes = array_map('trim', $nodes); + $nodes = array_filter($nodes); + + return array_values($nodes); + } + + protected function validateNodes(array $nodes): array + { + $nodeService = new NodeService(); + $allNodes = $nodeService->getNodeParis(); + + $validNodes = []; + foreach ($nodes as $node) { + if (isset($allNodes[$node])) { + $validNodes[] = $node; + } + } + + return $validNodes; + } +} diff --git a/extend/base/common/command/admin/role/AdminRolePermissionListBase.php b/extend/base/common/command/admin/role/AdminRolePermissionListBase.php new file mode 100644 index 0000000..a50741c --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRolePermissionListBase.php @@ -0,0 +1,65 @@ +setName('admin:role:permission:list') + ->addOption('role-id', null, Option::VALUE_REQUIRED, '角色ID') + ->setDescription('查看角色权限'); + } + + protected function execute(Input $input, Output $output) + { + $roleId = $input->getOption('role-id'); + + if (empty($roleId)) { + $output->error('角色ID不能为空'); + return false; + } + + try { + $role = SystemAuth::find($roleId); + if (empty($role)) { + $output->error('角色ID ' . $roleId . ' 不存在'); + return false; + } + + $nodes = SystemAuthNode::where('auth_id', $roleId) + ->column('node'); + + $output->info('角色权限列表'); + $output->writeln('================================'); + $output->info('角色ID: ' . $role->id); + $output->comment('角色名称: ' . $role->title); + $output->writeln('--------------------------------'); + + if (empty($nodes)) { + $output->comment('该角色没有分配任何权限节点'); + } else { + $output->info('权限节点 (' . count($nodes) . ' 个):'); + foreach ($nodes as $node) { + $output->comment(' - ' . $node); + } + } + } catch (\Throwable $e) { + $output->error('获取角色权限失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/role/AdminRolePermissionRevokeBase.php b/extend/base/common/command/admin/role/AdminRolePermissionRevokeBase.php new file mode 100644 index 0000000..fc92689 --- /dev/null +++ b/extend/base/common/command/admin/role/AdminRolePermissionRevokeBase.php @@ -0,0 +1,113 @@ +setName('admin:role:permission:revoke') + ->addOption('role-id', null, Option::VALUE_REQUIRED, '角色ID') + ->addOption('nodes', null, Option::VALUE_REQUIRED, '权限节点列表(逗号分隔)') + ->setDescription('撤回角色权限'); + } + + protected function execute(Input $input, Output $output) + { + $roleId = $input->getOption('role-id'); + $nodesParam = $input->getOption('nodes'); + + if (empty($roleId)) { + $output->error('角色ID不能为空'); + return false; + } + + if (empty($nodesParam)) { + $output->error('权限节点不能为空'); + return false; + } + + try { + $auth = SystemAuth::find($roleId); + if (empty($auth)) { + $output->error('角色ID ' . $roleId . ' 不存在'); + return false; + } + + $nodes = $this->parseNodes($nodesParam); + if (empty($nodes)) { + $output->error('权限节点列表为空'); + return false; + } + + $validNodes = $this->validateNodes($nodes); + $invalidNodes = array_diff($nodes, $validNodes); + + if (!empty($invalidNodes)) { + $output->comment('警告: 以下权限节点无效: ' . implode(', ', array_values($invalidNodes))); + } + + if (empty($validNodes)) { + $output->error('没有有效的权限节点'); + return false; + } + + $deletedCount = SystemAuthNode::where('auth_id', $roleId) + ->where('node', 'in', $validNodes) + ->delete(); + + if ($deletedCount == 0) { + $output->error('没有权限可以撤回(角色不包含指定的权限节点)'); + return false; + } + + $remainingCount = SystemAuthNode::where('auth_id', $roleId)->count(); + + $output->info('权限撤回成功'); + $output->info('角色ID: ' . $roleId); + $output->info('撤回的权限节点: ' . implode(', ', $validNodes)); + $output->info('该角色剩余权限节点数: ' . $remainingCount); + } catch (\Throwable $e) { + $output->error('撤回权限失败: ' . $e->getMessage()); + return false; + } + + return true; + } + + protected function parseNodes(string $nodesParam): array + { + $nodes = explode(',', $nodesParam); + $nodes = array_map('trim', $nodes); + $nodes = array_filter($nodes); + + return array_values($nodes); + } + + protected function validateNodes(array $nodes): array + { + $nodeService = new NodeService(); + $allNodes = $nodeService->getNodeParis(); + + $validNodes = []; + foreach ($nodes as $node) { + if (isset($allNodes[$node])) { + $validNodes[] = $node; + } + } + + return $validNodes; + } +} diff --git a/extend/base/common/command/admin/user/AdminUserRoleAssignBase.php b/extend/base/common/command/admin/user/AdminUserRoleAssignBase.php new file mode 100644 index 0000000..f4d3cf1 --- /dev/null +++ b/extend/base/common/command/admin/user/AdminUserRoleAssignBase.php @@ -0,0 +1,121 @@ +setName('admin:user:role:assign') + ->addOption('user-id', null, Option::VALUE_REQUIRED, '用户ID') + ->addOption('role-ids', null, Option::VALUE_REQUIRED, '角色ID列表(逗号分隔)') + ->setDescription('给用户分配角色'); + } + + protected function execute(Input $input, Output $output) + { + $userId = $input->getOption('user-id'); + $roleIdsParam = $input->getOption('role-ids'); + + if (empty($userId)) { + $output->error('用户ID不能为空'); + return false; + } + + if (empty($roleIdsParam)) { + $output->error('角色ID不能为空'); + return false; + } + + try { + $admin = SystemAdmin::find($userId); + if (empty($admin)) { + $output->error('用户ID ' . $userId . ' 不存在'); + return false; + } + + $roleIds = $this->parseRoleIds($roleIdsParam); + if (empty($roleIds)) { + $output->error('角色ID列表为空'); + return false; + } + + $validRoleIds = $this->validateRoleIds($roleIds); + $invalidRoleIds = array_diff($roleIds, $validRoleIds); + + if (!empty($invalidRoleIds)) { + $invalidCount = count($invalidRoleIds); + $output->error("[错误] 发现 {$invalidCount} 个无效的角色ID:"); + foreach (array_values($invalidRoleIds) as $roleId) { + $output->error(" - {$roleId}"); + } + $output->writeln(''); + } + + if (empty($validRoleIds)) { + $output->error("有效角色ID数: 0"); + $output->error("分配失败"); + return false; + } + + $existingAuthIds = $admin->auth_ids; + $existingAuthIdArray = []; + if (!empty($existingAuthIds)) { + $existingAuthIdArray = array_filter(explode(',', $existingAuthIds)); + } + + $newRoleIds = array_diff($validRoleIds, $existingAuthIdArray); + + if (empty($newRoleIds)) { + $output->info('所有角色已存在,无需重复分配'); + return true; + } + + $mergedRoleIds = array_merge($existingAuthIdArray, $newRoleIds); + $mergedRoleIds = array_values(array_unique($mergedRoleIds)); + + $admin->auth_ids = implode(',', $mergedRoleIds); + $admin->save(); + + $output->info('角色分配成功'); + $output->info('用户ID: ' . $userId); + $output->info('新增的角色ID: ' . implode(', ', $newRoleIds)); + $output->info('该用户总角色数: ' . count($mergedRoleIds)); + $output->info('所有角色ID: ' . implode(', ', $mergedRoleIds)); + } catch (\Throwable $e) { + $output->error('分配角色失败: ' . $e->getMessage()); + return false; + } + + return true; + } + + protected function parseRoleIds(string $roleIdsParam): array + { + $roleIds = explode(',', $roleIdsParam); + $roleIds = array_map('trim', $roleIds); + $roleIds = array_filter($roleIds); + + return array_values($roleIds); + } + + protected function validateRoleIds(array $roleIds): array + { + $validRoleIds = SystemAuth::whereIn('id', $roleIds) + ->where('delete_time', 0) + ->column('id'); + + return $validRoleIds; + } +} diff --git a/extend/base/common/command/admin/user/AdminUserRoleListBase.php b/extend/base/common/command/admin/user/AdminUserRoleListBase.php new file mode 100644 index 0000000..240d434 --- /dev/null +++ b/extend/base/common/command/admin/user/AdminUserRoleListBase.php @@ -0,0 +1,91 @@ +setName('admin:user:role:list') + ->addOption('user-id', null, Option::VALUE_REQUIRED, '用户ID') + ->setDescription('查看用户角色'); + } + + protected function execute(Input $input, Output $output) + { + $userId = $input->getOption('user-id'); + + if (empty($userId)) { + $output->error('用户ID不能为空'); + return false; + } + + try { + $user = SystemAdmin::find($userId); + if (empty($user)) { + $output->error('用户ID ' . $userId . ' 不存在'); + return false; + } + + $authIds = $user->auth_ids; + $authIdArray = []; + + if (!empty($authIds)) { + $authIdArray = array_filter(explode(',', $authIds)); + } + + if (empty($authIdArray)) { + $output->info('用户角色列表'); + $output->writeln('================================'); + $output->info('用户ID: ' . $user->id); + $output->info('用户名: ' . $user->username); + $output->writeln('--------------------------------'); + $output->comment('该用户没有分配任何角色'); + return true; + } + + $roles = SystemAuth::whereIn('id', $authIdArray) + ->where('delete_time', 0) + ->field('id,title,status,remark') + ->select() + ->toArray(); + + $output->info('用户角色列表'); + $output->writeln('================================'); + $output->info('用户ID: ' . $user->id); + $output->info('用户名: ' . $user->username); + $output->writeln('--------------------------------'); + + if (empty($roles)) { + $output->comment('没有找到有效的角色信息'); + } else { + foreach ($roles as $role) { + $statusText = $role['status'] == 1 ? '启用' : '禁用'; + $output->info('角色ID: ' . $role['id']); + $output->comment(' 角色名称: ' . $role['title']); + $output->comment(' 状态: ' . $statusText); + if (!empty($role['remark'])) { + $output->comment(' 备注: ' . $role['remark']); + } + $output->writeln('--------------------------------'); + } + } + } catch (\Throwable $e) { + $output->error('获取用户角色失败: ' . $e->getMessage()); + return false; + } + + return true; + } +} diff --git a/extend/base/common/command/admin/user/AdminUserRoleRevokeBase.php b/extend/base/common/command/admin/user/AdminUserRoleRevokeBase.php new file mode 100644 index 0000000..94b1c93 --- /dev/null +++ b/extend/base/common/command/admin/user/AdminUserRoleRevokeBase.php @@ -0,0 +1,117 @@ +setName('admin:user:role:revoke') + ->addOption('user-id', null, Option::VALUE_REQUIRED, '用户ID') + ->addOption('role-ids', null, Option::VALUE_REQUIRED, '角色ID列表(逗号分隔)') + ->setDescription('撤回用户角色'); + } + + protected function execute(Input $input, Output $output) + { + $userId = $input->getOption('user-id'); + $roleIdsParam = $input->getOption('role-ids'); + + if (empty($userId)) { + $output->error('用户ID不能为空'); + return false; + } + + if (empty($roleIdsParam)) { + $output->error('角色ID不能为空'); + return false; + } + + try { + $admin = SystemAdmin::find($userId); + if (empty($admin)) { + $output->error('用户ID ' . $userId . ' 不存在'); + return false; + } + + $roleIds = $this->parseRoleIds($roleIdsParam); + if (empty($roleIds)) { + $output->error('角色ID列表为空'); + return false; + } + + $validRoleIds = $this->validateRoleIds($roleIds); + $invalidRoleIds = array_diff($roleIds, $validRoleIds); + + if (!empty($invalidRoleIds)) { + $output->comment('警告: 以下角色ID无效: ' . implode(', ', array_values($invalidRoleIds))); + } + + if (empty($validRoleIds)) { + $output->error('没有有效的角色ID'); + return false; + } + + $existingAuthIds = $admin->auth_ids; + if (empty($existingAuthIds)) { + $output->error('用户未分配任何角色,无法撤回'); + return false; + } + + $existingAuthIdArray = array_filter(explode(',', $existingAuthIds)); + + $revokedRoleIds = array_intersect($validRoleIds, $existingAuthIdArray); + + if (empty($revokedRoleIds)) { + $output->error('没有角色可以撤回(用户不拥有指定的角色)'); + return false; + } + + $remainingRoleIds = array_diff($existingAuthIdArray, $revokedRoleIds); + $admin->auth_ids = empty($remainingRoleIds) ? '' : implode(',', $remainingRoleIds); + $admin->save(); + + $output->info('角色撤回成功'); + $output->info('用户ID: ' . $userId); + $output->info('撤回的角色ID: ' . implode(', ', $revokedRoleIds)); + $output->info('该用户剩余角色数: ' . count($remainingRoleIds)); + if (!empty($remainingRoleIds)) { + $output->info('剩余角色ID: ' . implode(', ', $remainingRoleIds)); + } + } catch (\Throwable $e) { + $output->error('撤回角色失败: ' . $e->getMessage()); + return false; + } + + return true; + } + + protected function parseRoleIds(string $roleIdsParam): array + { + $roleIds = explode(',', $roleIdsParam); + $roleIds = array_map('trim', $roleIds); + $roleIds = array_filter($roleIds); + + return array_values($roleIds); + } + + protected function validateRoleIds(array $roleIds): array + { + $validRoleIds = SystemAuth::whereIn('id', $roleIds) + ->where('delete_time', 0) + ->column('id'); + + return $validRoleIds; + } +} diff --git a/extend/base/common/command/curd/MigrateBase.php b/extend/base/common/command/curd/MigrateBase.php index f0bfe13..30611c6 100644 --- a/extend/base/common/command/curd/MigrateBase.php +++ b/extend/base/common/command/curd/MigrateBase.php @@ -86,15 +86,14 @@ class MigrateBase extends Command if ($is_extis) { $output->error('文件已存在:' . $patt_files[0]); - if (!$force) { - $confirm_force = $output->confirm($input, '确定要覆盖文件吗?如果您想生成更新文件请添加-u参数', false); - - if (!$confirm_force) { - return; - } + + if (!$output->confirm($input, '确定要覆盖文件吗?如果您想生成更新文件请添加-u参数', true)) { + $output->comment('已取消。'); + return false; } + $output->highlight('执行覆盖操作'); - + $dist_file_path = $patt_files[0]; } } diff --git a/extend/base/common/command/scheme/Make.php b/extend/base/common/command/scheme/Make.php index ef2635d..cc9d526 100644 --- a/extend/base/common/command/scheme/Make.php +++ b/extend/base/common/command/scheme/Make.php @@ -2,20 +2,24 @@ namespace base\common\command\scheme; -use think\console\Command; +use app\common\console\Command; use think\console\Input; -use think\console\input\Argument; -use think\console\input\Option; +use think\console\Input\Option; +use think\console\Input\Argument; use think\console\Output; use think\facade\Db; use think\facade\Config; use app\common\service\scheme\DbToSchemeService; use app\common\service\scheme\SchemeToDbService; +use app\common\service\scheme\attribute\Table; +use ReflectionClass; class Make extends Command { protected function configure() { + parent::configure(); + $this->setName('scheme:make') ->addOption('table', 't', Option::VALUE_REQUIRED, "The table name (without prefix)") ->addArgument('table', Argument::OPTIONAL, "The table name (without prefix)") @@ -27,7 +31,7 @@ class Make extends Command $table = $input->getOption('table') ?: $input->getArgument('table'); $service = new DbToSchemeService(); $compare = new SchemeToDbService(); - + $tables = []; if ($table) { $tables[] = $table; @@ -39,7 +43,7 @@ class Make extends Command $connection = Config::get('database.default', 'mysql'); $prefix = Config::get('database.connections.' . $connection . '.prefix', ''); $backupPrefix = Config::get('scheme.backup_prefix', 'backup'); - + foreach ($allTables as $t) { if ($this->isBackupTable($t, $prefix, $backupPrefix)) { continue; @@ -50,7 +54,7 @@ class Make extends Command if ($prefix && str_starts_with($t, $prefix)) { $shortName = substr($t, strlen($prefix)); } - + if (!in_array($shortName, $config) && !in_array($t, $config)) { $tables[] = $shortName; } @@ -61,7 +65,7 @@ class Make extends Command $output->writeln("Processing table: $t"); try { $code = $service->generate($t); - + // 提取类名以确定文件名 if (preg_match('/class\s+(\w+)/', $code, $matches)) { $className = $matches[1]; @@ -78,17 +82,17 @@ class Make extends Command } } } - + // 确保目录存在 if (!is_dir(dirname($path))) { mkdir(dirname($path), 0755, true); } - + file_put_contents($path, $code); $output->writeln("Generated: $path"); } } catch (\Exception $e) { - $output->writeln("Error processing $t: " . $e->getMessage() . ""); + $output->error("Error processing $t: " . $e->getMessage()); } } } diff --git a/extend/base/common/command/scheme/Sync.php b/extend/base/common/command/scheme/Sync.php index 95eefeb..e7715e2 100644 --- a/extend/base/common/command/scheme/Sync.php +++ b/extend/base/common/command/scheme/Sync.php @@ -2,23 +2,27 @@ namespace base\common\command\scheme; -use think\console\Command; +use app\common\console\Command; +use app\common\service\scheme\SchemeToDbService; use think\console\Input; -use think\console\input\Option; +use think\console\Input\Option; use think\console\Output; +use ReflectionClass; +use app\common\scheme\attribute\Table; use think\facade\Config; use think\facade\Db; -use app\common\service\scheme\SchemeToDbService; -use app\common\scheme\attribute\Table; -use ReflectionClass; +/** + * scheme:sync 命令基类 + */ class Sync extends Command { protected function configure() { + parent::configure(); + $this->setName('scheme:sync') ->addOption('skip-data', null, Option::VALUE_NONE, 'Skip data migration') - ->addOption('force', null, Option::VALUE_NONE, 'Force execution without confirmation') ->setDescription('Synchronize Scheme classes to Database'); } @@ -32,17 +36,20 @@ class Sync extends Command $connection = Config::get('database.default', 'mysql'); $prefix = Config::get('database.connections.' . $connection . '.prefix', ''); $backupPrefix = Config::get('scheme.backup_prefix', 'backup'); - + if (!is_dir($schemeDir)) { - $output->writeln("Scheme directory not found: $schemeDir"); + $error = "Scheme directory not found: $schemeDir"; + $output->writeln("$error"); return; } + $pendingSync = []; $files = glob($schemeDir . '*.php'); + foreach ($files as $file) { require_once $file; $className = 'app\\admin\\scheme\\' . basename($file, '.php'); - + if (class_exists($className)) { $tableName = $this->getTableNameFromScheme($className); if (empty($tableName)) { @@ -64,30 +71,62 @@ class Sync extends Command try { $diffs = $service->diff($className); } catch (\Throwable $e) { - $output->writeln("Check failed for $className: " . $e->getMessage() . ""); + $output->error("Check failed for $className: " . $e->getMessage()); continue; } if (count($diffs) === 1 && str_starts_with($diffs[0], '无法读取数据库表结构')) { - $output->writeln("Check failed for $className: {$diffs[0]}"); + $output->error("Check failed for $className: {$diffs[0]}"); continue; } if (empty($diffs)) { - $output->writeln("Skipping $className (no schema changes)"); continue; } - $output->writeln("Syncing $className..."); - try { - $backup = $service->sync($className, $skipData); - $output->writeln("Success!"); - if ($backup) { - $output->writeln("Backup created: $backup"); - } - } catch (\Exception $e) { - $output->writeln("Failed: " . $e->getMessage() . ""); + $pendingSync[] = [ + 'className' => $className, + 'tableName' => $fullTableName, + 'diffs' => $diffs + ]; + } + } + + if (empty($pendingSync)) { + $output->writeln('未检测到 Scheme 变更。'); + return; + } + + // 显示所有待同步的变更 + $output->writeln(''); + $output->writeln('========================================'); + $output->writeln('即将执行以下 Scheme 变更:'); + $output->writeln('========================================'); + foreach ($pendingSync as $item) { + $output->writeln("表名: {$item['tableName']} ({$item['className']})"); + foreach ($item['diffs'] as $line) { + $output->writeln(" $line"); + } + $output->writeln(''); + } + + // 确认(全局 -ff 可跳过) + if (!$output->confirm($input, '确认要将这些变更应用到数据库吗?', true)) { + $output->comment('操作已取消。'); + return; + } + + // 执行同步 + foreach ($pendingSync as $item) { + $output->writeln("正在同步 {$item['className']}..."); + try { + $backup = $service->sync($item['className'], $skipData); + $output->writeln("成功!"); + if ($backup) { + $output->writeln("已创建备份: $backup"); } + } catch (\Exception $e) { + $output->writeln("失败: " . $e->getMessage() . ""); } } } diff --git a/extend/base/common/command/tools/README.md b/extend/base/common/command/tools/README.md index 6f43bf8..15aa58b 100644 --- a/extend/base/common/command/tools/README.md +++ b/extend/base/common/command/tools/README.md @@ -7,29 +7,34 @@ ## 目录结构 ``` -extend/base/common/ -├── service/ -│ ├── ToolsDbServiceBase.php # 数据库工具服务 -│ ├── ToolsLogServiceBase.php # 日志工具服务 -│ └── ToolsBackupServiceBase.php # 备份工具服务 -└── command/ - └── tools/ - ├── README.md # 本文件(命名规范说明) - └── db/ # 数据库工具 - ├── ToolsDbQueryBase.php # 基础命令类 - ├── ToolsDbExecuteBase.php - └── ... + extend/base/common/ + ├── service/ + │ ├── ToolsDbServiceBase.php # 数据库工具服务 + │ ├── ToolsLogServiceBase.php # 日志工具服务 + │ └── ToolsBackupServiceBase.php # 备份工具服务 + └── command/ + └── tools/ + ├── README.md # 本文件(命名规范说明) + ├── db/ # 数据库工具 + │ ├── ToolsDbQueryBase.php # 基础命令类 + │ ├── ToolsDbExecuteBase.php + │ └── ... + ├── http/ # HTTP 工具 + │ └── ToolsHttpCallBase.php + └── ... -app/common/command/ -├── tools/ -│ └── db/ # 数据库工具 -│ ├── ToolsDbQuery.php # 业务命令类 -│ ├── ToolsDbExecute.php -│ └── ... -├── admin/ # 普通命令(无前缀) -├── scheme/ -├── curd/ -└── ... + app/common/command/ + ├── tools/ + │ ├── db/ # 数据库工具 + │ │ ├── ToolsDbQuery.php # 业务命令类 + │ │ ├── ToolsDbExecute.php + │ │ └── ... + │ └── http/ # HTTP 工具 + │ └── ToolsHttpCall.php + ├── admin/ # 普通命令(无前缀) + ├── scheme/ + ├── curd/ + └── ... ``` ## 命名规则对比 @@ -77,22 +82,24 @@ app/common/command/ ### 2. 命令名称规则 -- 格式:`tools:[类型]:[功能]` -- 示例: - - `tools:db:query` - 数据库查询 - - `tools:db:execute` - 数据库执行 - - `tools:log:clear` - 日志清理 - - `tools:backup:create` - 创建备份 + - 格式:`tools:[类型]:[功能]` + - 示例: + - `tools:db:query` - 数据库查询 + - `tools:db:execute` - 数据库执行 + - `tools:http:call` - HTTP 调用(类似 curl) + - `tools:log:clear` - 日志清理 + - `tools:backup:create` - 创建备份 ### 3. 目录结构规则 ``` -tools/ -├── db/ # 数据库工具(db) -├── log/ # 日志工具(log) -├── backup/ # 备份工具(backup) -├── cache/ # 缓存工具(cache) -└── ... + tools/ + ├── db/ # 数据库工具(db) + ├── http/ # HTTP 工具(http) + ├── log/ # 日志工具(log) + ├── backup/ # 备份工具(backup) + ├── cache/ # 缓存工具(cache) + └── ... ``` ## 创建新 Tools 命令的步骤 @@ -185,10 +192,17 @@ $this->commands([ ## 已实现的工具 -- **数据库工具 (db)** - - `tools:db:query` - 执行 SQL 查询 - - `tools:db:execute` - 执行 SQL 非查询语句 - - `tools:db:table` - 使用查询构建器操作表 - - `tools:db:info` - 显示数据库信息 - - `tools:db:desc` - 显示表结构 - - `tools:db:count` - 统计表记录数 + - **数据库工具 (db)** + - `tools:db:query` - 执行 SQL 查询 + - `tools:db:execute` - 执行 SQL 非查询语句 + - `tools:db:table` - 使用查询构建器操作表 + - `tools:db:info` - 显示数据库信息 + - `tools:db:desc` - 显示表结构 + - `tools:db:count` - 统计表记录数 + +- **HTTP 工具 (http)** + - `tools:http:call` - HTTP 调用工具,类似 curl + - 支持标准 curl 参数:`--url`, `--method`, `--data`, `--body`, `--headers` + - 支持框架特性参数:`--app`, `--controller`, `--action`, `--super-token`, `--user-id` + - JSON 输出格式:`{ success, response: { status, data, headers }, execution_time, exception }` + diff --git a/extend/base/common/command/tools/agent/ToolsAgentPublishBase.php b/extend/base/common/command/tools/agent/ToolsAgentPublishBase.php new file mode 100644 index 0000000..a419caf --- /dev/null +++ b/extend/base/common/command/tools/agent/ToolsAgentPublishBase.php @@ -0,0 +1,277 @@ +setName('tools:agent:publish') + ->setDescription('发布 AGENTS.md 与 .agent 到兼容目标(复制 rules/skills;可选生成 CLAUDE.md)') + ->addOption('target', 't', Option::VALUE_OPTIONAL, '发布目标:trae|opencode|roocode|cursor|claude|all', 'all') + ->addOption('dry-run', null, Option::VALUE_NONE, '只输出将要写入/覆盖的清单,不落盘') + ->addOption('clean', null, Option::VALUE_NONE, '清理目标中由发布器生成的内容(仅影响 rules/skills 与发布文件)') + ->addOption('force', 'f', Option::VALUE_NONE, '跳过覆盖确认'); + } + + protected function execute(Input $input, Output $output) + { + $rootPath = rtrim(App::getRootPath(), "\\/ \t\n\r\0\x0B"); + + $sourceAgentsPath = $rootPath . DIRECTORY_SEPARATOR . 'AGENTS.md'; + $sourceRulesDir = $rootPath . DIRECTORY_SEPARATOR . '.agent' . DIRECTORY_SEPARATOR . 'rules'; + $sourceSkillsDir = $rootPath . DIRECTORY_SEPARATOR . '.agent' . DIRECTORY_SEPARATOR . 'skills'; + + if (!is_file($sourceAgentsPath)) { + $output->error('缺少单一事实来源文件:' . $sourceAgentsPath); + return; + } + if (!is_dir($sourceRulesDir)) { + $output->error('缺少单一事实来源目录:' . $sourceRulesDir); + return; + } + if (!is_dir($sourceSkillsDir)) { + $output->error('缺少单一事实来源目录:' . $sourceSkillsDir); + return; + } + + $target = strtolower((string)$input->getOption('target')); + if ($target === '') { + $target = 'all'; + } + + $supportedTargets = ['trae', 'opencode', 'roocode', 'cursor', 'claude', 'all']; + if (!in_array($target, $supportedTargets, true)) { + $output->error('不支持的 target:' . $target . '(可选:' . implode('|', $supportedTargets) . ')'); + return; + } + + $dryRun = (bool)$input->getOption('dry-run'); + $clean = (bool)$input->getOption('clean'); + + $targets = $target === 'all' ? ['trae', 'opencode', 'roocode', 'cursor', 'claude'] : [$target]; + + $writeActions = []; + $deleteActions = []; + + $agentsContent = file_get_contents($sourceAgentsPath); + if ($agentsContent === false) { + $output->error('读取失败:' . $sourceAgentsPath); + return; + } + + foreach ($targets as $t) { + if (in_array($t, ['trae', 'opencode', 'roocode', 'cursor'], true)) { + $distRoot = $rootPath . DIRECTORY_SEPARATOR . '.' . $t; + $distRules = $distRoot . DIRECTORY_SEPARATOR . 'rules'; + $distSkills = $distRoot . DIRECTORY_SEPARATOR . 'skills'; + + if ($t === 'cursor') { + $deleteActions[] = $distRules . DIRECTORY_SEPARATOR . 'ulthon-framework.mdc'; + } + + if ($clean) { + $deleteActions[] = $distRules; + $deleteActions[] = $distSkills; + } + + $writeActions = array_merge($writeActions, $this->planCopyDir($sourceRulesDir, $distRules)); + $writeActions = array_merge($writeActions, $this->planCopyDir($sourceSkillsDir, $distSkills)); + } + + if ($t === 'claude') { + $distClaude = $rootPath . DIRECTORY_SEPARATOR . 'CLAUDE.md'; + if ($clean) { + $deleteActions[] = $distClaude; + } + $writeActions = array_merge($writeActions, $this->planWriteFile($distClaude, $agentsContent)); + } + } + + $deleteActions = array_values(array_unique(array_filter($deleteActions, 'strlen'))); + + $plannedDeletes = $this->filterExistingDeletes($deleteActions); + $plannedWrites = $this->filterEffectiveWrites($writeActions, $clean); + + $output->writeln(''); + $output->writeln('=== tools:agent:publish 计划 ==='); + $output->writeln('target:' . $target); + $output->writeln('dry-run:' . ($dryRun ? 'yes' : 'no')); + $output->writeln('clean:' . ($clean ? 'yes' : 'no')); + $output->writeln(''); + + if (empty($plannedDeletes) && empty($plannedWrites)) { + $output->writeln('无变更:目标已是最新状态。'); + $output->writeln(''); + return; + } + + if (!empty($plannedDeletes)) { + $output->writeln('将删除:'); + foreach ($plannedDeletes as $path) { + $output->writeln(' - ' . $path); + } + $output->writeln(''); + } + + if (!empty($plannedWrites)) { + $output->writeln('将写入:'); + foreach ($plannedWrites as $item) { + $output->writeln(' - [' . $item['mode'] . '] ' . $item['dist']); + } + $output->writeln(''); + } + + if ($dryRun) { + return; + } + + if (!$output->confirm($input, '将执行上述写入/覆盖/删除操作,是否继续?', true)) { + $output->comment('已取消。'); + $output->newLine(); + return false; + } + + foreach ($plannedDeletes as $path) { + $this->deletePath($path); + } + + foreach ($plannedWrites as $item) { + $this->ensureDir(dirname($item['dist'])); + file_put_contents($item['dist'], $item['content']); + } + + $output->info('发布完成。'); + $output->newLine(); + } + + protected function planCopyDir(string $sourceDir, string $distDir): array + { + $actions = []; + if (!is_dir($sourceDir)) { + return $actions; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + $sourceDirNormalized = rtrim($sourceDir, "\\/") . DIRECTORY_SEPARATOR; + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + + $src = $fileInfo->getPathname(); + $relative = substr($src, strlen($sourceDirNormalized)); + $dist = $distDir . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative); + + $content = file_get_contents($src); + if ($content === false) { + continue; + } + + $actions[] = [ + 'dist' => $dist, + 'content' => $content, + ]; + } + + return $actions; + } + + protected function planWriteFile(string $dist, string $content): array + { + return [[ + 'dist' => $dist, + 'content' => $content, + ]]; + } + + protected function filterEffectiveWrites(array $actions, bool $forceWrite = false): array + { + $result = []; + $resultMap = []; + foreach ($actions as $item) { + $dist = (string)($item['dist'] ?? ''); + $content = (string)($item['content'] ?? ''); + if ($dist === '') { + continue; + } + + $mode = 'create'; + if (is_file($dist)) { + $existing = file_get_contents($dist); + if (!$forceWrite && $existing !== false && $existing === $content) { + continue; + } + $mode = 'update'; + } + + $resultMap[$dist] = [ + 'dist' => $dist, + 'content' => $content, + 'mode' => $mode, + ]; + } + $result = array_values($resultMap); + return $result; + } + + protected function filterExistingDeletes(array $deleteActions): array + { + $result = []; + foreach ($deleteActions as $path) { + if (is_file($path) || is_dir($path)) { + $result[] = $path; + } + } + return $result; + } + + protected function deletePath(string $path): void + { + if (is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + @rmdir($fileInfo->getPathname()); + continue; + } + @unlink($fileInfo->getPathname()); + } + + @rmdir($path); + } + + protected function ensureDir(string $dir): void + { + if ($dir === '' || is_dir($dir)) { + return; + } + @mkdir($dir, 0777, true); + } +} + diff --git a/extend/base/common/command/tools/db/ToolsDbCountBase.php b/extend/base/common/command/tools/db/ToolsDbCountBase.php index d0e4f8d..d4d3d64 100644 --- a/extend/base/common/command/tools/db/ToolsDbCountBase.php +++ b/extend/base/common/command/tools/db/ToolsDbCountBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -13,6 +13,8 @@ class ToolsDbCountBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:count') ->setDescription('统计表记录数') ->addArgument('table', null, '表名') @@ -24,12 +26,12 @@ class ToolsDbCountBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:count', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -61,19 +63,19 @@ class ToolsDbCountBase extends Command $endTime = microtime(true); $executionTime = round(($endTime - $startTime) * 1000, 2); - $output->writeln(''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln('表名:' . $fullTableName . ''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln(''); - $output->writeln('记录数:' . $count); - $output->writeln('执行时间:' . $executionTime . 'ms'); - $output->writeln(''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln(''); + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->info('表名:' . $fullTableName); + $output->comment(str_repeat('=', 60)); + $output->newLine(); + $output->info('记录数:' . $count); + $output->info('执行时间:' . $executionTime . 'ms'); + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->newLine(); } catch (\Exception $e) { $output->error('统计失败:' . $e->getMessage()); return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/db/ToolsDbDescBase.php b/extend/base/common/command/tools/db/ToolsDbDescBase.php index d8ceff5..23ce11d 100644 --- a/extend/base/common/command/tools/db/ToolsDbDescBase.php +++ b/extend/base/common/command/tools/db/ToolsDbDescBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -13,6 +13,8 @@ class ToolsDbDescBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:desc') ->setDescription('显示表结构信息') ->addArgument('table', null, '表名') @@ -24,12 +26,12 @@ class ToolsDbDescBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:desc', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -48,12 +50,6 @@ class ToolsDbDescBase extends Command $showIndex = $input->getOption('show-index'); try { - $output->writeln(''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln('表结构:' . $fullTableName . ''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln(''); - $tableComment = ''; try { $escaped = addcslashes($fullTableName, "\\_%"); @@ -77,9 +73,6 @@ class ToolsDbDescBase extends Command } } - $output->writeln('表注释:' . $tableComment); - $output->writeln(''); - $columns = Db::connect($connection)->query("SHOW FULL COLUMNS FROM `$fullTableName`"); if (empty($columns)) { @@ -88,6 +81,45 @@ class ToolsDbDescBase extends Command return; } + $columnData = []; + foreach ($columns as $column) { + $columnData[] = [ + 'name' => $column['Field'], + 'type' => $column['Type'], + 'null' => $column['Null'] === 'YES', + 'key' => $column['Key'], + 'default' => $column['Default'] ?? null, + 'extra' => $column['Extra'] ?? '', + 'comment' => $column['Comment'] ?? '', + ]; + } + + $indexData = []; + if ($showIndex) { + $indexes = Db::connect($connection)->query("SHOW INDEX FROM `$fullTableName`"); + + if (!empty($indexes)) { + foreach ($indexes as $index) { + $indexData[] = [ + 'name' => $index['Key_name'], + 'column' => $index['Column_name'], + 'unique' => $index['Non_unique'] == 0, + 'type' => $index['Index_type'], + 'sequence' => $index['Seq_in_index'], + ]; + } + } + } + + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->info('表结构:' . $fullTableName); + $output->comment(str_repeat('=', 60)); + $output->newLine(); + + $output->info('表注释:' . $tableComment); + $output->newLine(); + $tableData = []; foreach ($columns as $column) { $tableData[] = [ @@ -104,18 +136,16 @@ class ToolsDbDescBase extends Command $service->formatTableOutput($tableData, $output); if ($showIndex) { - $output->writeln(''); - $output->writeln('' . str_repeat('-', 60) . ''); - $output->writeln('索引信息'); - $output->writeln('' . str_repeat('-', 60) . ''); - $output->writeln(''); - - $indexes = Db::connect($connection)->query("SHOW INDEX FROM `$fullTableName`"); + $output->newLine(); + $output->comment(str_repeat('-', 60)); + $output->info('索引信息'); + $output->comment(str_repeat('-', 60)); + $output->newLine(); if (!empty($indexes)) { - $indexData = []; + $indexDataDisplay = []; foreach ($indexes as $index) { - $indexData[] = [ + $indexDataDisplay[] = [ '索引名' => $index['Key_name'], '列名' => $index['Column_name'], '唯一' => $index['Non_unique'] == 0 ? '是' : '否', @@ -123,18 +153,18 @@ class ToolsDbDescBase extends Command '顺序' => $index['Seq_in_index'], ]; } - $service->formatTableOutput($indexData, $output); + $service->formatTableOutput($indexDataDisplay, $output); } else { $output->writeln('无索引'); } } - $output->writeln(''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln(''); + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->newLine(); } catch (\Exception $e) { $output->error('获取表结构失败:' . $e->getMessage()); return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/db/ToolsDbExecuteBase.php b/extend/base/common/command/tools/db/ToolsDbExecuteBase.php index 3b61bfb..1db1f27 100644 --- a/extend/base/common/command/tools/db/ToolsDbExecuteBase.php +++ b/extend/base/common/command/tools/db/ToolsDbExecuteBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -13,10 +13,11 @@ class ToolsDbExecuteBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:execute') ->setDescription('执行 SQL 非查询语句(INSERT/UPDATE/DELETE)') ->addArgument('sql', null, 'SQL 执行语句') - ->addOption('force', null, Option::VALUE_NONE, '跳过确认直接执行') ->addOption('transaction', null, Option::VALUE_NONE, '在事务中执行(失败自动回滚)') ->addOption('connection', null, Option::VALUE_OPTIONAL, '指定数据库连接配置') ->addOption('help', 'h', Option::VALUE_NONE, '显示帮助信息'); @@ -25,12 +26,12 @@ class ToolsDbExecuteBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:execute', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -45,7 +46,6 @@ class ToolsDbExecuteBase extends Command return; } - $force = $input->getOption('force'); $useTransaction = $input->getOption('transaction'); if (preg_match('/^\s*SELECT\s+/i', $sql)) { @@ -53,17 +53,14 @@ class ToolsDbExecuteBase extends Command return; } - $output->writeln(''); - $output->writeln('准备执行的 SQL:'); + $output->newLine(); + $output->comment('准备执行的 SQL:'); $output->writeln($sql); - $output->writeln(''); + $output->newLine(); - if (!$force) { - $confirm = $output->confirm($input, '确定要执行此 SQL 语句吗? '); - if (!$confirm) { - $output->writeln('操作已取消'); - return; - } + if (!$output->confirm($input, '确定要执行此 SQL 语句吗?', true)) { + $output->comment('已取消。'); + return; } try { @@ -85,19 +82,19 @@ class ToolsDbExecuteBase extends Command $endTime = microtime(true); $executionTime = round(($endTime - $startTime) * 1000, 2); - $output->writeln(''); - $output->writeln('执行成功'); + $output->newLine(); + $output->info('执行成功'); $output->writeln('影响行数:' . $affectedRows); $output->writeln('执行时间:' . $executionTime . 'ms'); - $output->writeln(''); + $output->newLine(); } catch (\Exception $e) { - $output->writeln(''); + $output->newLine(); $output->error('执行失败:' . $e->getMessage()); if ($useTransaction) { - $output->writeln('事务已回滚'); + $output->comment('事务已回滚'); } - $output->writeln(''); + $output->newLine(); return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/db/ToolsDbInfoBase.php b/extend/base/common/command/tools/db/ToolsDbInfoBase.php index 6adefd6..afa524f 100644 --- a/extend/base/common/command/tools/db/ToolsDbInfoBase.php +++ b/extend/base/common/command/tools/db/ToolsDbInfoBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -14,6 +14,8 @@ class ToolsDbInfoBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:info') ->setDescription('显示数据库连接信息和表列表') ->addOption('with-count', null, Option::VALUE_NONE, '显示每个表的记录数') @@ -24,12 +26,12 @@ class ToolsDbInfoBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:info', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -47,11 +49,69 @@ class ToolsDbInfoBase extends Command return; } - $output->writeln(''); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln('数据库连接信息'); - $output->writeln('' . str_repeat('=', 60) . ''); - $output->writeln(''); + $tables = Db::connect($connection)->getTables(); + + $tableList = []; + if (!empty($tables)) { + $prefix = $config['prefix'] ?? ''; + + foreach ($tables as $table) { + $shortName = $table; + if ($prefix && str_starts_with($table, $prefix)) { + $shortName = substr($table, strlen($prefix)); + } + + $tableItem = [ + 'name' => $shortName, + 'full_name' => $table + ]; + + if ($withCount) { + $count = Db::connect($connection)->table($table)->count(); + $tableItem['count'] = $count; + } + + $tableList[] = $tableItem; + } + } + + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->info('数据库连接信息'); + $output->comment(str_repeat('=', 60)); + $output->newLine(); + + $output->info('连接名称:' . $connection); + $output->info('数据库类型:' . ($config['type'] ?? 'unknown')); + $output->info('主机地址:' . ($config['hostname'] ?? 'unknown')); + $output->info('数据库名:' . ($config['database'] ?? 'unknown')); + $output->info('端口:' . ($config['hostport'] ?? 'unknown')); + $output->info('字符集:' . ($config['charset'] ?? 'unknown')); + $output->info('表前缀:' . ($config['prefix'] ?? '')); + $output->newLine(); + + $output->comment(str_repeat('-', 60)); + $output->info('表列表'); + $output->comment(str_repeat('-', 60)); + $output->newLine(); + + if (empty($tables)) { + $output->writeln('无表'); + $output->newLine(); + return; + } + + foreach ($tableList as $tableItem) { + if ($withCount) { + $output->info(str_pad($tableItem['name'], 40) . ($tableItem['count'] ?? 0) . ' 条记录'); + } else { + $output->info($tableItem['name']); + } + } + + $output->newLine(); + $output->comment(str_repeat('=', 60)); + $output->newLine(); $output->writeln('连接名称:' . $connection); $output->writeln('数据库类型:' . ($config['type'] ?? 'unknown')); $output->writeln('主机地址:' . ($config['hostname'] ?? 'unknown')); @@ -66,27 +126,17 @@ class ToolsDbInfoBase extends Command $output->writeln('' . str_repeat('-', 60) . ''); $output->writeln(''); - $tables = Db::connect($connection)->getTables(); - if (empty($tables)) { $output->writeln('无表'); $output->writeln(''); return; } - $prefix = $config['prefix'] ?? ''; - - foreach ($tables as $table) { - $shortName = $table; - if ($prefix && str_starts_with($table, $prefix)) { - $shortName = substr($table, strlen($prefix)); - } - + foreach ($tableList as $tableItem) { if ($withCount) { - $count = Db::connect($connection)->table($table)->count(); - $output->writeln('' . str_pad($shortName, 40) . '' . $count . ' 条记录'); + $output->writeln('' . str_pad($tableItem['name'], 40) . '' . ($tableItem['count'] ?? 0) . ' 条记录'); } else { - $output->writeln('' . $shortName . ''); + $output->writeln('' . $tableItem['name'] . ''); } } @@ -98,4 +148,4 @@ class ToolsDbInfoBase extends Command return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/db/ToolsDbQueryBase.php b/extend/base/common/command/tools/db/ToolsDbQueryBase.php index c1ccaaa..71fc620 100644 --- a/extend/base/common/command/tools/db/ToolsDbQueryBase.php +++ b/extend/base/common/command/tools/db/ToolsDbQueryBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -13,10 +13,11 @@ class ToolsDbQueryBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:query') ->setDescription('执行 SQL 查询语句(SELECT)并显示结果') ->addArgument('sql', null, 'SQL 查询语句') - ->addOption('format', null, Option::VALUE_OPTIONAL, '输出格式,可选值:table(默认)、json', 'table') ->addOption('limit', null, Option::VALUE_OPTIONAL, '限制显示行数') ->addOption('connection', null, Option::VALUE_OPTIONAL, '指定数据库连接配置') ->addOption('help', 'h', Option::VALUE_NONE, '显示帮助信息'); @@ -25,12 +26,12 @@ class ToolsDbQueryBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:query', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -45,7 +46,6 @@ class ToolsDbQueryBase extends Command return; } - $format = $input->getOption('format') ?: 'table'; $limit = $input->getOption('limit'); if (!preg_match('/^\s*SELECT\s+/i', $sql)) { @@ -69,22 +69,16 @@ class ToolsDbQueryBase extends Command $query = array_slice($query, 0, (int)$limit); } - $output->writeln(''); - - if ($format === 'json') { - $service->formatJsonOutput($query, $output); - } else { - $service->formatTableOutput($query, $output); - } - - $output->writeln(''); - $output->writeln('查询成功'); + $output->newLine(); + $service->formatTableOutput($query, $output); + $output->newLine(); + $output->info('查询成功'); $output->writeln('影响行数:' . count($query)); $output->writeln('执行时间:' . $executionTime . 'ms'); - $output->writeln(''); + $output->newLine(); } catch (\Exception $e) { $output->error('查询失败:' . $e->getMessage()); return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/db/ToolsDbTableBase.php b/extend/base/common/command/tools/db/ToolsDbTableBase.php index 4b6af1b..2c392ad 100644 --- a/extend/base/common/command/tools/db/ToolsDbTableBase.php +++ b/extend/base/common/command/tools/db/ToolsDbTableBase.php @@ -2,8 +2,8 @@ namespace base\common\command\tools\db; -use base\common\service\ToolsDbServiceBase; -use think\console\Command; +use app\common\service\tools\DbService; +use app\common\console\Command; use think\console\Input; use think\console\input\Option; use think\console\Output; @@ -13,6 +13,8 @@ class ToolsDbTableBase extends Command { protected function configure() { + parent::configure(); + $this->setName('tools:db:table') ->setDescription('使用查询构建器操作表') ->addArgument('table', null, '表名') @@ -28,12 +30,12 @@ class ToolsDbTableBase extends Command protected function execute($input, $output) { if ($input->getOption('help')) { - $service = new ToolsDbServiceBase(); + $service = new DbService(); $service->showHelp('tools:db:table', $output); return; } - $service = new ToolsDbServiceBase(); + $service = new DbService(); if (!$service->checkDebugMode($output)) { return; @@ -75,10 +77,11 @@ class ToolsDbTableBase extends Command if ($count) { $result = $query->count(); - $output->writeln(''); - $output->writeln('表名:' . $fullTableName . ''); + + $output->newLine(); + $output->info('表名:' . $fullTableName); $output->writeln('记录数:' . $result); - $output->writeln(''); + $output->newLine(); } else { if ($limit && is_numeric($limit)) { $query->limit((int)$limit); @@ -89,17 +92,17 @@ class ToolsDbTableBase extends Command $endTime = microtime(true); $executionTime = round(($endTime - $startTime) * 1000, 2); - $output->writeln(''); - $output->writeln('表名:' . $fullTableName . ''); + $output->newLine(); + $output->info('表名:' . $fullTableName); $service->formatTableOutput($result, $output); - $output->writeln(''); + $output->newLine(); $output->writeln('影响行数:' . count($result)); $output->writeln('执行时间:' . $executionTime . 'ms'); - $output->writeln(''); + $output->newLine(); } } catch (\Exception $e) { $output->error('查询失败:' . $e->getMessage()); return; } } -} +} \ No newline at end of file diff --git a/extend/base/common/command/tools/http/ToolsHttpCallBase.php b/extend/base/common/command/tools/http/ToolsHttpCallBase.php new file mode 100644 index 0000000..08f4371 --- /dev/null +++ b/extend/base/common/command/tools/http/ToolsHttpCallBase.php @@ -0,0 +1,445 @@ +setName('tools:http:call') + ->setDescription('HTTP 调用工具,类似 curl,支持框架特性参数') + ->addArgument('url', Argument::OPTIONAL, '请求 URL 或控制器路径(如 /admin/user/index)') + ->addOption('url', null, Option::VALUE_OPTIONAL, '请求 URL') + ->addOption('method', 'm', Option::VALUE_OPTIONAL, 'HTTP 方法 (GET, POST, PUT, DELETE)', 'GET') + ->addOption('data', 'd', Option::VALUE_OPTIONAL, '请求数据(JSON 或 key=value)') + ->addOption('body', null, Option::VALUE_OPTIONAL, '请求体(原始数据)') + ->addOption('headers', 'H', Option::VALUE_OPTIONAL, '请求头(JSON 格式,如 {"Content-Type":"application/json"})') + ->addOption('app', null, Option::VALUE_OPTIONAL, '应用名称(框架特性)') + ->addOption('controller', null, Option::VALUE_OPTIONAL, '控制器名称(框架特性)') + ->addOption('action', null, Option::VALUE_OPTIONAL, '动作名称(框架特性)') + ->addOption('page-data', null, Option::VALUE_NONE, '返回页面 assign 数据(追加 get_page_data=1)') + ->addOption('super-token', null, Option::VALUE_OPTIONAL, '超级 Token(框架特性)', 'true') + ->addOption('user-id', null, Option::VALUE_OPTIONAL, '用户 ID(框架特性)'); + } + + /** + * 执行命令 + */ + protected function execute(Input $input, Output $output) + { + $this->startTime = microtime(true); + + // 检查环境 + $this->checkEnvironment($input); + + // 初始化 HTTP 客户端 + $this->httpClient = new Client([ + 'base_uri' => $this->getBaseUrl(), + 'timeout' => 30, + 'verify' => false, // 开发环境跳过 SSL 验证 + ]); + + try { + // 构建请求 + $request = $this->buildRequest($input); + + // 执行请求 + $response = $this->executeRequest($request, $output); + + // 输出结果 + $this->outputResult($response, $output); + + } catch (\Exception $e) { + $executionTime = round((microtime(true) - $this->startTime) * 1000, 2); + $this->outputError($e, $executionTime, $output); + } + } + + /** + * 检查运行环境 + */ + protected function checkEnvironment(Input $input): void + { + // 检查是否使用框架特性参数 + $usingFrameworkParams = !empty($input->getOption('app')) || + !empty($input->getOption('controller')) || + !empty($input->getOption('action')); + + if ($usingFrameworkParams) { + // 检查应用是否运行 + $baseUrl = $this->getBaseUrl(); + $isLocalhost = (strpos($baseUrl, 'localhost') !== false || strpos($baseUrl, '127.0.0.1') !== false); + + if ($isLocalhost) { + // 尝试检测应用是否运行 + $client = new Client([ + 'timeout' => 20, + 'verify' => false, + 'http_errors' => false, + ]); + try { + $client->get($baseUrl); + } catch (\GuzzleHttp\Exception\ConnectException $e) { + throw new \Exception( + "无法连接到应用服务器 ($baseUrl)。请确保应用正在运行(执行 'php think run')" . + " 或者设置 'app.app_host' 环境变量。" + ); + } + } + } + } + + /** + * 获取基础 URL + */ + protected function getBaseUrl(): string + { + $appUrl = env('app.app_host', ''); + if (empty($appUrl)) { + $appUrl = 'http://127.0.0.1:8000'; + } + return rtrim($appUrl, '/'); + } + + /** + * 构建请求 + */ + protected function buildRequest(Input $input): array + { + // 获取 URL(优先使用 --url 参数,其次使用位置参数) + $url = $input->getOption('url') ?: $input->getArgument('url'); + + // 如果使用框架特性参数,构建 URL + if (empty($url)) { + $url = $this->buildUrlFromFrameworkParams($input); + } + + if (empty($url)) { + throw new \Exception('请提供 URL 或使用框架特性参数(--app, --controller, --action)'); + } + + if ($input->getOption('page-data') && strpos($url, 'get_page_data=') === false) { + $url .= (strpos($url, '?') === false ? '?' : '&') . 'get_page_data=1'; + } + + $request = [ + 'url' => $url, + 'method' => strtoupper($input->getOption('method')), + 'headers' => [], + 'body' => null, + ]; + + // 解析请求头 + $headers = $input->getOption('headers'); + if (!empty($headers)) { + $request['headers'] = json_decode($headers, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('请求头格式错误:' . json_last_error_msg()); + } + } + + // 设置 Accept 头为 JSON + $request['headers']['Accept'] = 'application/json'; + + // 解析请求数据 + $data = $input->getOption('data'); + $body = $input->getOption('body'); + + if (!empty($body)) { + $request['body'] = $body; + } elseif (!empty($data)) { + $request['headers']['Content-Type'] = 'application/json'; + $request['body'] = $this->normalizeDataToJson($data); + } + + $superToken = $this->resolveSuperToken($input); + if (!is_null($superToken)) { + $userId = $input->getOption('user-id') ?: AdminConstant::SUPER_ADMIN_ID; + $admin = SystemAdmin::find($userId); + if (empty($admin)) { + throw new \Exception('管理员不存在:' . $userId); + } + + $adminData = $admin->toArray(); + unset($adminData['password']); + $adminData['expire_time'] = time() + 7200; + + Cache::store('login')->set($superToken, $adminData, 7200); + $request['headers']['Authorization'] = 'Bearer ' . $superToken; + } + + return $request; + } + + protected function normalizeDataToJson(string $raw): string + { + $raw = trim($raw); + if ($raw === '') { + return $raw; + } + + while (strlen($raw) >= 2) { + $first = $raw[0]; + $last = $raw[strlen($raw) - 1]; + if (!($first === $last && ($first === '"' || $first === "'"))) { + break; + } + $raw = substr($raw, 1, -1); + $raw = trim($raw); + } + + json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $raw; + } + + if (strpos($raw, '=') === false) { + throw new \Exception('数据格式错误:必须是 JSON 字符串,或 key=value 格式'); + } + + $data = $this->parseKeyValueData($raw); + + return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + protected function parseKeyValueData(string $raw): array + { + $parts = preg_split('/[&,\s]+/', trim($raw)) ?: []; + $result = []; + + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part === '') { + continue; + } + + $pos = strpos($part, '='); + if ($pos === false) { + throw new \Exception('数据格式错误:key=value 格式中存在无效片段:' . $part); + } + + $key = urldecode(trim(substr($part, 0, $pos))); + $value = urldecode(substr($part, $pos + 1)); + + if ($key === '') { + throw new \Exception('数据格式错误:key 不能为空'); + } + + $result[$key] = $this->castStringValue($value); + } + + return $result; + } + + protected function castStringValue(string $value) + { + $value = trim($value); + + $lower = strtolower($value); + if ($lower === 'true') { + return true; + } + if ($lower === 'false') { + return false; + } + if ($lower === 'null') { + return null; + } + + if ($value !== '' && ($value[0] === '{' || $value[0] === '[')) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + + if (preg_match('/^-?\d+$/', $value)) { + return (int) $value; + } + + if (is_numeric($value)) { + return (float) $value; + } + + return $value; + } + + /** + * 从框架特性参数构建 URL + */ + protected function buildUrlFromFrameworkParams(Input $input): string + { + $app = $input->getOption('app'); + $controller = $input->getOption('controller'); + $action = $input->getOption('action'); + + if (empty($app) || empty($controller) || empty($action)) { + return ''; + } + + // 构建路由路径 + $path = '/' . $app . '/' . $controller . '/' . $action; + + return $path; + } + + protected function resolveSuperToken(Input $input): ?string + { + $raw = $input->getOption('super-token'); + $value = is_bool($raw) ? ($raw ? 'true' : 'false') : trim((string) $raw); + $lower = strtolower($value); + + if (in_array($lower, ['false', '0', 'off', 'no'], true)) { + return null; + } + + if ($value === '' || in_array($lower, ['true', '1', 'on', 'yes'], true)) { + return $this->generateToken(); + } + + return $value; + } + + protected function generateToken(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * 执行 HTTP 请求 + */ + protected function executeRequest(array $request, Output $output): array + { + $guzzleOptions = [ + 'headers' => $request['headers'], + ]; + + // 如果有请求体,添加到选项中 + if ($request['body'] !== null) { + $guzzleOptions['body'] = $request['body']; + } + + // 执行请求 + $guzzleResponse = $this->httpClient->request($request['method'], $request['url'], $guzzleOptions); + + // 解析响应 + $response = [ + 'status' => $guzzleResponse->getStatusCode(), + 'headers' => $guzzleResponse->getHeaders(), + 'data' => null, + ]; + + // 解析响应体 + $body = $guzzleResponse->getBody()->getContents(); + $jsonData = json_decode($body, true); + if (json_last_error() === JSON_ERROR_NONE) { + $response['data'] = $jsonData; + } else { + $response['data'] = $body; + } + + return $response; + } + + /** + * 输出结果 + */ + protected function outputResult(array $response, Output $output): void + { + $executionTime = round((microtime(true) - $this->startTime) * 1000, 2); + + // 构建输出数据 + $outputData = [ + 'success' => $response['status'] >= 200 && $response['status'] < 300, + 'response' => [ + 'status' => $response['status'], + 'data' => $response['data'], + 'headers' => $response['headers'], + ], + 'execution_time' => $executionTime, + 'exception' => null, + ]; + + // 文本模式:输出可读格式 + $this->outputTextResult($outputData, $output); + } + + /** + * 输出文本格式结果 + */ + protected function outputTextResult(array $data, Output $output): void + { + $response = $data['response']; + + $output->newLine(); + $output->info('HTTP 状态: ' . $response['status']); + $output->info('执行时间: ' . $data['execution_time'] . 'ms'); + $output->newLine(); + + // 输出响应头 + $output->comment('响应头:'); + foreach ($response['headers'] as $key => $values) { + $output->writeln(' ' . $key . ': ' . implode(', ', $values)); + } + $output->newLine(); + + // 输出响应数据 + $output->comment('响应数据:'); + if (is_string($response['data'])) { + $output->writeln(' ' . $response['data']); + } else { + $output->writeln(' ' . json_encode($response['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + $output->newLine(); + } + + /** + * 输出错误信息 + */ + protected function outputError(\Exception $e, float $executionTime, Output $output): void + { + $output->error('错误: ' . $e->getMessage()); + $output->writeln('执行时间: ' . $executionTime . 'ms'); + if (env('app_debug', false)) { + $output->writeln('文件: ' . $e->getFile() . ':' . $e->getLine()); + + // 如果是 Guzzle 连接错误,提供提示 + if (strpos($e->getMessage(), 'Failed to connect') !== false) { + $baseUrl = $this->getBaseUrl(); + $output->comment('提示: 请确保应用正在运行(执行 "php think run")或设置 "app.app_host" 环境变量'); + } + } + } +} diff --git a/extend/base/common/command/tools/log/ToolsLogSearchBase.php b/extend/base/common/command/tools/log/ToolsLogSearchBase.php new file mode 100644 index 0000000..26a838e --- /dev/null +++ b/extend/base/common/command/tools/log/ToolsLogSearchBase.php @@ -0,0 +1,199 @@ +setName('tools:log:search') + ->setDescription('搜索数据库日志') + ->addArgument('keywords', Argument::REQUIRED, '搜索关键词') + ->addOption('level', 'l', Option::VALUE_OPTIONAL, '过滤日志级别 (info, warning, error)') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '限制显示条数', 50) + ->addOption('controller', 'c', Option::VALUE_OPTIONAL, '过滤控制器名称') + ->addOption('help', 'h', Option::VALUE_NONE, '显示帮助信息'); + } + + protected function execute($input, $output) + { + if ($input->getOption('help')) { + $this->showHelp($output); + return; + } + + $keywords = $input->getArgument('keywords'); + + if (empty($keywords)) { + $output->error('请提供搜索关键词'); + return; + } + + $filters = $this->buildFilters($input); + $limit = (int)$input->getOption('limit') ?? 50; + + try { + $query = Db::name('debug_log'); + + // 搜索关键词(在 content 字段中) + $query->whereLike('content', '%' . $keywords . '%'); + + // 应用过滤条件 + if (!empty($filters['level'])) { + $query->where('level', '=', $filters['level']); + } + if (!empty($filters['controller'])) { + $query->whereLike('controller_name', '%' . $filters['controller'] . '%'); + } + + // 获取总数 + $count = $query->count(); + + // 获取日志记录 + $logs = $query->order('create_time', 'desc') + ->limit($limit) + ->select() + ->toArray(); + + // 格式化时间戳并高亮关键词 + foreach ($logs as &$log) { + if (!empty($log['create_time'])) { + $log['create_time_formatted'] = date('Y-m-d H:i:s', $log['create_time']); + } + // 高亮关键词 + $log['content_highlighted'] = $this->highlightKeywords($log['content'] ?? '', $keywords); + } + unset($log); + + $this->outputText($logs, $count, $keywords, $filters, $output); + } catch (\Exception $e) { + $output->error('搜索失败:' . $e->getMessage()); + return; + } + } + + /** + * 构建过滤条件 + */ + protected function buildFilters(Input $input): array + { + return [ + 'level' => $input->getOption('level'), + 'controller' => $input->getOption('controller') + ]; + } + + /** + * 高亮关键词 + */ + protected function highlightKeywords(string $content, string $keywords): string + { + // 使用 ANSI 颜色代码高亮关键词(仅用于文本模式) + return str_replace($keywords, '' . $keywords . '', $content); + } + + /** + * 文本格式输出 + */ + protected function outputText(array $logs, int $count, string $keywords, array $filters, Output $output): void + { + $output->newLine(); + $output->info('=== 数据库日志搜索结果 ==='); + $output->newLine(); + + $output->comment('搜索关键词:' . $keywords); + $output->newLine(); + + // 显示过滤条件 + $activeFilters = array_filter($filters, function($value) { + return !empty($value); + }); + if (!empty($activeFilters)) { + $output->comment('过滤条件:'); + foreach ($activeFilters as $key => $value) { + $output->writeln(' - ' . $key . ': ' . $value); + } + $output->newLine(); + } + + if (empty($logs)) { + $output->comment('没有找到包含 "' . $keywords . '" 的日志'); + return; + } + + // 显示搜索结果 + $output->info('搜索结果(共 ' . $count . ' 条,显示 ' . count($logs) . ' 条):'); + $output->newLine(); + + foreach ($logs as $log) { + $levelMethod = $this->getLevelMethod($log['level'] ?? ''); + $output->$levelMethod('[' . $log['level'] . '] ' . + '[' . ($log['create_time_formatted'] ?? 'N/A') . '] ' . + '[' . ($log['controller_name'] ?? '') . '::' . ($log['action_name'] ?? '') . ']'); + + // 显示内容并高亮关键词 + $content = $log['content'] ?? ''; + if (strlen($content) > 200) { + $content = substr($content, 0, 200) . '...'; + } + $output->writeln(' ' . $this->highlightKeywords($content, $keywords)); + $output->newLine(); + } + } + + /** + * 获取日志级别对应的方法名 + */ + protected function getLevelMethod(string $level): string + { + $level = strtolower($level); + $methods = [ + 'error' => 'error', + 'warning' => 'comment', + 'info' => 'info', + 'debug' => 'info' + ]; + + return $methods[$level] ?? 'info'; + } + + /** + * 显示帮助信息 + */ + protected function showHelp(Output $output): void + { + $output->newLine(); + $output->info('命令名称:'); + $output->writeln(' tools:log:search - 搜索数据库日志'); + $output->newLine(); + $output->info('用法:'); + $output->writeln(' php think tools:log:search 关键词 [选项]'); + $output->newLine(); + $output->info('参数:'); + $output->writeln(' keywords 搜索关键词(必填)'); + $output->newLine(); + $output->info('选项:'); + $output->writeln(' -l, --level 过滤日志级别 (info, warning, error)'); + $output->writeln(' --limit 限制显示条数 (默认: 50)'); + $output->writeln(' -c, --controller 过滤控制器名称'); + $output->writeln(' -h, --help 显示帮助信息'); + $output->newLine(); + $output->info('示例:'); + $output->writeln(' php think tools:log:search "用户登录失败" --level=error'); + $output->writeln(' php think tools:log:search "数据库连接" --controller=Admin --limit=20'); + $output->writeln(' php think tools:log:search "Exception" --level=error'); + $output->newLine(); + } +} \ No newline at end of file diff --git a/extend/base/common/command/tools/log/ToolsLogShowBase.php b/extend/base/common/command/tools/log/ToolsLogShowBase.php new file mode 100644 index 0000000..3ae1b43 --- /dev/null +++ b/extend/base/common/command/tools/log/ToolsLogShowBase.php @@ -0,0 +1,195 @@ +setName('tools:log:show') + ->setDescription('查看数据库日志') + ->addOption('level', 'l', Option::VALUE_OPTIONAL, '过滤日志级别 (info, warning, error)') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '限制显示条数', 50) + ->addOption('controller', 'c', Option::VALUE_OPTIONAL, '过滤控制器名称') + ->addOption('action', 'a', Option::VALUE_OPTIONAL, '过滤操作名称') + ->addOption('start', 's', Option::VALUE_OPTIONAL, '开始时间 (Y-m-d H:i:s)') + ->addOption('end', 'e', Option::VALUE_OPTIONAL, '结束时间 (Y-m-d H:i:s)') + ->addOption('help', 'h', Option::VALUE_NONE, '显示帮助信息'); + } + + protected function execute($input, $output) + { + if ($input->getOption('help')) { + $this->showHelp($output); + return; + } + + $filters = $this->buildFilters($input); + $limit = (int)$input->getOption('limit') ?? 50; + + try { + $query = Db::name('debug_log'); + + // 应用过滤条件 + if (!empty($filters['level'])) { + $query->where('level', '=', $filters['level']); + } + if (!empty($filters['controller'])) { + $query->whereLike('controller_name', '%' . $filters['controller'] . '%'); + } + if (!empty($filters['action'])) { + $query->whereLike('action_name', '%' . $filters['action'] . '%'); + } + if (!empty($filters['start'])) { + $startTime = strtotime($filters['start']); + if ($startTime !== false) { + $query->where('create_time', '>=', $startTime); + } + } + if (!empty($filters['end'])) { + $endTime = strtotime($filters['end']); + if ($endTime !== false) { + $query->where('create_time', '<=', $endTime); + } + } + + // 获取总数 + $count = $query->count(); + + // 获取日志记录 + $logs = $query->order('create_time', 'desc') + ->limit($limit) + ->select() + ->toArray(); + + // 格式化时间戳 + foreach ($logs as &$log) { + if (!empty($log['create_time'])) { + $log['create_time_formatted'] = date('Y-m-d H:i:s', $log['create_time']); + } + } + unset($log); + + $this->outputText($logs, $count, $filters, $output); + } catch (\Exception $e) { + $output->error('查询失败:' . $e->getMessage()); + return; + } + } + + /** + * 构建过滤条件 + */ + protected function buildFilters(Input $input): array + { + return [ + 'level' => $input->getOption('level'), + 'controller' => $input->getOption('controller'), + 'action' => $input->getOption('action'), + 'start' => $input->getOption('start'), + 'end' => $input->getOption('end') + ]; + } + + /** + * 文本格式输出 + */ + protected function outputText(array $logs, int $count, array $filters, Output $output): void + { + $output->newLine(); + $output->info('=== 数据库日志查询结果 ==='); + $output->newLine(); + + // 显示过滤条件 + $activeFilters = array_filter($filters, function($value) { + return !empty($value); + }); + if (!empty($activeFilters)) { + $output->comment('过滤条件:'); + foreach ($activeFilters as $key => $value) { + $output->writeln(' - ' . $key . ': ' . $value); + } + $output->newLine(); + } + + if (empty($logs)) { + $output->comment('没有找到符合条件的日志'); + return; + } + + // 显示日志列表 + $output->info('日志列表(共 ' . $count . ' 条,显示 ' . count($logs) . ' 条):'); + $output->newLine(); + + foreach ($logs as $log) { + $levelMethod = $this->getLevelMethod($log['level'] ?? ''); + $output->$levelMethod('[' . $log['level'] . '] ' . + '[' . ($log['create_time_formatted'] ?? 'N/A') . '] ' . + '[' . ($log['controller_name'] ?? '') . '::' . ($log['action_name'] ?? '') . ']'); + + // 显示内容摘要 + $content = $log['content'] ?? ''; + if (strlen($content) > 200) { + $content = substr($content, 0, 200) . '...'; + } + $output->writeln(' ' . $content); + $output->newLine(); + } + } + + /** + * 获取日志级别对应的方法名 + */ + protected function getLevelMethod(string $level): string + { + $level = strtolower($level); + $methods = [ + 'error' => 'error', + 'warning' => 'comment', + 'info' => 'info', + 'debug' => 'info' + ]; + + return $methods[$level] ?? 'info'; + } + + /** + * 显示帮助信息 + */ + protected function showHelp(Output $output): void + { + $output->newLine(); + $output->info('命令名称:'); + $output->writeln(' tools:log:show - 查看数据库日志'); + $output->newLine(); + $output->info('用法:'); + $output->writeln(' php think tools:log:show [选项]'); + $output->newLine(); + $output->info('选项:'); + $output->writeln(' -l, --level 过滤日志级别 (info, warning, error)'); + $output->writeln(' --limit 限制显示条数 (默认: 50)'); + $output->writeln(' -c, --controller 过滤控制器名称'); + $output->writeln(' -a, --action 过滤操作名称'); + $output->writeln(' -s, --start 开始时间 (Y-m-d H:i:s)'); + $output->writeln(' -e, --end 结束时间 (Y-m-d H:i:s)'); + $output->writeln(' -h, --help 显示帮助信息'); + $output->newLine(); + $output->info('示例:'); + $output->writeln(' php think tools:log:show --level=error --limit=10'); + $output->writeln(' php think tools:log:show --controller=Admin --limit=20'); + $output->writeln(' php think tools:log:show --start="2024-01-01 00:00:00" --end="2024-12-31 23:59:59"'); + $output->newLine(); + } +} \ No newline at end of file diff --git a/extend/base/common/command/tools/log/ToolsLogStatsBase.php b/extend/base/common/command/tools/log/ToolsLogStatsBase.php new file mode 100644 index 0000000..0ed7a7f --- /dev/null +++ b/extend/base/common/command/tools/log/ToolsLogStatsBase.php @@ -0,0 +1,259 @@ +setName('tools:log:stats') + ->setDescription('统计数据库日志') + ->addOption('by-level', null, Option::VALUE_NONE, '按日志级别统计') + ->addOption('by-controller', null, Option::VALUE_NONE, '按控制器统计') + ->addOption('by-time', null, Option::VALUE_NONE, '按时间统计') + ->addOption('time-group', null, Option::VALUE_OPTIONAL, '时间分组 (hour|day|week|month)', 'day') + ->addOption('help', 'h', Option::VALUE_NONE, '显示帮助信息'); + } + + protected function execute($input, $output) + { + if ($input->getOption('help')) { + $this->showHelp($output); + return; + } + + $byLevel = $input->getOption('by-level'); + $byController = $input->getOption('by-controller'); + $byTime = $input->getOption('by-time'); + $timeGroup = $input->getOption('time-group') ?? 'day'; + + // 如果没有指定任何统计类型,默认显示所有统计 + if (!$byLevel && !$byController && !$byTime) { + $byLevel = $byController = $byTime = true; + } + + try { + $stats = [ + 'by_level' => [], + 'by_controller' => [], + 'by_time' => [] + ]; + + // 按日志级别统计 + if ($byLevel) { + $stats['by_level'] = $this->statsByLevel(); + } + + // 按控制器统计 + if ($byController) { + $stats['by_controller'] = $this->statsByController(); + } + + // 按时间统计 + if ($byTime) { + $stats['by_time'] = $this->statsByTime($timeGroup); + } + + // 总数 + $stats['total'] = Db::name('debug_log')->count(); + + $this->outputText($stats, $byLevel, $byController, $byTime, $output); + } catch (\Exception $e) { + $output->error('统计失败:' . $e->getMessage()); + return; + } + } + + /** + * 按日志级别统计 + */ + protected function statsByLevel(): array + { + $results = Db::name('debug_log') + ->field('level, COUNT(*) as count') + ->group('level') + ->select() + ->toArray(); + + $stats = []; + foreach ($results as $row) { + $stats[$row['level']] = (int)$row['count']; + } + + return $stats; + } + + /** + * 按控制器统计 + */ + protected function statsByController(): array + { + $results = Db::name('debug_log') + ->field('controller_name, COUNT(*) as count') + ->where('controller_name', '<>', '') + ->group('controller_name') + ->order('count', 'desc') + ->limit(20) + ->select() + ->toArray(); + + $stats = []; + foreach ($results as $row) { + $stats[$row['controller_name']] = (int)$row['count']; + } + + return $stats; + } + + /** + * 按时间统计 + */ + protected function statsByTime(string $group): array + { + $query = Db::name('debug_log'); + + // 根据分组类型格式化时间 + switch ($group) { + case 'hour': + $dateFormat = '%Y-%m-%d %H:00'; + $phpFormat = 'Y-m-d H:00'; + break; + case 'week': + $dateFormat = '%Y-%u'; // 年-周数 + $phpFormat = 'Y-W'; // 年-周数 + break; + case 'month': + $dateFormat = '%Y-%m'; + $phpFormat = 'Y-m'; + break; + case 'day': + default: + $dateFormat = '%Y-%m-%d'; + $phpFormat = 'Y-m-d'; + break; + } + + // 使用 MySQL DATE_FORMAT 函数 + $results = $query->field('FROM_UNIXTIME(create_time, \'' . $dateFormat . '\') as time_period, COUNT(*) as count') + ->where('create_time', '>', 0) + ->group('time_period') + ->order('time_period', 'desc') + ->limit(30) + ->select() + ->toArray(); + + $stats = []; + foreach ($results as $row) { + $stats[$row['time_period']] = (int)$row['count']; + } + + return $stats; + } + + /** + * 文本格式输出 + */ + protected function outputText(array $stats, bool $byLevel, bool $byController, bool $byTime, Output $output): void + { + $output->newLine(); + $output->info('=== 数据库日志统计 ==='); + $output->newLine(); + + // 总数 + $output->info('总日志数:' . ($stats['total'] ?? 0)); + $output->newLine(); + + // 按级别统计 + if ($byLevel) { + $output->comment('按日志级别统计:'); + if (empty($stats['by_level'])) { + $output->writeln(' 无数据'); + } else { + foreach ($stats['by_level'] as $level => $count) { + $output->writeln(' ' . $level . ':' . $count . ' 条'); + } + } + $output->newLine(); + } + + // 按控制器统计 + if ($byController) { + $output->comment('按控制器统计(Top 20):'); + if (empty($stats['by_controller'])) { + $output->writeln(' 无数据'); + } else { + foreach ($stats['by_controller'] as $controller => $count) { + $output->writeln(' ' . $controller . ':' . $count . ' 条'); + } + } + $output->newLine(); + } + + // 按时间统计 + if ($byTime) { + $output->comment('按时间统计(最近 30 个时段):'); + if (empty($stats['by_time'])) { + $output->writeln(' 无数据'); + } else { + foreach ($stats['by_time'] as $timePeriod => $count) { + $output->writeln(' ' . $timePeriod . ':' . $count . ' 条'); + } + } + $output->newLine(); + } + } + + /** + * 获取日志级别对应的颜色 + */ + protected function getLevelColor(string $level): string + { + $level = strtolower($level); + $colors = [ + 'error' => 'error', + 'warning' => 'comment', + 'info' => 'info', + 'debug' => 'info' + ]; + + return $colors[$level] ?? 'info'; + } + + /** + * 显示帮助信息 + */ + protected function showHelp(Output $output): void + { + $output->newLine(); + $output->info('命令名称:'); + $output->writeln(' tools:log:stats - 统计数据库日志'); + $output->newLine(); + $output->info('用法:'); + $output->writeln(' php think tools:log:stats [选项]'); + $output->newLine(); + $output->info('选项:'); + $output->writeln(' --by-level 按日志级别统计'); + $output->writeln(' --by-controller 按控制器统计'); + $output->writeln(' --by-time 按时间统计'); + $output->writeln(' --time-group 时间分组 (hour|day|week|month,默认: day)'); + $output->writeln(' -h, --help 显示帮助信息'); + $output->newLine(); + $output->info('示例:'); + $output->writeln(' php think tools:log:stats'); + $output->writeln(' php think tools:log:stats --by-level'); + $output->writeln(' php think tools:log:stats --by-controller --by-time'); + $output->writeln(' php think tools:log:stats --by-time --time-group=hour'); + $output->newLine(); + } +} \ No newline at end of file diff --git a/extend/base/common/console/OutputBase.php b/extend/base/common/console/OutputBase.php index 59bf51c..80b0ba4 100644 --- a/extend/base/common/console/OutputBase.php +++ b/extend/base/common/console/OutputBase.php @@ -12,7 +12,7 @@ class OutputBase extends Output $question = $this->appendForceForceTip($question); if ($this->isForceForceEnabled($input)) { - return true; + return $default; } return parent::confirm($input, $question, $default); diff --git a/extend/base/common/controller/AdminControllerBase.php b/extend/base/common/controller/AdminControllerBase.php index df3e238..e92107b 100644 --- a/extend/base/common/controller/AdminControllerBase.php +++ b/extend/base/common/controller/AdminControllerBase.php @@ -134,6 +134,40 @@ class AdminControllerBase extends BaseController $this->assign('Controller', $this, -1); } + /** + * 获取当前登录管理员ID(控制器上下文)。 + * + * 优先使用控制器已完成鉴权后的上下文($this->sessionAdmin), + * 在非标准场景下兼容从 Request 挂载的 adminInfo 读取, + * 最后兜底到全局函数 get_session_admin('id')(适用于无控制器上下文的通用读取方式)。 + * + * @param mixed $default 未登录或无法获取时返回的默认值 + * @return mixed + */ + public function getAdminId($default = null) + { + if (!empty($this->sessionAdmin) && isset($this->sessionAdmin->id)) { + return $this->sessionAdmin->id; + } + + if (isset($this->request->adminInfo)) { + $adminInfo = $this->request->adminInfo; + if (is_array($adminInfo) && isset($adminInfo['id'])) { + return $adminInfo['id']; + } + if (is_object($adminInfo) && isset($adminInfo->id)) { + return $adminInfo->id; + } + } + + $adminId = get_session_admin('id'); + if ($adminId !== null && $adminId !== '') { + return $adminId; + } + + return $default; + } + public function initSort() { $sort = $this->request->param('sort'); @@ -232,6 +266,10 @@ class AdminControllerBase extends BaseController */ public function fetch($template = '', $vars = []) { + if (Config::get('app.auto_parse_api') && $this->request->param('get_page_data') && !$this->request->isJson()) { + return json_message([], 400, '使用 get_page_data 获取页面数据时,请设置请求头 Accept: application/json'); + } + $this->assign('data_brage', json_encode($this->dataBrage)); $this->assign('content_js', $this->fetchJS($template), -1); diff --git a/extend/base/common/scheme/attribute/Field.php b/extend/base/common/scheme/attribute/Field.php index ed35610..e10b877 100644 --- a/extend/base/common/scheme/attribute/Field.php +++ b/extend/base/common/scheme/attribute/Field.php @@ -19,5 +19,14 @@ class Field public bool $autoIncrement = false, public bool $primary = false ) { + $type = strtolower(trim($this->type)); + + if ($this->length !== null && $this->length < 1) { + throw new \InvalidArgumentException("Scheme 字段 length 必须 >= 1,当前={$this->length}"); + } + + if ($type === 'char' && $this->length !== null && $this->length > 255) { + throw new \InvalidArgumentException("Scheme 字段类型 char 的 length 超出范围,允许 1-255,当前={$this->length}"); + } } } diff --git a/extend/base/common/service/ErrorHandlerBase.php b/extend/base/common/service/ErrorHandlerBase.php new file mode 100644 index 0000000..4cd1240 --- /dev/null +++ b/extend/base/common/service/ErrorHandlerBase.php @@ -0,0 +1,326 @@ + '未知错误', + 'ERR_GEN_INVALID_PARAM' => '参数错误', + 'ERR_GEN_MISSING_PARAM' => '缺少必需参数', + 'ERR_GEN_OPERATION_FAILED' => '操作失败', + 'ERR_GEN_PERMISSION_DENIED' => '权限不足', + 'ERR_GEN_NOT_FOUND' => '资源不存在', + 'ERR_GEN_TIMEOUT' => '操作超时', + + // 数据库错误 (ERR_DB_XXX) + 'ERR_DB_CONNECTION' => '数据库连接失败', + 'ERR_DB_QUERY' => '数据库查询失败', + 'ERR_DB_INSERT' => '数据插入失败', + 'ERR_DB_UPDATE' => '数据更新失败', + 'ERR_DB_DELETE' => '数据删除失败', + 'ERR_DB_TRANSACTION' => '数据库事务执行失败', + 'ERR_DB_SCHEME_MISMATCH' => '数据库结构与期望不符', + + // 文件系统错误 (ERR_FS_XXX) + 'ERR_FS_NOT_FOUND' => '文件不存在', + 'ERR_FS_READ_FAILED' => '文件读取失败', + 'ERR_FS_WRITE_FAILED' => '文件写入失败', + 'ERR_FS_DELETE_FAILED' => '文件删除失败', + 'ERR_FS_PERMISSION' => '文件权限不足', + + // 网络错误 (ERR_NET_XXX) + 'ERR_NET_CONNECTION' => '网络连接失败', + 'ERR_NET_TIMEOUT' => '网络请求超时', + 'ERR_NET_RESPONSE' => '网络响应错误', + + // 配置错误 (ERR_CFG_XXX) + 'ERR_CFG_MISSING' => '配置缺失', + 'ERR_CFG_INVALID' => '配置无效', + ]; + + /** + * 修复建议注册表 + */ + protected array $suggestions = [ + // 通用错误修复建议 + 'ERR_GEN_UNKNOWN' => '未知错误,请稍后重试或联系管理员', + 'ERR_GEN_INVALID_PARAM' => '请检查传入的参数是否符合要求', + 'ERR_GEN_MISSING_PARAM' => '请确保所有必需参数都已提供', + 'ERR_GEN_OPERATION_FAILED' => '请稍后重试,如果问题持续存在请联系管理员', + 'ERR_GEN_PERMISSION_DENIED' => '请检查您是否有执行此操作的权限', + 'ERR_GEN_NOT_FOUND' => '请确认请求的资源ID是否正确', + 'ERR_GEN_TIMEOUT' => '请检查网络连接或稍后重试', + + // 数据库错误修复建议 + 'ERR_DB_CONNECTION' => '请检查数据库连接配置和网络连接', + 'ERR_DB_QUERY' => '请检查SQL语句语法和数据库表结构', + 'ERR_DB_INSERT' => '请检查数据是否符合表结构和约束条件', + 'ERR_DB_UPDATE' => '请确保要更新的数据存在且符合约束条件', + 'ERR_DB_DELETE' => '请确保要删除的数据存在', + 'ERR_DB_TRANSACTION' => '请检查事务中的操作是否都执行成功', + 'ERR_DB_SCHEME_MISMATCH' => '请运行数据库迁移命令: php think migrate:run', + + // 文件系统错误修复建议 + 'ERR_FS_NOT_FOUND' => '请检查文件路径是否正确', + 'ERR_FS_READ_FAILED' => '请检查文件是否存在且有读取权限', + 'ERR_FS_WRITE_FAILED' => '请检查目录是否存在且有写入权限', + 'ERR_FS_DELETE_FAILED' => '请检查文件是否存在且有删除权限', + 'ERR_FS_PERMISSION' => '请检查文件或目录的权限设置', + + // 网络错误修复建议 + 'ERR_NET_CONNECTION' => '请检查网络连接和目标服务器状态', + 'ERR_NET_TIMEOUT' => '请检查网络连接或增加超时时间', + 'ERR_NET_RESPONSE' => '请检查请求参数和服务器响应', + + // 配置错误修复建议 + 'ERR_CFG_MISSING' => '请在配置文件中添加缺失的配置项', + 'ERR_CFG_INVALID' => '请检查配置项的值是否符合要求', + ]; + + /** + * 获取结构化错误信息 + * + * @param string $errorCode 错误码 + * @param string|null $errorMessage 错误消息(覆盖默认错误消息) + * @param array $context 上下文信息 + * @return array 结构化错误信息 + */ + public function getError(string $errorCode, ?string $errorMessage = null, array $context = []): array + { + $defaultMessage = $this->errorCodes[$errorCode] ?? $this->errorCodes['ERR_GEN_UNKNOWN']; + $actualMessage = $errorMessage ?? $defaultMessage; + + $error = [ + 'success' => false, + 'error_code' => $errorCode, + 'error_message' => $actualMessage, + ]; + + // 添加文件和行信息(如果存在) + if (isset($context['file'])) { + $error['file'] = $context['file']; + } + if (isset($context['line'])) { + $error['line'] = $context['line']; + } + + // 添加修复建议 + $suggestion = $this->suggestions[$errorCode] ?? $this->suggestions['ERR_GEN_UNKNOWN']; + $error['suggestion'] = $suggestion; + + // 添加额外的上下文信息 + if (!empty($context)) { + $error['context'] = $context; + } + + return $error; + } + + /** + * 从异常生成结构化错误信息 + * + * @param Throwable $exception 异常对象 + * @param string|null $customErrorCode 自定义错误码 + * @return array 结构化错误信息 + */ + public function getErrorForException(Throwable $exception, ?string $customErrorCode = null): array + { + // 尝试从异常消息推断错误码 + $errorCode = $customErrorCode ?? $this->inferErrorCodeFromException($exception); + + $error = [ + 'success' => false, + 'error_code' => $errorCode, + 'error_message' => $exception->getMessage(), + ]; + + // 添加文件和行信息 + if ($exception->getFile()) { + $error['file'] = $exception->getFile(); + } + if ($exception->getLine()) { + $error['line'] = $exception->getLine(); + } + + // 添加修复建议 + $suggestion = $this->suggestions[$errorCode] ?? $this->suggestions['ERR_GEN_UNKNOWN']; + $error['suggestion'] = $suggestion; + + // 添加异常类型 + $error['exception_type'] = get_class($exception); + + // 添加堆栈跟踪(在开发模式下) + if ($this->isDebugMode()) { + $error['trace'] = $this->formatTrace($exception); + } + + return $error; + } + + /** + * 从异常推断错误码 + * + * @param Throwable $exception 异常对象 + * @return string 错误码 + */ + protected function inferErrorCodeFromException(Throwable $exception): string + { + $message = $exception->getMessage(); + $class = get_class($exception); + + // 根据异常类型推断 + if (strpos($class, 'Db') !== false || strpos($class, 'Query') !== false) { + if (strpos($message, 'connection') !== false || strpos($message, 'connect') !== false) { + return 'ERR_DB_CONNECTION'; + } + if (strpos($message, 'SQLSTATE') !== false) { + return 'ERR_DB_QUERY'; + } + return 'ERR_DB_OPERATION_FAILED'; + } + + if (strpos($class, 'File') !== false || strpos($class, 'Stream') !== false) { + if (strpos($message, 'No such file') !== false || strpos($message, 'not found') !== false) { + return 'ERR_FS_NOT_FOUND'; + } + if (strpos($message, 'permission') !== false) { + return 'ERR_FS_PERMISSION'; + } + return 'ERR_FS_READ_FAILED'; + } + + if (strpos($class, 'Network') !== false || strpos($class, 'Curl') !== false) { + if (strpos($message, 'timeout') !== false) { + return 'ERR_NET_TIMEOUT'; + } + return 'ERR_NET_CONNECTION'; + } + + // 根据消息内容推断 + if (strpos($message, 'permission') !== false || strpos($message, 'denied') !== false) { + return 'ERR_GEN_PERMISSION_DENIED'; + } + + if (strpos($message, 'not found') !== false || strpos($message, '不存在') !== false) { + return 'ERR_GEN_NOT_FOUND'; + } + + if (strpos($message, 'timeout') !== false) { + return 'ERR_GEN_TIMEOUT'; + } + + return 'ERR_GEN_UNKNOWN'; + } + + /** + * 格式化堆栈跟踪 + * + * @param Throwable $exception 异常对象 + * @return array 格式化的堆栈跟踪 + */ + protected function formatTrace(Throwable $exception): array + { + $trace = []; + foreach ($exception->getTrace() as $index => $frame) { + $traceItem = [ + 'index' => $index, + ]; + + if (isset($frame['file'])) { + $traceItem['file'] = $frame['file']; + } + if (isset($frame['line'])) { + $traceItem['line'] = $frame['line']; + } + if (isset($frame['function'])) { + $traceItem['function'] = $frame['function']; + } + if (isset($frame['class'])) { + $traceItem['class'] = $frame['class']; + } + if (isset($frame['type'])) { + $traceItem['type'] = $frame['type']; + } + + $trace[] = $traceItem; + } + return $trace; + } + + /** + * 检查是否为调试模式 + * + * @return bool + */ + protected function isDebugMode(): bool + { + try { + $app = \think\facade\App::instance(); + return (bool)($app->config->get('app.app_debug') ?? false); + } catch (\Exception $e) { + return false; + } + } + + /** + * 输出错误(作为JSON) + * + * @param array $error 错误信息 + * @return string JSON格式的错误信息 + */ + public function outputError(array $error): string + { + $options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + return json_encode($error, $options); + } + + /** + * 注册自定义错误码 + * + * @param string $errorCode 错误码 + * @param string $errorMessage 错误消息 + * @param string|null $suggestion 修复建议 + * @return void + */ + public function registerErrorCode(string $errorCode, string $errorMessage, ?string $suggestion = null): void + { + $this->errorCodes[$errorCode] = $errorMessage; + if ($suggestion !== null) { + $this->suggestions[$errorCode] = $suggestion; + } + } + + /** + * 批量注册错误码 + * + * @param array $errors 错误码数组,格式:['ERR_XXX' => ['message' => '...', 'suggestion' => '...']] + * @return void + */ + public function registerErrorCodes(array $errors): void + { + foreach ($errors as $errorCode => $config) { + $this->errorCodes[$errorCode] = $config['message'] ?? '未知错误'; + if (isset($config['suggestion'])) { + $this->suggestions[$errorCode] = $config['suggestion']; + } + } + } + + /** + * 获取所有已注册的错误码 + * + * @return array 错误码数组 + */ + public function getRegisteredErrorCodes(): array + { + return $this->errorCodes; + } +} diff --git a/extend/base/common/service/MenuServiceBase.php b/extend/base/common/service/MenuServiceBase.php index da1aae3..7cfc8bf 100644 --- a/extend/base/common/service/MenuServiceBase.php +++ b/extend/base/common/service/MenuServiceBase.php @@ -111,4 +111,198 @@ class MenuServiceBase return $menuData; } + + /** + * 创建菜单 + * + * @param array $data 菜单数据 + * @return int 返回新创建的菜单ID + * @throws \Exception + */ + public function create(array $data): int + { + $menu = Db::name('system_menu'); + + // 准备数据 + $insertData = [ + 'pid' => $data['parent_id'] ?? 0, + 'title' => $data['title'] ?? '', + 'icon' => $data['icon'] ?? '', + 'href' => $data['path'] ?? '', + 'auth_node' => $data['node'] ?? '', + 'sort' => $data['sort'] ?? 100, + 'status' => 1, + 'target' => '_self', + 'remark' => $data['remark'] ?? '', + 'create_time' => time(), + 'update_time' => time(), + 'delete_time' => 0, + ]; + + // 验证必填字段 + if (empty($insertData['title'])) { + throw new \Exception('菜单标题不能为空'); + } + + // 验证父菜单是否存在 + if ($insertData['pid'] > 0) { + $parentMenu = Db::name('system_menu') + ->where('id', $insertData['pid']) + ->where('delete_time', 0) + ->find(); + if (empty($parentMenu)) { + throw new \Exception("父菜单ID {$insertData['pid']} 不存在"); + } + } + + // 插入数据 + $menuId = $menu->insertGetId($insertData); + + if (!$menuId) { + throw new \Exception('菜单创建失败'); + } + + return (int)$menuId; + } + + /** + * 更新菜单 + * + * @param int $menuId 菜单ID + * @param array $data 更新数据 + * @return bool 是否更新成功 + * @throws \Exception + */ + public function update(int $menuId, array $data): bool + { + // 验证菜单是否存在 + $menu = Db::name('system_menu') + ->where('id', $menuId) + ->where('delete_time', 0) + ->find(); + + if (empty($menu)) { + throw new \Exception("菜单ID {$menuId} 不存在"); + } + + // 准备更新数据 + $updateData = [ + 'update_time' => time(), + ]; + + if (isset($data['title'])) { + $updateData['title'] = $data['title']; + } + if (isset($data['parent_id'])) { + $updateData['pid'] = $data['parent_id']; + } + if (isset($data['icon'])) { + $updateData['icon'] = $data['icon']; + } + if (isset($data['path'])) { + $updateData['href'] = $data['path']; + } + if (isset($data['node'])) { + $updateData['auth_node'] = $data['node']; + } + if (isset($data['sort'])) { + $updateData['sort'] = $data['sort']; + } + if (isset($data['status'])) { + $updateData['status'] = $data['status']; + } + if (isset($data['remark'])) { + $updateData['remark'] = $data['remark']; + } + if (isset($data['target'])) { + $updateData['target'] = $data['target']; + } + + // 验证必填字段 + if (empty($menu['title']) && empty($updateData['title'])) { + throw new \Exception('菜单标题不能为空'); + } + + // 验证父菜单是否存在(如果修改了父菜单) + if (isset($updateData['pid']) && $updateData['pid'] > 0) { + // 不能将自己设置为父菜单 + if ($updateData['pid'] == $menuId) { + throw new \Exception('不能将自己设置为父菜单'); + } + + $parentMenu = Db::name('system_menu') + ->where('id', $updateData['pid']) + ->where('delete_time', 0) + ->find(); + if (empty($parentMenu)) { + throw new \Exception("父菜单ID {$updateData['pid']} 不存在"); + } + } + + // 更新数据 + $result = Db::name('system_menu') + ->where('id', $menuId) + ->update($updateData); + + return $result !== false; + } + + /** + * 删除菜单 + * + * @param int $menuId 菜单ID + * @return bool 是否删除成功 + * @throws \Exception + */ + public function delete(int $menuId): bool + { + // 验证菜单是否存在 + $menu = Db::name('system_menu') + ->where('id', $menuId) + ->where('delete_time', 0) + ->find(); + + if (empty($menu)) { + throw new \Exception("菜单ID {$menuId} 不存在"); + } + + // 检查是否有子菜单 + $hasChildren = Db::name('system_menu') + ->where('pid', $menuId) + ->where('delete_time', 0) + ->count(); + + if ($hasChildren > 0) { + throw new \Exception('该菜单下存在子菜单,请先删除子菜单'); + } + + // 软删除 + $result = Db::name('system_menu') + ->where('id', $menuId) + ->update([ + 'delete_time' => time(), + 'update_time' => time(), + ]); + + return $result !== false; + } + + /** + * 获取菜单详情 + * + * @param int $menuId 菜单ID + * @return array|null 菜单数据 + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\DbException + * @throws \think\db\exception\ModelNotFoundException + */ + public function getMenu(int $menuId): ?array + { + $menu = Db::name('system_menu') + ->where('id', $menuId) + ->where('delete_time', 0) + ->find(); + + return $menu ? $menu : null; + } } diff --git a/extend/base/common/service/scheme/SchemeToDbService.php b/extend/base/common/service/scheme/SchemeToDbService.php index 5905a39..199c63b 100644 --- a/extend/base/common/service/scheme/SchemeToDbService.php +++ b/extend/base/common/service/scheme/SchemeToDbService.php @@ -46,6 +46,8 @@ class SchemeToDbService $fullTableName = $prefix . $tableName; } + $sql = $this->buildCreateTableSql($fullTableName, $tableAttr, $ref); + // 检查表是否存在 $tableExists = $this->checkTableExists($connection, $fullTableName); $backupTableName = null; @@ -66,7 +68,6 @@ class SchemeToDbService } // 2. 建表 - $sql = $this->buildCreateTableSql($fullTableName, $tableAttr, $ref); Db::connect($connection)->execute($sql); // 3. 恢复数据 @@ -121,88 +122,159 @@ class SchemeToDbService $schemeIndexes = $this->buildSchemeIndexSignature($ref); $dbIndexes = $this->buildDbIndexSignature($dbKeysRows); - $diffs = []; + $missingFields = []; + $extraFields = []; + $changedFields = []; foreach ($schemeColumns as $field => $sig) { if (!isset($dbColumns[$field])) { - $diffs[] = "缺少字段:{$field}"; + $missingFields[] = $field; continue; } $row = $dbColumns[$field]; + $fieldChanges = []; $dbType = $this->normalizeDbType((string)$row['Type']); $schemeType = $this->normalizeDbType($sig['type']); if ($dbType !== $schemeType) { - $diffs[] = "字段类型不一致:{$field} DB={$dbType} Scheme={$schemeType}"; + $fieldChanges['type'] = ['db' => $dbType, 'scheme' => $schemeType]; } $dbNull = (string)$row['Null']; $schemeNull = $sig['null']; if ($dbNull !== $schemeNull) { - $diffs[] = "字段可空不一致:{$field} DB={$dbNull} Scheme={$schemeNull}"; + $fieldChanges['null'] = ['db' => $dbNull, 'scheme' => $schemeNull]; } $dbDefault = $row['Default']; $schemeDefault = $sig['default']; if (!$this->defaultEquals($dbDefault, $schemeDefault)) { - $dbStr = is_null($dbDefault) ? 'NULL' : (string)$dbDefault; - $schemeStr = is_null($schemeDefault) ? 'NULL' : (string)$schemeDefault; - $diffs[] = "字段默认值不一致:{$field} DB={$dbStr} Scheme={$schemeStr}"; + $fieldChanges['default'] = [ + 'db' => $this->stringifyDefault($dbDefault), + 'scheme' => $this->stringifyDefault($schemeDefault), + ]; } $dbExtra = (string)$row['Extra']; $schemeExtra = $sig['extra']; if ($dbExtra !== $schemeExtra) { - $diffs[] = "字段 Extra 不一致:{$field} DB={$dbExtra} Scheme={$schemeExtra}"; + $fieldChanges['extra'] = ['db' => $dbExtra, 'scheme' => $schemeExtra]; } $dbPrimary = (string)$row['Key'] === 'PRI'; if ($dbPrimary !== $sig['primary']) { - $diffs[] = "字段主键不一致:{$field} DB=" . ($dbPrimary ? 'PRI' : '') . " Scheme=" . ($sig['primary'] ? 'PRI' : ''); + $fieldChanges['primary'] = [ + 'db' => $dbPrimary ? 'PRI' : '', + 'scheme' => $sig['primary'] ? 'PRI' : '', + ]; } $dbComment = (string)$row['Comment']; $schemeComment = $sig['comment']; if ($this->parseComment($dbComment) !== $this->parseComment($schemeComment)) { - $diffs[] = "字段注释不一致:{$field} DB={$dbComment} Scheme={$schemeComment}"; + $fieldChanges['comment'] = ['db' => $dbComment, 'scheme' => $schemeComment]; + } + + if (!empty($fieldChanges)) { + $changedFields[$field] = $fieldChanges; } } foreach ($dbColumns as $field => $row) { if (!isset($schemeColumns[$field])) { - $diffs[] = "多余字段:{$field}"; + $extraFields[] = $field; } } + $missingIndexes = []; + $extraIndexes = []; + $changedIndexes = []; + foreach ($schemeIndexes as $name => $idx) { if (!isset($dbIndexes[$name])) { - $diffs[] = "缺少索引:{$name}"; + $missingIndexes[] = $name; continue; } $dbIdx = $dbIndexes[$name]; + $idxChanges = []; + if ($dbIdx['type'] !== $idx['type']) { - $diffs[] = "索引类型不一致:{$name} DB={$dbIdx['type']} Scheme={$idx['type']}"; + $idxChanges['type'] = ['db' => $dbIdx['type'], 'scheme' => $idx['type']]; } if ($dbIdx['columns'] !== $idx['columns']) { - $diffs[] = "索引字段不一致:{$name} DB=" . implode(',', $dbIdx['columns']) . " Scheme=" . implode(',', $idx['columns']); + $idxChanges['columns'] = [ + 'db' => implode(',', $dbIdx['columns']), + 'scheme' => implode(',', $idx['columns']), + ]; + } + + if (!empty($idxChanges)) { + $changedIndexes[$name] = $idxChanges; } } foreach ($dbIndexes as $name => $idx) { if (!isset($schemeIndexes[$name])) { - $diffs[] = "多余索引:{$name}"; + $extraIndexes[] = $name; } } $tableCommentDiff = $this->diffTableComment($connection, $fullTableName, $tableAttr->comment); - if ($tableCommentDiff !== null) { - $diffs[] = $tableCommentDiff; + $hasDiff = !empty($missingFields) || !empty($extraFields) || !empty($changedFields) || !empty($missingIndexes) || !empty($extraIndexes) || !empty($changedIndexes) || $tableCommentDiff !== null; + if (!$hasDiff) { + return []; } - return $diffs; + $lines = []; + $lines[] = '差异汇总:' + . '字段缺失=' . count($missingFields) + . ',字段多余=' . count($extraFields) + . ',字段修改=' . count($changedFields) + . ';索引缺失=' . count($missingIndexes) + . ',索引多余=' . count($extraIndexes) + . ',索引修改=' . count($changedIndexes) + . ';表注释=' . ($tableCommentDiff !== null ? '不一致' : '一致'); + + if (!empty($missingFields)) { + $lines[] = '字段缺失(' . count($missingFields) . '):' . implode(',', $missingFields); + } + if (!empty($extraFields)) { + $lines[] = '字段多余(' . count($extraFields) . '):' . implode(',', $extraFields); + } + if (!empty($changedFields)) { + $lines[] = '字段修改(' . count($changedFields) . '):' . implode(',', array_keys($changedFields)); + foreach ($changedFields as $field => $changes) { + $lines[] = '字段 ' . $field . ':'; + foreach ($changes as $k => $v) { + $lines[] = ' - ' . $k . ':DB=' . $v['db'] . ' Scheme=' . $v['scheme']; + } + } + } + + if (!empty($missingIndexes)) { + $lines[] = '索引缺失(' . count($missingIndexes) . '):' . implode(',', $missingIndexes); + } + if (!empty($extraIndexes)) { + $lines[] = '索引多余(' . count($extraIndexes) . '):' . implode(',', $extraIndexes); + } + if (!empty($changedIndexes)) { + $lines[] = '索引修改(' . count($changedIndexes) . '):' . implode(',', array_keys($changedIndexes)); + foreach ($changedIndexes as $name => $changes) { + $lines[] = '索引 ' . $name . ':'; + foreach ($changes as $k => $v) { + $lines[] = ' - ' . $k . ':DB=' . $v['db'] . ' Scheme=' . $v['scheme']; + } + } + } + + if ($tableCommentDiff !== null) { + $lines[] = $tableCommentDiff; + } + + return $lines; } public function getColumnsForCurd(string $className, array $onlyFields = []): array @@ -227,6 +299,7 @@ class SchemeToDbService /** @var Field $field */ $field = $fieldAttrs[0]->newInstance(); + $this->validateSchemeField($field, $ref->getName(), $fieldName); $columns[$fieldName] = [ 'type' => $this->buildMysqlTypeFromSchemeField($field), @@ -283,6 +356,7 @@ class SchemeToDbService /** @var Field $field */ $field = $fieldAttrs[0]->newInstance(); $fieldName = $prop->getName(); + $this->validateSchemeField($field, $ref->getName(), $fieldName); $line = "`$fieldName` {$field->type}"; @@ -364,6 +438,20 @@ class SchemeToDbService return "CREATE TABLE `$tableName` (\n $body\n) ENGINE={$tableAttr->engine} DEFAULT CHARSET={$tableAttr->charset}$comment"; } + protected function validateSchemeField(Field $field, string $className, string $fieldName): void + { + $type = strtolower(trim($field->type)); + $length = $field->length; + + if ($length !== null && $length < 1) { + throw new \InvalidArgumentException("Scheme 字段定义非法:{$className}::\${$fieldName} length 必须 >= 1,当前={$length}"); + } + + if ($type === 'char' && $length !== null && $length > 255) { + throw new \InvalidArgumentException("Scheme 字段定义非法:{$className}::\${$fieldName} type=char 的 length 超出范围,允许 1-255,当前={$length}"); + } + } + protected function restoreData(string $connection, $newTable, $oldTable, array $properties) { $fields = []; @@ -431,6 +519,7 @@ class SchemeToDbService $fieldName = $prop->getName(); /** @var Field $field */ $field = $fieldAttrs[0]->newInstance(); + $this->validateSchemeField($field, $ref->getName(), $fieldName); $sig[$fieldName] = [ 'type' => $this->buildMysqlTypeFromSchemeField($field), @@ -614,6 +703,23 @@ class SchemeToDbService return (string)$dbDefault === (string)$schemeDefault; } + protected function stringifyDefault($value): string + { + if (is_null($value)) { + return 'NULL'; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_int($value) || is_float($value)) { + return (string)$value; + } + + return (string)$value; + } + protected function diffTableComment(string $connection, string $tableName, string $schemeComment): ?string { $type = strtolower((string)Config::get('database.connections.' . $connection . '.type', '')); diff --git a/extend/base/common/service/ToolsDbServiceBase.php b/extend/base/common/service/tools/DbServiceBase.php similarity index 99% rename from extend/base/common/service/ToolsDbServiceBase.php rename to extend/base/common/service/tools/DbServiceBase.php index 053b266..582b86c 100644 --- a/extend/base/common/service/ToolsDbServiceBase.php +++ b/extend/base/common/service/tools/DbServiceBase.php @@ -1,6 +1,6 @@ get($token); - } else { - $admin = session('admin'); - } +if (!function_exists('get_session_admin')) { + /** + * 获取当前登录管理员信息(全局函数)。 + * + * 该函数用于无控制器上下文的场景(如 Service/Helper/Command 等)获取登录态数据。 + * 在控制器内建议优先使用 $this->getAdminId() / $this->sessionAdmin 获取已鉴权后的上下文。 + * + * @param string|null $key 取值路径(支持 Arr::get 语法),为空返回整个管理员数据 + * @return mixed + */ + function get_session_admin($key = null) + { + $token = read_header_token(); + if (!empty($token)) { + $admin = Cache::store('login')->get($token); + } else { + $admin = session('admin'); + } - return Arr::get($admin, $key); + return Arr::get($admin, $key); + } } function read_header_token() diff --git a/extend/think/UlthonAdminService.php b/extend/think/UlthonAdminService.php index 8a16c49..793fdaf 100644 --- a/extend/think/UlthonAdminService.php +++ b/extend/think/UlthonAdminService.php @@ -2,13 +2,47 @@ namespace think; +use app\common\command\Curd; +use app\common\command\OssStatic; +use app\common\command\Timer; +use app\common\command\admin\Clear; use app\common\command\admin\MigrateFileData; +use app\common\command\admin\ResetPassword; +use app\common\command\admin\Update; +use app\common\command\admin\UpdateCode; +use app\common\command\admin\Version; +use app\common\command\admin\menu\AdminMenuCreate; +use app\common\command\admin\menu\AdminMenuDelete; +use app\common\command\admin\menu\AdminMenuExport; +use app\common\command\admin\menu\AdminMenuList; +use app\common\command\admin\menu\AdminMenuUpdate; +use app\common\command\admin\permission\AdminPermissionNodes; +use app\common\command\admin\permission\PermissionUser; +use app\common\command\admin\role\AdminRoleCreate; +use app\common\command\admin\role\AdminRoleDelete; +use app\common\command\admin\role\AdminRoleInfo; +use app\common\command\admin\role\AdminRoleList; +use app\common\command\admin\role\AdminRolePermissionAssign; +use app\common\command\admin\role\AdminRolePermissionList; +use app\common\command\admin\role\AdminRolePermissionRevoke; +use app\common\command\admin\user\AdminUserRoleAssign; +use app\common\command\admin\user\AdminUserRoleList; +use app\common\command\admin\user\AdminUserRoleRevoke; +use app\common\command\curd\Migrate; +use app\common\command\scheme\Backup; +use app\common\command\scheme\Make; +use app\common\command\scheme\Sync; +use app\common\command\tools\http\ToolsHttpCall; use app\common\command\tools\db\ToolsDbCount; use app\common\command\tools\db\ToolsDbDesc; use app\common\command\tools\db\ToolsDbExecute; use app\common\command\tools\db\ToolsDbInfo; use app\common\command\tools\db\ToolsDbQuery; use app\common\command\tools\db\ToolsDbTable; +use app\common\command\tools\agent\ToolsAgentPublish; +use app\common\command\tools\log\ToolsLogSearch; +use app\common\command\tools\log\ToolsLogShow; +use app\common\command\tools\log\ToolsLogStats; use app\common\command\Test; use app\common\event\AdminLoginSuccess\LogEvent; use app\common\event\AdminLoginType\DemoEvent; @@ -57,13 +91,47 @@ class UlthonAdminService extends Service // 绑定命令行 $this->commands([ Test::class, + Curd::class, + OssStatic::class, + ResetPassword::class, + Timer::class, + Version::class, + Migrate::class, + Clear::class, + Update::class, + UpdateCode::class, + AdminPermissionNodes::class, + PermissionUser::class, + AdminMenuList::class, + AdminMenuDelete::class, + AdminMenuCreate::class, + AdminMenuUpdate::class, + AdminMenuExport::class, + AdminRoleCreate::class, + AdminRoleDelete::class, + AdminRoleList::class, + AdminRoleInfo::class, + AdminRolePermissionAssign::class, + AdminRolePermissionRevoke::class, + AdminRolePermissionList::class, + AdminUserRoleAssign::class, + AdminUserRoleRevoke::class, + AdminUserRoleList::class, + Make::class, + Sync::class, + Backup::class, + ToolsHttpCall::class, MigrateFileData::class, + ToolsAgentPublish::class, ToolsDbQuery::class, ToolsDbExecute::class, ToolsDbTable::class, ToolsDbInfo::class, ToolsDbDesc::class, ToolsDbCount::class, + ToolsLogShow::class, + ToolsLogSearch::class, + ToolsLogStats::class, ]); // 绑定标识容器 diff --git a/extend/think/log/driver/DebugMysql.php b/extend/think/log/driver/DebugMysql.php index 20b1744..44ac683 100644 --- a/extend/think/log/driver/DebugMysql.php +++ b/extend/think/log/driver/DebugMysql.php @@ -110,32 +110,39 @@ class DebugMysql implements LogHandlerInterface $log_key = uniqid(); } - foreach ($log as $log_level => $log_list) { - foreach ($log_list as $key => $log_item) { - if (!is_string($log_item)) { - $log_item = print_r($log_item, true); - } + foreach ($log as $log_item) { + // 适配 ThinkPHP 8.x 的 LogRecord 对象格式 + // 兼容旧格式:包含 type 和 message 属性的对象 + if (is_object($log_item) && isset($log_item->type) && isset($log_item->message)) { + $log_level = $log_item->type; + $log_content = $log_item->message; + } else { + continue; + } - $log_data = [ - 'level' => $log_level, - 'content' => $log_item, - 'create_time' => $create_time, - 'create_time_title' => $create_time_title, - 'uid' => $log_key, - 'app_name' => $app_name, - 'controller_name' => $controller_name, - 'action_name' => $action_name, - ]; + if (!is_string($log_content)) { + $log_content = print_r($log_content, true); + } - try { - if (!is_null($this->pdo)) { - $this->saveByConnect($log_data); - } else { - $this->saveByFile($log_data); - } - } catch (\Throwable $th) { + $log_data = [ + 'level' => $log_level, + 'content' => $log_content, + 'create_time' => $create_time, + 'create_time_title' => $create_time_title, + 'uid' => $log_key, + 'app_name' => $app_name, + 'controller_name' => $controller_name, + 'action_name' => $action_name, + ]; + + try { + if (!is_null($this->pdo)) { + $this->saveByConnect($log_data); + } else { $this->saveByFile($log_data); } + } catch (\Throwable $th) { + $this->saveByFile($log_data); } } @@ -242,7 +249,7 @@ class DebugMysql implements LogHandlerInterface $dirname = dirname($log_path); if (!is_dir($dirname)) { - mkdir($log_path, 0777, true); + mkdir($dirname, 0777, true); } $first_line = false; diff --git a/source/clients/uniapp/src/api/auth.js b/source/clients/uniapp/src/api/auth.js new file mode 100644 index 0000000..53bf398 --- /dev/null +++ b/source/clients/uniapp/src/api/auth.js @@ -0,0 +1,57 @@ +import { request } from '../utils/request' + +/** + * 登录 + * @param {Object} data - 登录参数 + * @param {string} data.username - 用户名 + * @param {string} data.password - 密码 + * @param {string} [data.captcha] - 验证码 + * @param {number} [data.keep_login] - 是否保持登录 + * @returns {Promise} 登录响应 + */ +export function login(data) { + return request({ + url: '/admin/login/index', + method: 'POST', + data, + }) +} + +/** + * 退出登录 + * @returns {Promise} + */ +export function logout() { + return request({ + url: '/admin/login/out', + method: 'POST', + }) +} + +/** + * 获取当前用户信息 + * @returns {Promise} + */ +export function getProfile() { + return request({ + url: '/admin/index/editAdmin', + method: 'GET', + data: { get_page_data: 1 }, + }) +} + +/** + * 修改密码 + * @param {Object} data - 修改密码参数 + * @param {string} data.old_password - 旧密码 + * @param {string} data.new_password - 新密码 + * @param {string} data.confirm_password - 确认密码 + * @returns {Promise} + */ +export function changePassword(data) { + return request({ + url: '/admin/index/changePassword', + method: 'POST', + data, + }) +} diff --git a/source/clients/uniapp/src/api/permission.js b/source/clients/uniapp/src/api/permission.js new file mode 100644 index 0000000..ac1c82f --- /dev/null +++ b/source/clients/uniapp/src/api/permission.js @@ -0,0 +1,101 @@ +import { request } from '../utils/request' + +/** + * 获取权限列表(树形) + * @param {Object} params - 查询参数 + * @returns {Promise} + */ +export function getPermissionTree(params = {}) { + return request({ + url: '/admin/auth/index', + method: 'GET', + data: params, + }) +} + +/** + * 获取权限节点列表 + * @param {Object} params - 查询参数 + * @returns {Promise} + */ +export function getPermissionList(params = {}) { + return request({ + url: '/admin/auth/index', + method: 'GET', + data: params, + }) +} + +/** + * 获取权限详情 + * @param {number} id - 权限ID + * @returns {Promise} + */ +export function getPermissionDetail(id) { + return request({ + url: '/admin/auth/edit', + method: 'GET', + data: { id }, + }) +} + +/** + * 创建权限 + * @param {Object} data - 权限数据 + * @returns {Promise} + */ +export function createPermission(data) { + return request({ + url: '/admin/auth/edit', + method: 'POST', + data, + }) +} + +/** + * 更新权限 + * @param {Object} data - 权限数据 + * @returns {Promise} + */ +export function updatePermission(data) { + return request({ + url: '/admin/auth/edit', + method: 'POST', + data, + }) +} + +/** + * 删除权限 + * @param {number} id - 权限ID + * @returns {Promise} + */ +export function deletePermission(id) { + return request({ + url: '/admin/auth/delete', + method: 'POST', + data: { id }, + }) +} + +/** + * 更新权限节点(同步后端权限到数据库) + * @returns {Promise} + */ +export function syncPermissions() { + return request({ + url: '/admin/auth/updateAuth', + method: 'POST', + }) +} + +/** + * 获取用户权限列表 + * @returns {Promise} + */ +export function getUserPermissions() { + return request({ + url: '/admin/auth/userAuth', + method: 'GET', + }) +} diff --git a/source/clients/uniapp/src/api/user.js b/source/clients/uniapp/src/api/user.js new file mode 100644 index 0000000..092b10e --- /dev/null +++ b/source/clients/uniapp/src/api/user.js @@ -0,0 +1,130 @@ +import { request } from '../utils/request' + +/** + * 获取用户列表 + * @param {Object} params - 查询参数 + * @param {number} [params.page] - 页码 + * @param {number} [params.limit] - 每页数量 + * @param {string} [params.username] - 用户名 + * @param {string} [params.nickname] - 昵称 + * @param {string} [params.phone] - 手机号 + * @param {number} [params.status] - 状态 + * @returns {Promise} + */ +export function getUserList(params = {}) { + return request({ + url: '/admin/admin/index', + method: 'GET', + data: params, + }) +} + +/** + * 获取用户详情 + * @param {number} id - 用户ID + * @returns {Promise} + */ +export function getUserDetail(id) { + return request({ + url: '/admin/admin/edit', + method: 'GET', + data: { id }, + }) +} + +/** + * 创建用户 + * @param {Object} data - 用户数据 + * @param {string} data.username - 用户名 + * @param {string} data.password - 密码 + * @param {string} [data.nickname] - 昵称 + * @param {string} [data.phone] - 手机号 + * @param {string} [data.email] - 邮箱 + * @param {string} [data.head_img] - 头像 + * @param {string} [data.remark] - 备注 + * @param {number} [data.status] - 状态 + * @returns {Promise} + */ +export function createUser(data) { + return request({ + url: '/admin/admin/edit', + method: 'POST', + data, + }) +} + +/** + * 更新用户 + * @param {Object} data - 用户数据 + * @param {number} data.id - 用户ID + * @param {string} [data.username] - 用户名 + * @param {string} [data.password] - 密码 + * @param {string} [data.nickname] - 昵称 + * @param {string} [data.phone] - 手机号 + * @param {string} [data.email] - 邮箱 + * @param {string} [data.head_img] - 头像 + * @param {string} [data.remark] - 备注 + * @param {number} [data.status] - 状态 + * @returns {Promise} + */ +export function updateUser(data) { + return request({ + url: '/admin/admin/edit', + method: 'POST', + data, + }) +} + +/** + * 删除用户 + * @param {number} id - 用户ID + * @returns {Promise} + */ +export function deleteUser(id) { + return request({ + url: '/admin/admin/delete', + method: 'POST', + data: { id }, + }) +} + +/** + * 批量删除用户 + * @param {Array} ids - 用户ID列表 + * @returns {Promise} + */ +export function batchDeleteUsers(ids) { + return request({ + url: '/admin/admin/delete', + method: 'POST', + data: { ids }, + }) +} + +/** + * 修改用户状态 + * @param {number} id - 用户ID + * @param {number} status - 状态 + * @returns {Promise} + */ +export function updateUserStatus(id, status) { + return request({ + url: '/admin/admin/edit', + method: 'POST', + data: { id, status }, + }) +} + +/** + * 重置用户密码 + * @param {number} id - 用户ID + * @param {string} password - 新密码 + * @returns {Promise} + */ +export function resetUserPassword(id, password) { + return request({ + url: '/admin/admin/resetPassword', + method: 'POST', + data: { id, password }, + }) +} diff --git a/source/clients/uniapp/src/pages.json b/source/clients/uniapp/src/pages.json index db4a75d..aa2e64f 100644 --- a/source/clients/uniapp/src/pages.json +++ b/source/clients/uniapp/src/pages.json @@ -25,6 +25,36 @@ "style": { "navigationBarTitleText": "登录" } + }, + { + "path": "pages/permission/index", + "style": { + "navigationBarTitleText": "权限管理" + } + }, + { + "path": "pages/user/list", + "style": { + "navigationBarTitleText": "用户列表" + } + }, + { + "path": "pages/user/detail", + "style": { + "navigationBarTitleText": "用户详情" + } + }, + { + "path": "pages/user/edit", + "style": { + "navigationBarTitleText": "编辑用户" + } + }, + { + "path": "pages/user/delete", + "style": { + "navigationBarTitleText": "删除用户" + } } ], "globalStyle": { diff --git a/source/clients/uniapp/src/pages/permission/index.vue b/source/clients/uniapp/src/pages/permission/index.vue new file mode 100644 index 0000000..fc1b9f5 --- /dev/null +++ b/source/clients/uniapp/src/pages/permission/index.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/source/clients/uniapp/src/pages/user/delete.vue b/source/clients/uniapp/src/pages/user/delete.vue new file mode 100644 index 0000000..89be5f3 --- /dev/null +++ b/source/clients/uniapp/src/pages/user/delete.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/source/clients/uniapp/src/pages/user/detail.vue b/source/clients/uniapp/src/pages/user/detail.vue new file mode 100644 index 0000000..021639c --- /dev/null +++ b/source/clients/uniapp/src/pages/user/detail.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/source/clients/uniapp/src/pages/user/edit.vue b/source/clients/uniapp/src/pages/user/edit.vue new file mode 100644 index 0000000..7437d6f --- /dev/null +++ b/source/clients/uniapp/src/pages/user/edit.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/source/clients/uniapp/src/pages/user/list.vue b/source/clients/uniapp/src/pages/user/list.vue new file mode 100644 index 0000000..10476f2 --- /dev/null +++ b/source/clients/uniapp/src/pages/user/list.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/source/clients/uniapp/src/types/index.ts b/source/clients/uniapp/src/types/index.ts new file mode 100644 index 0000000..9a7a564 --- /dev/null +++ b/source/clients/uniapp/src/types/index.ts @@ -0,0 +1,131 @@ +/** + * API 响应基础类型 + */ +export interface ApiResponse { + code: number + msg: string + data: T +} + +/** + * 分页响应类型 + */ +export interface PageResponse { + total: number + page: number + limit: number + list: T[] +} + +/** + * 分页请求参数类型 + */ +export interface PageParams { + page?: number + limit?: number + [key: string]: any +} + +/** + * 用户信息类型 + */ +export interface User { + id: number + username: string + nickname?: string + phone?: string + email?: string + head_img?: string + remark?: string + status?: number + create_time?: string + update_time?: string +} + +/** + * 登录请求参数类型 + */ +export interface LoginParams { + username: string + password: string + captcha?: string + keep_login?: number +} + +/** + * 登录响应类型 + */ +export interface LoginResponse { + token: string + admin?: User +} + +/** + * 权限节点类型 + */ +export interface Permission { + id: number + pid: number + name: string + title: string + type: string + status: number + sort: number + create_time?: string + update_time?: string +} + +/** + * 菜单树节点类型 + */ +export interface MenuNode extends Permission { + children?: MenuNode[] +} + +/** + * 用户列表查询参数类型 + */ +export interface UserListParams extends PageParams { + username?: string + nickname?: string + phone?: string + status?: number +} + +/** + * 用户表单数据类型 + */ +export interface UserFormData { + id?: number + username: string + password?: string + nickname?: string + phone?: string + email?: string + head_img?: string + remark?: string + status?: number +} + +/** + * 权限列表查询参数类型 + */ +export interface PermissionListParams extends PageParams { + name?: string + title?: string + type?: string + status?: number +} + +/** + * 权限表单数据类型 + */ +export interface PermissionFormData { + id?: number + pid: number + name: string + title: string + type: string + status?: number + sort?: number +} diff --git a/source/clients/uniapp/tsconfig.json b/source/clients/uniapp/tsconfig.json new file mode 100644 index 0000000..8402f19 --- /dev/null +++ b/source/clients/uniapp/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": ["@dcloudio/types"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["dist", "node_modules"] +} diff --git a/source/clients/uniapp/tsconfig.node.json b/source/clients/uniapp/tsconfig.node.json new file mode 100644 index 0000000..8762794 --- /dev/null +++ b/source/clients/uniapp/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.js"] +}