Browse Source

feat(treeTable): 新增左树右表相关组件及功能实现

PIGCLOUD 3 weeks ago
parent
commit
97baf96247

+ 47 - 0
README.md

@@ -101,3 +101,50 @@ public class ${ClassName}Entity extends Model<${ClassName}Entity> {
 | ------------- | ------------------ |
 | ------------- | ------------------ |
 | isSpringBoot3 | 是否是 springboot3 |
 | isSpringBoot3 | 是否是 springboot3 |
 | isTenant      | 是否支持多租户     |
 | isTenant      | 是否支持多租户     |
+
+## 左树右表模板约定
+
+新增模板组:`左树右表增删改查`
+
+该模板组用于生成“左侧树节点 + 右侧主表”的标准 CRUD 页面,关系方向为:
+
+- 左侧树使用子表元信息
+- 右侧列表和表单使用主表元信息
+- 左树内部层级关系使用 `parentField`
+- 主表通过 `childField` 指向左树节点主键
+
+### 变量来源
+
+右侧主表继续使用现有主表变量:
+
+- `fieldList`
+- `formList`
+- `gridList`
+- `queryList`
+- `pk`
+- `ClassName`
+- `className`
+
+左侧树继续复用主子表变量:
+
+- `childFieldList`
+- `childTableName`
+- `ChildClassName`
+- `childClassName`
+
+### 新增约定
+
+左树子表还需要额外提供两个字段约定:
+
+- `parentField`:树节点父级字段
+- `nameField`:树节点显示名称字段
+
+主表与左树关联继续复用现有变量:
+
+- `childField`:主表中关联左树节点的外键字段
+
+### 页面行为
+
+- 页面初始化时,左树加载全部节点,右表默认展示全部数据
+- 选中左树节点后,右表按 `childField` 过滤
+- 删除左树节点时,只允许删除没有子节点且没有关联主表数据的空节点

+ 67 - 0
config.json

@@ -164,5 +164,72 @@
       "generatorPath": "${frontendPath}/src/views/${moduleName}/${functionName}/form.vue",
       "generatorPath": "${frontendPath}/src/views/${moduleName}/${functionName}/form.vue",
       "templateFile": "tree/树形表单.vue"
       "templateFile": "tree/树形表单.vue"
     }
     }
+  ],
+  "左树右表增删改查": [
+    {
+      "templateName": "Controller",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/controller/${ClassName}Controller.java",
+      "templateFile": "treeTable/左树右表Controller.java"
+    },
+    {
+      "templateName": "Service",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/service/${ClassName}Service.java",
+      "templateFile": "treeTable/左树右表Service.java"
+    },
+    {
+      "templateName": "ServiceImpl",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/service/impl/${ClassName}ServiceImpl.java",
+      "templateFile": "treeTable/左树右表ServiceImpl.java"
+    },
+    {
+      "templateName": "实体",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/entity/${ClassName}Entity.java",
+      "templateFile": "single/实体.java"
+    },
+    {
+      "templateName": "Mapper",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/mapper/${ClassName}Mapper.java",
+      "templateFile": "single/Mapper.java"
+    },
+    {
+      "templateName": "Mapper.xml",
+      "generatorPath": "${backendPath}/src/main/resources/mapper/${ClassName}Mapper.xml",
+      "templateFile": "single/Mapper.xml"
+    },
+    {
+      "templateName": "子实体",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/entity/${ChildClassName}Entity.java",
+      "templateFile": "multiple/子实体.java"
+    },
+    {
+      "templateName": "子Mapper",
+      "generatorPath": "${backendPath}/src/main/java/${packagePath}/${moduleName}/mapper/${ChildClassName}Mapper.java",
+      "templateFile": "multiple/子Mapper.java"
+    },
+    {
+      "templateName": "权限菜单",
+      "generatorPath": "${backendPath}/menu/${functionName}_menu.sql",
+      "templateFile": "common/权限菜单.sql"
+    },
+    {
+      "templateName": "api.ts",
+      "generatorPath": "${frontendPath}/src/api/${moduleName}/${functionName}.ts",
+      "templateFile": "treeTable/左树右表api.ts"
+    },
+    {
+      "templateName": "表格",
+      "generatorPath": "${frontendPath}/src/views/${moduleName}/${functionName}/index.vue",
+      "templateFile": "treeTable/左树右表表格.vue"
+    },
+    {
+      "templateName": "树表单",
+      "generatorPath": "${frontendPath}/src/views/${moduleName}/${functionName}/tree-form.vue",
+      "templateFile": "treeTable/左树右表树表单.vue"
+    },
+    {
+      "templateName": "主表单",
+      "generatorPath": "${frontendPath}/src/views/${moduleName}/${functionName}/form.vue",
+      "templateFile": "treeTable/左树右表主表单.vue"
+    }
   ]
   ]
 }
 }

+ 0 - 479
docs/superpowers/specs/2026-04-08-left-tree-right-table-design.md

@@ -1,479 +0,0 @@
-# 左树右表代码生成模板设计
-
-- 日期:2026-04-08
-- 状态:已确认,待进入 implementation planning
-- 适用仓库:`/Users/lengleng/Downloads/CGTM`
-
-## 1. 背景
-
-当前仓库已提供三组模板:
-
-- 单表增删改查
-- 主子表增删改查
-- 树形表格增删改查
-
-本次需要新增第四组模板,用于生成“左树右表”业务页面。页面形态为:
-
-- 左侧:树形结构,支持完整增删改
-- 右侧:主表列表和表单,支持完整增删改
-- 左右联动:右表数据按左树节点过滤
-
-该场景与现有主子表最接近,但关系方向不同。这里不是“主表聚合子表列表”,而是“主表记录持有树节点外键”。
-
-## 2. 目标
-
-新增一组独立模板 `左树右表增删改查`,满足以下目标:
-
-1. 生成整套前后端代码,而不是仅生成页面。
-2. 左树和右表在同一业务模块下,权限、菜单、路径风格与现有模板保持一致。
-3. 左树来源于子表元信息,右表来源于主表元信息。
-4. 页面首次进入时右表默认展示全部数据。
-5. 选中左树节点后,右表按节点过滤。
-6. 删除左树节点时,仅允许删除“没有子节点且没有关联主表数据”的空节点。
-
-## 3. 非目标
-
-本次设计不包含以下内容:
-
-- 不新增第二套独立的树 Controller/Service/ServiceImpl 产物
-- 不沿用主子表 `saveDeep/updateDeep` 聚合模型
-- 不扩展新的复杂生成器上下文字段体系
-- 不处理多级树批量拖拽、排序、懒加载等额外能力
-
-## 4. 采用方案
-
-采用“新增独立模板组,但仍使用同一业务模块命名空间”的方案。
-
-核心思路:
-
-- 新增第四组模板 `左树右表增删改查`
-- 主控制器统一挂载在 `/${functionName}` 下
-- 主表接口和树节点接口通过不同子路由区分
-- 左树使用主子表上下文中的子表元信息,再补充树专用字段约定
-- 右表使用现有单表/主表模板的字段能力
-
-不采用“完全拆成两套资源”的原因:
-
-- 会显著偏离当前 `config.json` 和模板目录的组织方式
-- 会增加路径、权限、菜单和模板变量的额外复杂度
-
-不采用“直接改造主子表模板”的原因:
-
-- 当前主子表模板的语义是主表聚合子表
-- 本需求的真实关系是主表引用树节点
-- 强行复用 `saveDeep/updateDeep` 会让前后端职责边界变得混乱
-
-## 5. 数据关系与元信息约定
-
-### 5.1 关系定义
-
-关系采用以下形式:
-
-- 左树对应子表
-- 右表对应主表
-- 主表中保存树节点外键
-
-即:
-
-- 一个树节点可以关联多条主表记录
-- 一条主表记录只属于一个树节点
-
-### 5.2 模板变量来源
-
-右侧主表继续使用现有主表变量:
-
-- `fieldList`
-- `formList`
-- `gridList`
-- `queryList`
-- `pk`
-- `ClassName`
-- `className`
-
-左侧树继续复用现有主子表变量:
-
-- `childFieldList`
-- `childTableName`
-- `ChildClassName`
-- `childClassName`
-
-树专用额外约定:
-
-- `parentField`:左树子表中的父节点字段
-- `nameField`:左树子表中的树节点名称字段
-
-### 5.3 跨表关联约定
-
-这一组模板中,跨表关联采用以下固定规则:
-
-- `mainField` 表示主表中的树节点外键字段
-- 主表通过 `mainField` 关联到左树子表主键
-- 左树子表主键从 `childFieldList` 中 `primaryPk = true` 的字段推导
-
-说明:
-
-- 当前 README 中的 `childField` 定义来自主子表聚合场景
-- 在本模板组里,不再以 `childField` 作为跨表删除或聚合写入的核心字段
-- 实现阶段如需保留 `childField` 兼容生成器配置,可保留变量,但生成逻辑以“主表外键 -> 子表主键”为准
-
-该约定必须在实现中明确,否则实现者可能误把当前场景继续按“子表保存主表外键”处理。
-
-### 5.4 关键元信息来源表
-
-| 字段 | 来源 | 是否新增输入 | 本模板组中的含义 |
-| --- | --- | --- | --- |
-| `mainField` | 复用现有主子表配置 | 否 | 主表中的树节点外键字段 |
-| `childFieldList` | 复用现有主子表配置 | 否 | 左树子表字段列表 |
-| `ChildClassName` / `childClassName` | 复用现有主子表配置 | 否 | 左树子表实体命名 |
-| `childTableName` | 复用现有主子表配置 | 否 | 左树子表表名 |
-| `parentField` | 新增模板配置 | 是 | 左树子表父节点字段 |
-| `nameField` | 新增模板配置 | 是 | 左树节点显示名称字段 |
-| 左树子表主键 | 从 `childFieldList` 推导 | 否 | 通过 `primaryPk = true` 的字段识别 |
-
-结论:
-
-- 本模板组需要在生成器配置层新增两个最小输入:`parentField`、`nameField`
-- `mainField` 沿用现有主子表配置,但语义固定为“主表引用树节点的外键”
-- 不要求新增更复杂的上下文字段体系
-
-## 6. 模板分组与生成产物
-
-新增模板组名称:
-
-- `左树右表增删改查`
-
-建议生成产物如下。
-
-### 6.1 后端主表产物
-
-- `Controller`
-- `Service`
-- `ServiceImpl`
-- `实体`
-- `Mapper`
-- `Mapper.xml`
-
-### 6.2 后端树表产物
-
-- `子实体`
-- `子Mapper`
-
-### 6.3 前端产物
-
-- `api.ts`
-- `index.vue`
-- `tree-form.vue`
-- `form.vue`
-
-### 6.4 通用产物
-
-- `权限菜单.sql`
-
-### 6.5 文件职责
-
-- `index.vue`:左右布局、树节点选中状态、右表查询、双弹窗联动
-- `tree-form.vue`:左树节点新增/编辑
-- `form.vue`:右表主记录新增/编辑
-- `api.ts`:同时暴露主表和树接口
-
-不建议将左右两种编辑能力继续合并到一个表单文件中,否则页面职责会过于拥挤。
-
-### 6.6 `config.json` 注册要求
-
-该仓库是 registry-driven,新增模板组后必须同步更新 `config.json`,否则模板无法出现在代码生成器里。
-
-建议新增一组根配置:
-
-- 分组名:`左树右表增删改查`
-
-建议的文件映射如下:
-
-| templateName | generatorPath | templateFile |
-| --- | --- | --- |
-| `Controller` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/controller/${ClassName}Controller.java` | `treeTable/Controller.java` |
-| `Service` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/service/${ClassName}Service.java` | `treeTable/Service.java` |
-| `ServiceImpl` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/service/impl/${ClassName}ServiceImpl.java` | `treeTable/ServiceImpl.java` |
-| `实体` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/entity/${ClassName}Entity.java` | `single/实体.java` |
-| `Mapper` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/mapper/${ClassName}Mapper.java` | `single/Mapper.java` |
-| `Mapper.xml` | `${backendPath}/src/main/resources/mapper/${ClassName}Mapper.xml` | `single/Mapper.xml` |
-| `子实体` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/entity/${ChildClassName}Entity.java` | `multiple/子实体.java` |
-| `子Mapper` | `${backendPath}/src/main/java/${packagePath}/${moduleName}/mapper/${ChildClassName}Mapper.java` | `multiple/子Mapper.java` |
-| `权限菜单` | `${backendPath}/menu/${functionName}_menu.sql` | `common/权限菜单.sql` |
-| `api.ts` | `${frontendPath}/src/api/${moduleName}/${functionName}.ts` | `treeTable/api.ts` |
-| `表格` | `${frontendPath}/src/views/${moduleName}/${functionName}/index.vue` | `treeTable/index.vue` |
-| `树表单` | `${frontendPath}/src/views/${moduleName}/${functionName}/tree-form.vue` | `treeTable/tree-form.vue` |
-| `主表单` | `${frontendPath}/src/views/${moduleName}/${functionName}/form.vue` | `treeTable/form.vue` |
-
-说明:
-
-- 新增模板目录建议命名为 `treeTable/`
-- 主表实体、Mapper、Mapper.xml 可以直接复用单表模板
-- 子实体、子 Mapper 可以直接复用主子表模板
-- 需要新建的主要是 `Controller / Service / ServiceImpl / api.ts / index.vue / tree-form.vue / form.vue`
-
-## 7. 后端接口设计
-
-### 7.1 主表接口
-
-主表接口沿用现有单表风格:
-
-- `GET /${functionName}/page`
-- `GET /${functionName}/details`
-- `POST /${functionName}`
-- `PUT /${functionName}`
-- `DELETE /${functionName}`
-- `GET /${functionName}/export`
-- `POST /${functionName}/import`
-
-### 7.2 左树接口
-
-在同一控制器下新增树节点子路由:
-
-- `GET /${functionName}/tree`
-- `GET /${functionName}/tree/details`
-- `POST /${functionName}/tree`
-- `PUT /${functionName}/tree`
-- `DELETE /${functionName}/tree`
-
-### 7.3 设计原则
-
-- 同一业务模块共用权限前缀和菜单命名风格
-- 通过 `/tree` 子路由区分树节点操作和主表操作
-- 避免再拆第二个 Controller,保持与现有模板目录结构一致
-
-## 8. Service 职责设计
-
-`Service` 和 `ServiceImpl` 不采用主子表 `saveDeep/updateDeep/removeDeep` 风格,而是明确承担三类职责:
-
-1. 主表分页和 CRUD
-2. 左树节点 CRUD 和树结构构建
-3. 删除前关联校验
-
-`ServiceImpl` 需要注入:
-
-- 主表 `Mapper`
-- 左树子表 `ChildMapper`
-
-建议至少存在以下方法类别:
-
-- 主表分页查询辅助方法
-- 树结构构建方法
-- 树节点详情查询方法
-- 树节点新增/修改方法
-- 树节点删除校验与删除方法
-
-这里的重点不是方法名,而是职责边界要明确,避免把左树和右表重新做成一个聚合保存模型。
-
-## 9. 页面交互与数据流
-
-### 9.1 初始状态
-
-页面初始化时:
-
-1. 加载左树数据
-2. 加载右表分页数据
-3. 右表默认显示全部数据
-
-### 9.2 树节点联动
-
-当用户选中左树节点后:
-
-1. 记录当前选中节点 ID
-2. 将该节点 ID 写入右表查询条件中的 `mainField`
-3. 重新加载右表分页数据
-
-当用户取消选中或删除了当前选中节点后:
-
-1. 清空当前树节点选中状态
-2. 移除右表查询中的 `mainField`
-3. 右表恢复“全部数据”视图
-
-### 9.3 左树 CRUD
-
-左树支持:
-
-- 新增根节点
-- 新增子节点
-- 编辑节点
-- 删除空节点
-
-`tree-form.vue` 的字段来源为 `childFieldList`,但需要:
-
-- 排除树表主键字段
-- 排除主表外键聚合概念中的无关字段
-- 保留 `parentField` 作为父级节点选择器
-- 使用 `nameField` 作为默认树节点显示名称来源
-- 根节点父级值统一使用 `0`,与现有树模板的根节点构建方式保持一致
-- 编辑节点时禁止选择自己作为父节点
-- 编辑节点时禁止选择自己的任意后代节点作为父节点,避免形成环
-- 父级节点选择列表需要排除当前节点及其后代
-
-### 9.4 右表 CRUD
-
-右表支持:
-
-- 分页查询
-- 条件筛选
-- 新增
-- 编辑
-- 删除
-
-`form.vue` 的字段来源为主表 `formList`。
-
-对于主表中的树节点外键字段 `mainField`:
-
-- 如果当前已选树节点,则新增时默认写入当前树节点 ID
-- 如果当前未选树节点,则用户必须手动选择树节点
-
-该字段在前端表现可以是:
-
-- 已选树节点时默认值 + 只读/禁改
-- 未选树节点时树选择器
-
-实现阶段二选一即可,但必须保证不会生成缺少树节点归属的主表记录。
-
-## 10. 删除规则与异常处理
-
-### 10.1 左树删除规则
-
-删除左树节点时,必须同时校验:
-
-1. 是否仍存在子节点:`parentField = 当前节点 ID`
-2. 是否仍存在主表数据关联:`mainField = 当前节点 ID`
-
-仅当两者都不存在时才允许删除。
-
-### 10.2 用户选择的删除语义
-
-用户确认采用:
-
-- 只允许删除空节点
-- 只要存在子树或主表数据都不允许删除
-
-### 10.3 前后端处理方式
-
-后端返回明确业务提示,例如:
-
-- 当前节点存在子节点,不能删除
-- 当前节点已关联主表数据,不能删除
-
-前端保持与现有模板一致的错误提示方式:
-
-- 列表加载失败提示
-- 详情获取失败提示
-- 新增/修改失败提示
-- 删除失败提示
-
-不在本次模板设计中扩展统一异常体系。
-
-## 11. 前端文件职责细化
-
-### 11.1 `index.vue`
-
-负责:
-
-- 左右布局
-- 树节点数据加载
-- 当前节点选中状态
-- 右表分页查询
-- 工具栏和操作按钮
-- 双弹窗打开关闭
-- 删除成功后的联动刷新
-
-### 11.2 `tree-form.vue`
-
-负责:
-
-- 新增根节点
-- 新增子节点
-- 编辑树节点
-- 父级节点选择
-
-参考方向接近当前 `tree/树形表单.vue`,但实体来源切换为左树子表。
-
-### 11.3 `form.vue`
-
-负责:
-
-- 主表详情回显
-- 主表新增/编辑
-- 树节点外键写入和校验
-
-### 11.4 `api.ts`
-
-应同时暴露两组接口:
-
-- 主表:`fetchList/getObj/addObj/putObj/delObjs`
-- 左树:`fetchTreeList/getTreeObj/addTreeObj/putTreeObj/delTreeObjs`
-
-## 12. 验证范围
-
-实现完成后,至少应验证以下行为。
-
-### 12.1 初始化
-
-- 左树可正常加载
-- 右表默认展示全部数据
-
-### 12.2 联动
-
-- 选中树节点后右表按节点过滤
-- 清空节点选中后右表恢复全部数据
-
-### 12.3 左树 CRUD
-
-- 新增根节点成功
-- 新增子节点成功
-- 编辑树节点成功
-- 删除空节点成功
-- 有子节点时删除失败
-- 有主表关联数据时删除失败
-
-### 12.4 右表 CRUD
-
-- 已选树节点下新增主表记录时自动带入树节点 ID
-- 未选树节点时新增主表记录必须手动选择树节点
-- 编辑主表记录可正常回显和保存
-- 删除主表记录后右表刷新正常
-
-### 12.5 模板变量验证
-
-- 新模板只依赖现有主子表变量和 `parentField/nameField`
-- 不再引入新的复杂生成器上下文字段
-
-## 13. 风险与约束
-
-### 13.1 最大风险
-
-当前仓库的主子表语义是“子表持有主表外键”,而本次场景是“主表持有树节点外键”。
-
-如果实现阶段没有显式区分,会导致:
-
-- 错误复用 `saveDeep/updateDeep`
-- 错误使用 `childField`
-- 错误生成删除逻辑
-
-因此 implementation planning 阶段必须首先确认:
-
-- 哪些现有主子表模板能直接复用
-- 哪些变量只保留名称,不保留原语义
-
-### 13.2 范围控制
-
-本次设计只覆盖“标准左树右表 CRUD 模板”,不额外承诺:
-
-- 树节点拖拽排序
-- 树懒加载
-- 跨节点批量移动主表数据
-- 主表明细内嵌子表编辑
-
-## 14. 交付结论
-
-本设计确认新增一组独立模板 `左树右表增删改查`,其实现原则为:
-
-- 结构上参考现有主子表与树形模板
-- 语义上明确采用“主表外键指向树节点”的关系
-- 页面上生成“左树 + 右表 + 双弹窗”的标准 CRUD 布局
-- 删除规则采用“仅可删除空节点”
-
-该设计已经满足进入 implementation planning 的条件。

+ 300 - 0
treeTable/左树右表Controller.java

@@ -0,0 +1,300 @@
+package ${package}.${moduleName}.controller;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.collection.CollUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import ${package}.common.core.util.R;
+import ${package}.common.log.annotation.SysLog;
+#if($opensource)
+import com.pig4cloud.plugin.excel.annotation.ResponseExcel;
+import com.pig4cloud.plugin.excel.annotation.RequestExcel;
+#else
+import ${package}.common.excel.annotation.ResponseExcel;
+import ${package}.common.excel.annotation.RequestExcel;
+#end
+import ${package}.${moduleName}.entity.${ChildClassName}Entity;
+import ${package}.${moduleName}.entity.${ClassName}Entity;
+import ${package}.${moduleName}.service.${ClassName}Service;
+
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+#if($isSpringBoot3)
+import ${package}.common.security.annotation.HasPermission;
+import org.springdoc.core.annotations.ParameterObject;
+#else
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springdoc.api.annotations.ParameterObject;
+#end
+import org.springframework.http.HttpHeaders;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * ${tableComment}
+ *
+ * @author ${author}
+ * @date ${datetime}
+ */
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#break
+#end
+#end
+#set($childFieldGetter = $str.getProperty($childField))
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/${functionName}" )
+@Tag(description = "${functionName}" , name = "${tableComment}管理" )
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class ${ClassName}Controller {
+
+    private final  ${ClassName}Service ${className}Service;
+
+    /**
+     * 分页查询
+     * @param page 分页对象
+     * @param ${className} ${tableComment}
+     * @return
+     */
+    @Operation(summary = "分页查询" , description = "分页查询" )
+    @GetMapping("/page" )
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view')" )
+    #end
+    public R get${ClassName}Page(@ParameterObject Page page, @ParameterObject ${ClassName}Entity ${className}) {
+        LambdaQueryWrapper<${ClassName}Entity> wrapper = Wrappers.lambdaQuery();
+#foreach ($field in $queryList)
+#set($getAttrName=$str.getProperty($field.attrName))
+#set($var="${className}.$getAttrName()")
+#if($field.attrType == 'String')
+#set($expression="StrUtil.isNotBlank")
+#else
+#set($expression="Objects.nonNull")
+#end
+#if($field.queryFormType == 'daterange' || $field.queryFormType == 'datetimerange')
+		if (ArrayUtil.isNotEmpty(${className}.${getAttrName}Range())) {
+            wrapper.between(${ClassName}Entity::$getAttrName, ${className}.${getAttrName}Range()[0], ${className}.${getAttrName}Range()[1]);
+        }
+#elseif($field.queryType == '=')
+		wrapper.eq($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == 'like' )
+		wrapper.like($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == '!-' )
+		wrapper.ne($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == '>' )
+		wrapper.gt($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == '<' )
+		wrapper.lt($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == '>=' )
+		wrapper.ge($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == '<=' )
+		wrapper.le($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == 'left like' )
+		wrapper.likeLeft($expression($var),${ClassName}Entity::$getAttrName,$var);
+#elseif( $field.queryType == 'right like' )
+		wrapper.likeRight($expression($var),${ClassName}Entity::$getAttrName,$var);
+#end
+#end
+#if($childField)
+        if (Objects.nonNull(${className}.$childFieldGetter())) {
+            wrapper.eq(${ClassName}Entity::$str.getProperty($childField), ${className}.$childFieldGetter());
+        }
+#end
+        return R.ok(${className}Service.page(page, wrapper));
+    }
+
+
+    /**
+     * 通过条件查询${tableComment}
+     * @param ${className} 查询条件
+     * @return R  对象列表
+     */
+    @Operation(summary = "通过条件查询" , description = "通过条件查询对象" )
+    @GetMapping("/details" )
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view')" )
+    #end
+    public R getDetails(@ParameterObject ${ClassName}Entity ${className}) {
+        return R.ok(${className}Service.list(Wrappers.query(${className})));
+    }
+
+    /**
+     * 新增${tableComment}
+     * @param ${className} ${tableComment}
+     * @return R
+     */
+    @Operation(summary = "新增${tableComment}" , description = "新增${tableComment}" )
+    @SysLog("新增${tableComment}" )
+    @PostMapping
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add')" )
+    #end
+    public R save(@RequestBody ${ClassName}Entity ${className}) {
+        return R.ok(${className}Service.save(${className}));
+    }
+
+    /**
+     * 修改${tableComment}
+     * @param ${className} ${tableComment}
+     * @return R
+     */
+    @Operation(summary = "修改${tableComment}" , description = "修改${tableComment}" )
+    @SysLog("修改${tableComment}" )
+    @PutMapping
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_edit")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_edit')" )
+    #end
+    public R updateById(@RequestBody ${ClassName}Entity ${className}) {
+        return R.ok(${className}Service.updateById(${className}));
+    }
+
+    /**
+     * 通过id删除${tableComment}
+     * @param ids ${pk.attrName}列表
+     * @return R
+     */
+    @Operation(summary = "通过id删除${tableComment}" , description = "通过id删除${tableComment}" )
+    @SysLog("通过id删除${tableComment}" )
+    @DeleteMapping
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del')" )
+    #end
+    public R removeById(@RequestBody ${pk.attrType}[] ids) {
+        return R.ok(${className}Service.removeBatchByIds(CollUtil.toList(ids)));
+    }
+
+    /**
+     * 导出excel 表格
+     * @param ${className} 查询条件
+   	 * @param ids 导出指定ID
+     * @return excel 文件流
+     */
+    @ResponseExcel
+    @GetMapping("/export")
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_export")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_export')" )
+    #end
+    public List<${ClassName}Entity> exportExcel(${ClassName}Entity ${className},${pk.attrType}[] ids) {
+        return ${className}Service.list(Wrappers.lambdaQuery(${className}).in(ArrayUtil.isNotEmpty(ids), ${ClassName}Entity::$str.getProperty($pk.attrName), ids));
+    }
+
+    /**
+     * 导入excel 表
+     * @param ${className}List 对象实体列表
+     * @param bindingResult 错误信息列表
+     * @return ok fail
+     */
+    @PostMapping("/import")
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_export")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_export')" )
+    #end
+    public R importExcel(@RequestExcel List<${ClassName}Entity> ${className}List, BindingResult bindingResult) {
+        return R.ok(${className}Service.saveBatch(${className}List));
+    }
+
+    /**
+     * 获取树形列表
+     * @param ${childClassName} 树节点查询条件
+     * @return 树形数据
+     */
+    @Operation(summary = "获取树形列表" , description = "获取树形列表" )
+    @GetMapping("/tree" )
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view')" )
+    #end
+    public R getTree(@ParameterObject ${ChildClassName}Entity ${childClassName}) {
+        return R.ok(${className}Service.buildTree(Wrappers.lambdaQuery(${childClassName})));
+    }
+
+    /**
+     * 通过条件查询树节点
+     * @param ${childClassName} 树节点查询条件
+     * @return 树节点列表
+     */
+    @Operation(summary = "通过条件查询树节点" , description = "通过条件查询树节点" )
+    @GetMapping("/tree/details" )
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_view')" )
+    #end
+    public R getTreeDetails(@ParameterObject ${ChildClassName}Entity ${childClassName}) {
+        return R.ok(${className}Service.getTreeDetails(${childClassName}));
+    }
+
+    /**
+     * 新增树节点
+     * @param ${childClassName} 树节点
+     * @return R
+     */
+    @Operation(summary = "新增树节点" , description = "新增树节点" )
+    @SysLog("新增树节点" )
+    @PostMapping("/tree")
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add')" )
+    #end
+    public R saveTree(@RequestBody ${ChildClassName}Entity ${childClassName}) {
+        return R.ok(${className}Service.saveTree(${childClassName}));
+    }
+
+    /**
+     * 修改树节点
+     * @param ${childClassName} 树节点
+     * @return R
+     */
+    @Operation(summary = "修改树节点" , description = "修改树节点" )
+    @SysLog("修改树节点" )
+    @PutMapping("/tree")
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_edit")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_edit')" )
+    #end
+    public R updateTree(@RequestBody ${ChildClassName}Entity ${childClassName}) {
+        return R.ok(${className}Service.updateTree(${childClassName}));
+    }
+
+    /**
+     * 通过id删除树节点
+     * @param ids 树节点ID列表
+     * @return R
+     */
+    @Operation(summary = "通过id删除树节点" , description = "通过id删除树节点" )
+    @SysLog("通过id删除树节点" )
+    @DeleteMapping("/tree")
+    #if($isSpringBoot3)
+    @HasPermission("$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del")
+    #else
+    @PreAuthorize("@pms.hasPermission('$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del')" )
+    #end
+    public R removeTree(@RequestBody ${childPkField.attrType}[] ids) {
+        return R.ok(${className}Service.removeTreeByIds(ids));
+    }
+}

+ 59 - 0
treeTable/左树右表Service.java

@@ -0,0 +1,59 @@
+package ${package}.${moduleName}.service;
+
+import cn.hutool.core.lang.tree.Tree;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.IService;
+import ${package}.${moduleName}.entity.${ChildClassName}Entity;
+import ${package}.${moduleName}.entity.${ClassName}Entity;
+
+import java.util.List;
+
+/**
+ * ${tableComment} Service接口
+ *
+ * @author ${author}
+ * @date ${datetime}
+ */
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#break
+#end
+#end
+public interface ${ClassName}Service extends IService<${ClassName}Entity> {
+
+    /**
+     * 构建树形结构数据
+     * @param wrapper 查询条件
+     * @return 树形结构数据
+     */
+    List<Tree<${childPkField.attrType}>> buildTree(LambdaQueryWrapper<${ChildClassName}Entity> wrapper);
+
+    /**
+     * 获取树节点详情
+     * @param ${childClassName} 树节点查询对象
+     * @return 树节点详情列表
+     */
+    List<${ChildClassName}Entity> getTreeDetails(${ChildClassName}Entity ${childClassName});
+
+    /**
+     * 保存树节点
+     * @param ${childClassName} 树节点对象
+     * @return 保存结果
+     */
+    Boolean saveTree(${ChildClassName}Entity ${childClassName});
+
+    /**
+     * 更新树节点
+     * @param ${childClassName} 树节点对象
+     * @return 更新结果
+     */
+    Boolean updateTree(${ChildClassName}Entity ${childClassName});
+
+    /**
+     * 批量删除树节点
+     * @param ids 树节点ID列表
+     * @return 删除结果
+     */
+    Boolean removeTreeByIds(${childPkField.attrType}[] ids);
+}

+ 231 - 0
treeTable/左树右表ServiceImpl.java

@@ -0,0 +1,231 @@
+package ${package}.${moduleName}.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.lang.tree.Tree;
+import cn.hutool.core.lang.tree.TreeNode;
+import cn.hutool.core.lang.tree.TreeUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import ${package}.${moduleName}.entity.${ChildClassName}Entity;
+import ${package}.${moduleName}.entity.${ClassName}Entity;
+import ${package}.${moduleName}.mapper.${ChildClassName}Mapper;
+import ${package}.${moduleName}.mapper.${ClassName}Mapper;
+import ${package}.${moduleName}.service.${ClassName}Service;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * ${tableComment} Service实现类
+ *
+ * @author ${author}
+ * @date ${datetime}
+ */
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#set($childPkGetter = $str.getProperty($childPkField.attrName))
+#break
+#end
+#end
+#set($parentGetter = $str.getProperty($parentField))
+#set($childFieldGetter = $str.getProperty($childField))
+#if($childPkField.attrType == 'Long')
+#set($treeRootId = '0L')
+#elseif($childPkField.attrType == 'String')
+#set($treeRootId = '"0"')
+#else
+#set($treeRootId = '0')
+#end
+@Service
+@RequiredArgsConstructor
+public class ${ClassName}ServiceImpl extends ServiceImpl<${ClassName}Mapper, ${ClassName}Entity> implements ${ClassName}Service {
+
+    private final ${ChildClassName}Mapper ${childClassName}Mapper;
+
+    /**
+     * 构建树形结构数据
+     * @param wrapper 查询条件
+     * @return 树形结构数据
+     */
+    @Override
+    public List<Tree<${childPkField.attrType}>> buildTree(LambdaQueryWrapper<${ChildClassName}Entity> wrapper) {
+        List<${ChildClassName}Entity> allList = ${childClassName}Mapper.selectList(wrapper);
+
+        if (CollUtil.isEmpty(allList)) {
+            return new ArrayList<>();
+        }
+
+        List<TreeNode<${childPkField.attrType}>> collect = allList.stream().map(getNodeFunction()).toList();
+        return TreeUtil.build(collect, ${treeRootId});
+    }
+
+    /**
+     * 获取树节点详情
+     * @param ${childClassName} 树节点查询对象
+     * @return 树节点详情列表
+     */
+    @Override
+    public List<${ChildClassName}Entity> getTreeDetails(${ChildClassName}Entity ${childClassName}) {
+        return ${childClassName}Mapper.selectList(Wrappers.query(${childClassName}));
+    }
+
+    /**
+     * 保存树节点
+     * @param ${childClassName} 树节点对象
+     * @return 保存结果
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean saveTree(${ChildClassName}Entity ${childClassName}) {
+        ${childClassName}.$str.setProperty($parentField)(validateParentRelation(null, ${childClassName}.$str.getProperty($parentField)()));
+        return ${childClassName}Mapper.insert(${childClassName}) > 0;
+    }
+
+    /**
+     * 更新树节点
+     * @param ${childClassName} 树节点对象
+     * @return 更新结果
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean updateTree(${ChildClassName}Entity ${childClassName}) {
+        ${childClassName}.$str.setProperty($parentField)(validateParentRelation(${childClassName}.$str.getProperty($childPkField.attrName)(), ${childClassName}.$str.getProperty($parentField)()));
+        return ${childClassName}Mapper.updateById(${childClassName}) > 0;
+    }
+
+    /**
+     * 批量删除树节点
+     * @param ids 树节点ID列表
+     * @return 删除结果
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean removeTreeByIds(${childPkField.attrType}[] ids) {
+        if (ArrayUtil.isEmpty(ids)) {
+            return Boolean.TRUE;
+        }
+
+        for (${childPkField.attrType} id : ids) {
+            if (hasChildNodes(id)) {
+                throw new RuntimeException("当前节点存在子节点,不能删除");
+            }
+            if (hasMainRecords(id)) {
+                throw new RuntimeException("当前节点已关联主表数据,不能删除");
+            }
+        }
+
+        return ${childClassName}Mapper.deleteBatchIds(CollUtil.toList(ids)) > 0;
+    }
+
+    /**
+     * 获取TreeNode转换函数
+     * @return TreeNode转换函数
+     */
+    private Function<${ChildClassName}Entity, TreeNode<${childPkField.attrType}>> getNodeFunction() {
+        return entity -> {
+            TreeNode<${childPkField.attrType}> node = new TreeNode<>();
+            node.setId(entity.$childPkGetter());
+            node.setName(entity.$str.getProperty($nameField)());
+            node.setParentId(normalizeParentId(entity.$parentGetter()));
+
+            Map<String, Object> extra = new HashMap<>();
+#foreach($field in $childFieldList)
+            extra.put("${field.attrName}", entity.$str.getProperty($field.attrName)());
+#end
+            node.setExtra(extra);
+            return node;
+        };
+    }
+
+    /**
+     * 规范化父节点ID,并校验父子关系
+     * @param currentId 当前节点ID
+     * @param parentId 父节点ID
+     * @return 规范化后的父节点ID
+     */
+    private ${childPkField.attrType} validateParentRelation(${childPkField.attrType} currentId, ${childPkField.attrType} parentId) {
+        ${childPkField.attrType} normalizedParentId = normalizeParentId(parentId);
+
+        if (Objects.nonNull(currentId) && Objects.equals(currentId, normalizedParentId)) {
+            throw new RuntimeException("不能选择当前节点作为父节点");
+        }
+
+        if (Objects.nonNull(currentId) && isDescendant(currentId, normalizedParentId)) {
+            throw new RuntimeException("不能选择当前节点的后代节点作为父节点");
+        }
+
+        return normalizedParentId;
+    }
+
+    /**
+     * 判断候选父节点是否是当前节点的后代节点
+     * @param currentId 当前节点ID
+     * @param candidateParentId 候选父节点ID
+     * @return 是否为后代节点
+     */
+    private boolean isDescendant(${childPkField.attrType} currentId, ${childPkField.attrType} candidateParentId) {
+        if (Objects.isNull(currentId) || Objects.isNull(candidateParentId) || Objects.equals(candidateParentId, ${treeRootId})) {
+            return false;
+        }
+
+        Set<${childPkField.attrType}> visited = new HashSet<>();
+        ${ChildClassName}Entity parent = ${childClassName}Mapper.selectById(candidateParentId);
+
+        while (Objects.nonNull(parent) && visited.add(parent.$childPkGetter())) {
+            if (Objects.equals(currentId, parent.$childPkGetter())) {
+                return true;
+            }
+
+            ${childPkField.attrType} nextParentId = parent.$parentGetter();
+            if (Objects.isNull(nextParentId) || Objects.equals(nextParentId, ${treeRootId})) {
+                return false;
+            }
+            parent = ${childClassName}Mapper.selectById(nextParentId);
+        }
+
+        return false;
+    }
+
+    /**
+     * 判断是否存在子节点
+     * @param parentId 父节点ID
+     * @return 是否存在子节点
+     */
+    private boolean hasChildNodes(${childPkField.attrType} parentId) {
+        LambdaQueryWrapper<${ChildClassName}Entity> wrapper = Wrappers.lambdaQuery();
+        wrapper.eq(${ChildClassName}Entity::$parentGetter, parentId);
+        return ${childClassName}Mapper.selectCount(wrapper) > 0;
+    }
+
+    /**
+     * 判断是否存在关联主表数据
+     * @param treeId 树节点ID
+     * @return 是否存在主表数据
+     */
+    private boolean hasMainRecords(${childPkField.attrType} treeId) {
+        LambdaQueryWrapper<${ClassName}Entity> wrapper = Wrappers.lambdaQuery();
+        wrapper.eq(${ClassName}Entity::$childFieldGetter, treeId);
+        return baseMapper.selectCount(wrapper) > 0;
+    }
+
+    /**
+     * 规范化父节点ID
+     * @param parentId 父节点ID
+     * @return 规范化后的父节点ID
+     */
+    private ${childPkField.attrType} normalizeParentId(${childPkField.attrType} parentId) {
+        return Objects.nonNull(parentId) ? parentId : ${treeRootId};
+    }
+}

+ 172 - 0
treeTable/左树右表api.ts

@@ -0,0 +1,172 @@
+import request from "/@/utils/request"
+
+// ========== 基础CRUD接口 ==========
+
+/**
+ * 分页查询列表数据
+ * @param query - 查询参数对象
+ * @returns Promise<分页数据>
+ */
+export function fetchList(query?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/page',
+    method: 'get',
+    params: query
+  })
+}
+
+/**
+ * 新增数据
+ * @param obj - 要新增的数据对象
+ * @returns Promise<boolean> - 操作结果
+ */
+export function addObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}',
+    method: 'post',
+    data: obj
+  })
+}
+
+/**
+ * 获取详情数据
+ * @param obj - 查询参数对象(包含ID等)
+ * @returns Promise<数据详情>
+ */
+export function getObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/details',
+    method: 'get',
+    params: obj
+  })
+}
+
+/**
+ * 批量删除数据
+ * @param ids - 要删除的ID数组
+ * @returns Promise<操作结果>
+ */
+export function delObjs(ids?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}',
+    method: 'delete',
+    data: ids
+  })
+}
+
+/**
+ * 更新数据
+ * @param obj - 要更新的数据对象
+ * @returns Promise<操作结果>
+ */
+export function putObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}',
+    method: 'put',
+    data: obj
+  })
+}
+
+// ========== 树形CRUD接口 ==========
+
+/**
+ * 获取树形列表数据
+ * @param query - 查询参数对象
+ * @returns Promise<树形数据>
+ */
+export function fetchTreeList(query?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/tree',
+    method: 'get',
+    params: query
+  })
+}
+
+/**
+ * 获取树节点详情
+ * @param obj - 查询参数对象(包含ID等)
+ * @returns Promise<树节点详情>
+ */
+export function getTreeObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/tree/details',
+    method: 'get',
+    params: obj
+  })
+}
+
+/**
+ * 新增树节点
+ * @param obj - 要新增的树节点对象
+ * @returns Promise<操作结果>
+ */
+export function addTreeObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/tree',
+    method: 'post',
+    data: obj
+  })
+}
+
+/**
+ * 更新树节点
+ * @param obj - 要更新的树节点对象
+ * @returns Promise<操作结果>
+ */
+export function putTreeObj(obj?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/tree',
+    method: 'put',
+    data: obj
+  })
+}
+
+/**
+ * 删除树节点
+ * @param ids - 要删除的树节点ID数组
+ * @returns Promise<操作结果>
+ */
+export function delTreeObjs(ids?: Object) {
+  return request({
+    url: '/${moduleName}/${functionName}/tree',
+    method: 'delete',
+    data: ids
+  })
+}
+
+// ========== 工具函数 ==========
+
+/**
+ * 验证字段值唯一性
+ * @param rule - 验证规则对象
+ * @param value - 要验证的值
+ * @param callback - 验证回调函数
+ * @param isEdit - 是否为编辑模式
+ *
+ * @example
+ * // 在表单验证规则中使用
+ * fieldName: [
+ *   {
+ *     validator: (rule, value, callback) => {
+ *       validateExist(rule, value, callback, form.${pk.attrName} !== '');
+ *     },
+ *     trigger: 'blur',
+ *   },
+ * ]
+ */
+export function validateExist(rule: any, value: any, callback: any, isEdit: boolean) {
+  // 编辑模式下跳过验证
+  if (isEdit) {
+    return callback();
+  }
+
+  // 查询是否存在相同值
+  getObj({ [rule.field]: value }).then((response) => {
+    const result = response.data;
+    if (result !== null && result.length > 0) {
+      callback(new Error('数据已经存在'));
+    } else {
+      callback();
+    }
+  });
+}

+ 330 - 0
treeTable/左树右表主表单.vue

@@ -0,0 +1,330 @@
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#break
+#end
+#end
+#set($relationFieldLabel = $childField)
+#foreach($field in $fieldList)
+#if($field.attrName == $childField)
+#if($field.fieldComment)
+#set($relationFieldLabel = $field.fieldComment)
+#else
+#set($relationFieldLabel = $field.attrName)
+#end
+#break
+#end
+#end
+#set($fieldDict=[])
+#foreach($field in $formList)
+#if($field.fieldDict)
+#set($void=$fieldDict.add($field.fieldDict))
+#end
+#end
+<template>
+  <el-dialog :title="form.${pk.attrName} ? '编辑' : '新增'" v-model="visible"
+    :close-on-click-modal="false" draggable>
+    <el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="90px" v-loading="loading">
+      <el-row :gutter="24">
+#if($formLayout == 1)
+        <el-col :span="24" class="mb20">
+#elseif($formLayout == 2)
+        <el-col :span="12" class="mb20">
+#end
+          <el-form-item label="${relationFieldLabel}" prop="${childField}">
+            <el-tree-select
+              v-model="form.${childField}"
+              :data="treeNodes"
+              :props="treeSelectProps"
+              check-strictly
+              :render-after-expand="false"
+              placeholder="请选择${relationFieldLabel}"
+              style="width: 100%"
+              clearable
+              filterable
+            />
+          </el-form-item>
+        </el-col>
+#foreach($field in $formList)
+#if($field.attrName != ${pk.attrName} && $field.attrName != ${childField})
+#if($formLayout == 1)
+        <el-col :span="24" class="mb20">
+#elseif($formLayout == 2)
+        <el-col :span="12" class="mb20">
+#end
+#if($field.formType == 'text')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'textarea')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input type="textarea" v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'select')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-select v-model="form.${field.attrName}" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end">
+#if($field.fieldDict)
+              <el-option :value="item.value" :label="item.label" v-for="(item, index) in ${field.fieldDict}" :key="index"></el-option>
+#else
+              <el-option label="请选择" value="0"></el-option>
+#end
+            </el-select>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'radio')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-radio-group v-model="form.${field.attrName}">
+#if($field.fieldDict)
+              <el-radio :label="item.value" v-for="(item, index) in ${field.fieldDict}" border :key="index">{{ item.label }}</el-radio>
+#else
+              <el-radio label="${field.fieldComment}" border>${field.fieldComment}</el-radio>
+#end
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'checkbox')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-checkbox-group v-model="form.${field.attrName}">
+#if($field.fieldDict)
+              <el-checkbox :label="item.value" v-for="(item, index) in ${field.fieldDict}" :key="index">{{ item.label }}</el-checkbox>
+#else
+              <el-checkbox label="启用" name="type"></el-checkbox>
+              <el-checkbox label="禁用" name="type"></el-checkbox>
+#end
+            </el-checkbox-group>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'date')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="date" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" v-model="form.${field.attrName}" :value-format="dateStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'datetime')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="datetime" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" v-model="form.${field.attrName}" :value-format="dateTimeStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'daterange')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" v-model="form.${field.attrName}" :value-format="dateStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'datetimerange')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" v-model="form.${field.attrName}" :value-format="dateTimeStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'number')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input-number :min="1" :max="1000" v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"></el-input-number>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'upload-file')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <upload-file v-model="form.${field.attrName}"></upload-file>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'upload-img')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <upload-img v-model:imageUrl="form.${field.attrName}"></upload-img>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'editor')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <editor v-if="visible" v-model:get-html="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"></editor>
+          </el-form-item>
+        </el-col>
+#else
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#end
+#end
+#end
+      </el-row>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="visible = false">取 消</el-button>
+        <el-button type="primary" @click="onSubmit" :disabled="loading">确 认</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="${ClassName}Dialog">
+import { useMessage } from "/@/hooks/message";
+import { addObj, fetchTreeList, getObj, putObj, validateExist } from '/@/api/${moduleName}/${functionName}';
+#if($fieldDict && $fieldDict.size() > 0)
+import { useDict } from '/@/hooks/dict';
+#end
+#foreach($field in $formList)
+#if($field.formValidator && $field.formValidator != 'duplicate')
+import { rule } from '/@/utils/validate';
+#break
+#end
+#end
+
+interface TreeNode {
+  [key: string]: any;
+  children?: TreeNode[];
+}
+
+interface FormData {
+  ${pk.attrName}?: string | number;
+  ${childField}?: string | number | null;
+#foreach($field in $formList)
+#if($field.attrName != ${pk.attrName} && $field.attrName != ${childField})
+#if($field.formType == 'number')
+  ${field.attrName}: number;
+#elseif($field.formType == 'checkbox' || $field.formType == 'daterange' || $field.formType == 'datetimerange')
+  ${field.attrName}: any[];
+#else
+  ${field.attrName}: string;
+#end
+#end
+#end
+}
+
+const emit = defineEmits(['refresh']);
+const dataFormRef = ref();
+const visible = ref(false);
+const loading = ref(false);
+const treeNodes = ref<TreeNode[]>([]);
+const currentTreeId = ref<string | number | null>(null);
+
+const treeSelectProps = {
+  children: 'children',
+  label: '${nameField}',
+  value: '${childPkField.attrName}',
+  checkStrictly: true
+};
+
+const form = reactive<FormData>({
+  ${pk.attrName}: '', // 主键
+  ${childField}: null,
+#foreach($field in $formList)
+#if($field.attrName != ${pk.attrName} && $field.attrName != ${childField})
+#if($field.formType == 'number')
+  ${field.attrName}: 0, // ${field.fieldComment}
+#elseif($field.formType == 'checkbox' || $field.formType == 'daterange' || $field.formType == 'datetimerange')
+  ${field.attrName}: [], // ${field.fieldComment}
+#else
+  ${field.attrName}: '', // ${field.fieldComment}
+#end
+#end
+#end
+});
+
+#if($fieldDict && $fieldDict.size() > 0)
+const { $dict.format($fieldDict) } = useDict($dict.quotation($fieldDict));
+#end
+
+const dataRules = ref({
+  ${childField}: [
+    {
+      required: true,
+        validator: (rule: any, value: any, callback: any) => {
+        if (value === null || value === undefined || value === '') {
+          callback(new Error('请选择${relationFieldLabel}'));
+        } else {
+          callback();
+        }
+      },
+      trigger: 'change'
+    }
+  ],
+#foreach($field in $formList)
+#if($field.attrName != ${childField} && $field.formRequired == '1' && $field.formValidator == 'duplicate')
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' },
+    {
+      validator: (rule: any, value: any, callback: any) => {
+        validateExist(rule, value, callback, form.${pk.attrName} !== '');
+      },
+      trigger: 'blur',
+    }
+  ],
+#elseif($field.attrName != ${childField} && $field.formRequired == '1' && $field.formValidator)
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' },
+    { validator: rule.${field.formValidator}, trigger: 'blur' }
+  ],
+#elseif($field.attrName != ${childField} && $field.formRequired == '1')
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' }
+  ],
+#elseif($field.attrName != ${childField} && $field.formValidator)
+  ${field.attrName}: [
+    { validator: rule.${field.formValidator}, trigger: 'blur' }
+  ],
+#end
+#end
+});
+
+const get${ClassName}Data = async (id: string) => {
+  try {
+    loading.value = true;
+    const { data } = await getObj({ ${pk.attrName}: id });
+    if (data && data.length > 0) {
+      Object.assign(form, data[0]);
+    }
+  } catch (error) {
+    useMessage().error('获取数据失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+const loadTreeNodes = async () => {
+  const { data } = await fetchTreeList();
+  treeNodes.value = data || [];
+};
+
+const openDialog = async (id?: string, treeId?: string | number) => {
+  visible.value = true;
+  currentTreeId.value = treeId ?? null;
+  form.${pk.attrName} = '';
+
+  nextTick(() => {
+    dataFormRef.value?.resetFields();
+  });
+
+  await loadTreeNodes();
+  form.${childField} = treeId ?? null;
+
+  if (id) {
+    form.${pk.attrName} = id;
+    await get${ClassName}Data(id);
+  }
+};
+
+const onSubmit = async () => {
+  loading.value = true;
+
+  const valid = await dataFormRef.value.validate().catch(() => {});
+  if (!valid) {
+    loading.value = false;
+    return false;
+  }
+
+  try {
+    form.${childField} = form.${childField} ?? null;
+    form.${pk.attrName} ? await putObj(form) : await addObj(form);
+    useMessage().success(form.${pk.attrName} ? '修改成功' : '添加成功');
+    visible.value = false;
+    emit('refresh');
+  } catch (error: any) {
+    useMessage().error(error.msg || '操作失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({
+  openDialog
+});
+</script>

+ 372 - 0
treeTable/左树右表树表单.vue

@@ -0,0 +1,372 @@
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#break
+#end
+#end
+#set($fieldDict=[])
+#foreach($field in $childFieldList)
+#if($field.fieldDict)
+#set($void=$fieldDict.add($field.fieldDict))
+#end
+#end
+<template>
+  <el-dialog :title="form.${childPkField.attrName} ? '编辑' : '新增'" v-model="visible"
+    :close-on-click-modal="false" draggable>
+    <el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="90px" v-loading="loading">
+      <el-row :gutter="24">
+#if($formLayout == 1)
+        <el-col :span="24" class="mb20">
+#elseif($formLayout == 2)
+        <el-col :span="12" class="mb20">
+#end
+          <el-form-item label="父级节点" prop="${parentField}">
+            <el-tree-select
+              v-model="form.${parentField}"
+              :data="parentNodes"
+              :props="treeSelectProps"
+              check-strictly
+              :render-after-expand="false"
+              placeholder="请选择父级节点"
+              style="width: 100%"
+              clearable
+              filterable
+            />
+          </el-form-item>
+        </el-col>
+#foreach($field in $childFieldList)
+#if($field.attrName != ${childPkField.attrName} && $field.attrName != ${parentField})
+#if($formLayout == 1)
+        <el-col :span="24" class="mb20">
+#elseif($formLayout == 2)
+        <el-col :span="12" class="mb20">
+#end
+#if($field.formType == 'text')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'textarea')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input type="textarea" v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'select')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-select v-model="form.${field.attrName}" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end">
+#if($field.fieldDict)
+              <el-option :value="item.value" :label="item.label" v-for="(item, index) in ${field.fieldDict}" :key="index"></el-option>
+#else
+              <el-option label="请选择" value="0"></el-option>
+#end
+            </el-select>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'radio')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-radio-group v-model="form.${field.attrName}">
+#if($field.fieldDict)
+              <el-radio :label="item.value" v-for="(item, index) in ${field.fieldDict}" border :key="index">{{ item.label }}</el-radio>
+#else
+              <el-radio label="${field.fieldComment}" border>${field.fieldComment}</el-radio>
+#end
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'checkbox')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-checkbox-group v-model="form.${field.attrName}">
+#if($field.fieldDict)
+              <el-checkbox :label="item.value" v-for="(item, index) in ${field.fieldDict}" :key="index">{{ item.label }}</el-checkbox>
+#else
+              <el-checkbox label="启用" name="type"></el-checkbox>
+              <el-checkbox label="禁用" name="type"></el-checkbox>
+#end
+            </el-checkbox-group>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'date')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="date" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" v-model="form.${field.attrName}" :value-format="dateStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'datetime')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="datetime" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" v-model="form.${field.attrName}" :value-format="dateTimeStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'daterange')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" v-model="form.${field.attrName}" :value-format="dateStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'datetimerange')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-date-picker type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" v-model="form.${field.attrName}" :value-format="dateTimeStr"></el-date-picker>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'number')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input-number :min="1" :max="1000" v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"></el-input-number>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'upload-file')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <upload-file v-model="form.${field.attrName}"></upload-file>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'upload-img')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <upload-img v-model:imageUrl="form.${field.attrName}"></upload-img>
+          </el-form-item>
+        </el-col>
+#elseif($field.formType == 'editor')
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <editor v-if="visible" v-model:get-html="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"></editor>
+          </el-form-item>
+        </el-col>
+#else
+          <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+            <el-input v-model="form.${field.attrName}" placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"/>
+          </el-form-item>
+        </el-col>
+#end
+#end
+#end
+      </el-row>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="visible = false">取 消</el-button>
+        <el-button type="primary" @click="onSubmit" :disabled="loading">确 认</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="${ChildClassName}TreeDialog">
+import { useMessage } from "/@/hooks/message";
+import { addTreeObj, fetchTreeList, getTreeObj, putTreeObj } from '/@/api/${moduleName}/${functionName}';
+#if($fieldDict && $fieldDict.size() > 0)
+import { useDict } from '/@/hooks/dict';
+#end
+#foreach($field in $childFieldList)
+#if($field.formValidator && $field.formValidator != 'duplicate')
+import { rule } from '/@/utils/validate';
+#break
+#end
+#end
+
+interface TreeNode {
+  [key: string]: any;
+  children?: TreeNode[];
+}
+
+interface FormData {
+  ${childPkField.attrName}?: string | number;
+  ${parentField}?: string | number | null;
+#foreach($field in $childFieldList)
+#if($field.attrName != ${childPkField.attrName} && $field.attrName != ${parentField})
+#if($field.formType == 'number')
+  ${field.attrName}: number;
+#elseif($field.formType == 'checkbox' || $field.formType == 'daterange' || $field.formType == 'datetimerange')
+  ${field.attrName}: any[];
+#else
+  ${field.attrName}: string;
+#end
+#end
+#end
+}
+
+const emit = defineEmits(['refresh']);
+const dataFormRef = ref();
+const visible = ref(false);
+const loading = ref(false);
+const parentNodes = ref<TreeNode[]>([]);
+const currentNodeId = ref<string | number | null>(null);
+
+const treeSelectProps = {
+  children: 'children',
+  label: '${nameField}',
+  value: '${childPkField.attrName}',
+  checkStrictly: true
+};
+
+const form = reactive<FormData>({
+  ${childPkField.attrName}: '', // 主键
+  ${parentField}: 0,
+#foreach($field in $childFieldList)
+#if($field.attrName != ${childPkField.attrName} && $field.attrName != ${parentField})
+#if($field.formType == 'number')
+  ${field.attrName}: 0, // ${field.fieldComment}
+#elseif($field.formType == 'checkbox' || $field.formType == 'daterange' || $field.formType == 'datetimerange')
+  ${field.attrName}: [], // ${field.fieldComment}
+#else
+  ${field.attrName}: '', // ${field.fieldComment}
+#end
+#end
+#end
+});
+
+#if($fieldDict && $fieldDict.size() > 0)
+const { $dict.format($fieldDict) } = useDict($dict.quotation($fieldDict));
+#end
+
+const dataRules = ref({
+  ${parentField}: [
+    {
+      required: true,
+      validator: (rule: any, value: any, callback: any) => {
+        if (value === null || value === undefined || value === '') {
+          callback(new Error('请选择父级节点'));
+        } else {
+          callback();
+        }
+      },
+      trigger: 'change'
+    }
+  ],
+#foreach($field in $childFieldList)
+#if($field.attrName != ${parentField} && $field.formRequired == '1' && $field.formValidator == 'duplicate')
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' },
+    {
+      validator: (rule: any, value: any, callback: any) => {
+        validateExist(rule, value, callback, form.${childPkField.attrName} !== '');
+      },
+      trigger: 'blur',
+    }
+  ],
+#elseif($field.attrName != ${parentField} && $field.formRequired == '1' && $field.formValidator)
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' },
+    { validator: rule.${field.formValidator}, trigger: 'blur' }
+  ],
+#elseif($field.attrName != ${parentField} && $field.formRequired == '1')
+  ${field.attrName}: [
+    { required: true, message: '${field.fieldComment}不能为空', trigger: 'blur' }
+  ],
+#elseif($field.attrName != ${parentField} && $field.formValidator)
+  ${field.attrName}: [
+    { validator: rule.${field.formValidator}, trigger: 'blur' }
+  ],
+#end
+#end
+});
+
+const get${ChildClassName}Data = async (id: string) => {
+  try {
+    loading.value = true;
+    const { data } = await getTreeObj({ ${childPkField.attrName}: id });
+    if (data && data.length > 0) {
+      Object.assign(form, data[0]);
+    }
+  } catch (error) {
+    useMessage().error('获取数据失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+const buildParentNodes = (nodes: TreeNode[], excludedIds: Set<string | number> = new Set()): TreeNode[] => {
+  return nodes
+    .filter((node) => !excludedIds.has(node.${childPkField.attrName}))
+    .map((node) => ({
+      ...node,
+      children: node.children ? buildParentNodes(node.children, excludedIds) : []
+    }));
+};
+
+const collectDescendantIds = (nodes: TreeNode[], targetId: string | number, collect: Set<string | number>) => {
+  nodes.forEach((node) => {
+    if (node.${childPkField.attrName} === targetId) {
+      const traverse = (children?: TreeNode[]) => {
+        children?.forEach((child) => {
+          collect.add(child.${childPkField.attrName});
+          traverse(child.children);
+        });
+      };
+      traverse(node.children);
+    } else if (node.children?.length) {
+      collectDescendantIds(node.children, targetId, collect);
+    }
+  });
+};
+
+const loadParentNodes = async (excludeCurrent = false) => {
+  const { data } = await fetchTreeList();
+  const rootNode: TreeNode = {
+    ${childPkField.attrName}: 0,
+    ${nameField}: '根节点',
+    children: data || []
+  };
+
+  if (excludeCurrent && currentNodeId.value !== null && currentNodeId.value !== undefined) {
+    const excludedIds = new Set<string | number>([currentNodeId.value]);
+    collectDescendantIds(rootNode.children || [], currentNodeId.value, excludedIds);
+    parentNodes.value = [{ ...rootNode, children: buildParentNodes(rootNode.children || [], excludedIds) }];
+    return;
+  }
+
+  parentNodes.value = [rootNode];
+};
+
+const openDialog = async (id?: string, parentId?: string | number) => {
+  visible.value = true;
+  form.${childPkField.attrName} = '';
+  currentNodeId.value = id || null;
+
+  nextTick(() => {
+    dataFormRef.value?.resetFields();
+  });
+
+  await loadParentNodes(Boolean(id));
+  form.${parentField} = parentId ?? 0;
+
+  if (id) {
+    form.${childPkField.attrName} = id;
+    await get${ChildClassName}Data(id);
+  }
+};
+
+const validateExist = (rule: any, value: any, callback: any, isEdit: boolean) => {
+  if (isEdit) {
+    return callback();
+  }
+
+  getTreeObj({ [rule.field]: value }).then((response) => {
+    const result = response.data;
+    if (result !== null && result.length > 0) {
+      callback(new Error('数据已经存在'));
+    } else {
+      callback();
+    }
+  });
+};
+
+const onSubmit = async () => {
+  loading.value = true;
+
+  const valid = await dataFormRef.value.validate().catch(() => {});
+  if (!valid) {
+    loading.value = false;
+    return false;
+  }
+
+  try {
+    form.${parentField} = form.${parentField} ?? 0;
+    form.${childPkField.attrName} ? await putTreeObj(form) : await addTreeObj(form);
+    useMessage().success(form.${childPkField.attrName} ? '修改成功' : '添加成功');
+    visible.value = false;
+    emit('refresh');
+  } catch (error: any) {
+    useMessage().error(error.msg || '操作失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+defineExpose({
+  openDialog
+});
+</script>

+ 440 - 0
treeTable/左树右表表格.vue

@@ -0,0 +1,440 @@
+#foreach($field in $childFieldList)
+#if($field.primaryPk == '1')
+#set($childPkField = $field)
+#break
+#end
+#end
+<template>
+  <div class="layout-padding">
+    <splitpanes>
+      <pane size="15">
+        <div class="layout-padding-auto layout-padding-view">
+          <el-scrollbar>
+            <div class="mb8" style="display: flex; align-items: center; justify-content: space-between;">
+              <span style="font-weight: 500;">左侧节点</span>
+              <div>
+                <el-tooltip :content="currentTreeId ? '新增子节点' : '新增根节点'" placement="top">
+                  <el-button link type="primary" icon="FolderAdd" @click="handleAddTree" />
+                </el-tooltip>
+              </div>
+            </div>
+            <el-tree
+              ref="treeRef"
+              :data="treeData"
+              :props="treeProps"
+              node-key="${childPkField.attrName}"
+              highlight-current
+              default-expand-all
+              :expand-on-click-node="false"
+              @node-click="handleTreeNodeClick"
+            >
+              <template #default="{ node, data }">
+                <span style="flex: 1; display: flex; align-items: center; justify-content: space-between;">
+                  <span>{{ node.label }}</span>
+                  <span v-if="currentTreeId === data.${childPkField.attrName}" style="margin-left: 8px;">
+                    <el-tooltip content="编辑" placement="top">
+                      <el-button link type="primary" icon="Edit" size="small" @click.stop="handleEditTree" />
+                    </el-tooltip>
+                    <el-tooltip content="删除" placement="top">
+                      <el-button link type="danger" icon="Delete" size="small" @click.stop="handleDeleteTree" />
+                    </el-tooltip>
+                  </span>
+                </span>
+              </template>
+            </el-tree>
+          </el-scrollbar>
+        </div>
+      </pane>
+      <pane class="ml8">
+        <div class="layout-padding-auto layout-padding-view">
+#if($queryList)
+            <el-row v-show="showSearch">
+              <el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
+#foreach($field in $queryList)
+#if($field.attrName != ${childField})
+#if($field.queryFormType == 'select')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+                  <el-select v-model="state.queryForm.${field.attrName}" placeholder="请选择#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end">
+#if($field.fieldDict)
+                    <el-option 
+                      :label="item.label" 
+                      :value="item.value" 
+                      v-for="(item, index) in ${field.fieldDict}" 
+                      :key="index"
+                    />
+#else
+                    <el-option label="请选择" value="0" />
+#end
+                  </el-select>
+                </el-form-item>
+#elseif($field.queryFormType == 'date')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+                  <el-date-picker
+                    type="date"
+                    placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"
+                    v-model="state.queryForm.${field.attrName}"
+                    :value-format="dateStr"
+                  />
+                </el-form-item>
+#elseif($field.queryFormType == 'daterange')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}Range">
+                  <el-date-picker
+                    type="daterange"
+                    range-separator="至"
+                    start-placeholder="开始日期"
+                    end-placeholder="结束日期"
+                    v-model="state.queryForm.${field.attrName}Range"
+                    :value-format="dateStr"
+                  />
+                </el-form-item>
+#elseif($field.queryFormType == 'datetime')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+                  <el-date-picker
+                    type="datetime"
+                    placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"
+                    v-model="state.queryForm.${field.attrName}"
+                    :value-format="dateTimeStr"
+                  />
+                </el-form-item>
+#elseif($field.queryFormType == 'datetimerange')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}Range">
+                  <el-date-picker
+                    type="datetimerange"
+                    range-separator="至"
+                    start-placeholder="开始时间"
+                    end-placeholder="结束时间"
+                    v-model="state.queryForm.${field.attrName}Range"
+                    :value-format="dateTimeStr"
+                  />
+                </el-form-item>
+#elseif($field.formType == 'radio')
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+                  <el-radio-group v-model="state.queryForm.${field.attrName}">
+#if($field.fieldDict)
+                    <el-radio 
+                      :label="item.value" 
+                      v-for="(item, index) in ${field.fieldDict}" 
+                      border 
+                      :key="index"
+                    >
+                      {{ item.label }}
+                    </el-radio>
+#else
+                    <el-radio label="${field.fieldComment}" border>
+                      ${field.fieldComment}
+                    </el-radio>
+#end
+                  </el-radio-group>
+                </el-form-item>
+#else
+                <el-form-item label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" prop="${field.attrName}">
+                  <el-input 
+                    placeholder="请输入#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" 
+                    v-model="state.queryForm.${field.attrName}" 
+                  />
+                </el-form-item>
+#end
+#end
+#end
+                <el-form-item>
+                  <el-button icon="search" type="primary" @click="getDataList">
+                    查询
+                  </el-button>
+                  <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+                </el-form-item>
+              </el-form>
+            </el-row>
+#end
+
+            <el-row>
+              <div class="mb8" style="width: 100%">
+                <el-button 
+                  icon="folder-add" 
+                  type="primary" 
+                  class="ml10" 
+                  @click="handleAddRow"
+                  v-auth="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add'"
+                >
+                  新增
+                </el-button>
+                <el-button 
+                  plain 
+                  icon="upload-filled" 
+                  type="primary" 
+                  class="ml10" 
+                  @click="excelUploadRef.show()" 
+                  v-auth="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_add'"
+                >
+                  导入
+                </el-button>
+                <el-button 
+                  plain 
+                  :disabled="multiple" 
+                  icon="Delete" 
+                  type="primary"
+                  v-auth="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del'" 
+                  @click="handleDelete(selectObjs)"
+                >
+                  删除
+                </el-button>
+                <right-toolbar 
+                  v-model:showSearch="showSearch" 
+                  :export="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_export'"
+                  @exportExcel="exportExcel" 
+                  class="ml10 mr20" 
+                  style="float: right;"
+                  @queryTable="getDataList"
+                />
+              </div>
+            </el-row>
+
+            <el-table 
+              :data="state.dataList" 
+              v-loading="state.loading" 
+              border 
+              :cell-style="tableStyle.cellStyle" 
+              :header-cell-style="tableStyle.headerCellStyle"
+              @selection-change="selectionChangeHandle"
+              @sort-change="sortChangeHandle"
+            >
+              <el-table-column type="selection" width="40" align="center" />
+              <el-table-column type="index" label="#" width="40" />
+#foreach($field in $gridList)
+#if($field.fieldDict)
+              <el-table-column prop="${field.attrName}" label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end" show-overflow-tooltip>
+                <template #default="scope">
+                  <dict-tag :options="${field.fieldDict}" :value="scope.row.${field.attrName}" />
+                </template>
+              </el-table-column>
+#elseif(${field.formType} == 'upload-img')
+              <el-table-column prop="${field.attrName}" label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end">
+                <template #default="{ row }">
+                  <upload-img disabled v-model:imageUrl="row.${field.attrName}"></upload-img>
+                </template>
+              </el-table-column>
+#else
+              <el-table-column
+                prop="${field.attrName}"
+                label="#if(${field.fieldComment})${field.fieldComment}#else${field.attrName}#end"
+#if(${field.gridSort} == '1')
+                sortable="custom"
+#end
+                show-overflow-tooltip
+              />
+#end
+#end
+              <el-table-column label="操作" width="200">
+                <template #default="scope">
+                  <el-button 
+                    icon="edit-pen" 
+                    text 
+                    type="primary" 
+                    v-auth="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_edit'"
+                    @click="handleEditRow(scope.row)"
+                  >
+                    编辑
+                  </el-button>
+                  <el-button 
+                    icon="delete" 
+                    text 
+                    type="primary" 
+                    v-auth="'$str.lowerCase($moduleName)_$str.lowerCase($functionName)_del'" 
+                    @click="handleDelete([scope.row.${pk.attrName}])"
+                  >
+                    删除
+                  </el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+
+            <pagination
+              @size-change="sizeChangeHandle"
+              @current-change="currentChangeHandle"
+              v-bind="state.pagination"
+            />
+          </div>
+      </pane>
+    </splitpanes>
+
+    <tree-form ref="treeFormDialogRef" @refresh="reloadTree" />
+    <form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
+
+    <upload-excel
+      ref="excelUploadRef"
+      title="导入"
+      url="/${moduleName}/${functionName}/import"
+      temp-url="/admin/sys-file/local/file/${functionName}.xlsx"
+      @refreshDataList="getDataList"
+    />
+  </div>
+</template>
+
+<script setup lang="ts" name="system${ClassName}TreeTable">
+import { BasicTableProps, useTable } from "/@/hooks/table";
+import { fetchList, delObjs, fetchTreeList, delTreeObjs } from "/@/api/${moduleName}/${functionName}";
+import { useMessage, useMessageBox } from "/@/hooks/message";
+import { useDict } from '/@/hooks/dict';
+
+const FormDialog = defineAsyncComponent(() => import('./form.vue'));
+const TreeForm = defineAsyncComponent(() => import('./tree-form.vue'));
+
+#set($fieldDict=[])
+#foreach($field in $queryList)
+#if($field.fieldDict)
+#set($void=$fieldDict.add($field.fieldDict))
+#end
+#end
+#foreach($field in $gridList)
+#if($field.fieldDict)
+#set($void=$fieldDict.add($field.fieldDict))
+#end
+#end
+#if($fieldDict && $fieldDict.size() > 0)
+const { $dict.format($fieldDict) } = useDict($dict.quotation($fieldDict));
+#end
+
+interface TreeNode {
+  [key: string]: any;
+  children?: TreeNode[];
+}
+
+const formDialogRef = ref();
+const treeFormDialogRef = ref();
+const excelUploadRef = ref();
+const queryRef = ref();
+const treeRef = ref();
+
+const showSearch = ref(true);
+const selectObjs = ref([]) as any;
+const multiple = ref(true);
+const currentTreeId = ref<string | number | null>(null);
+const treeData = ref<TreeNode[]>([]);
+
+const treeProps = {
+  children: 'children',
+  label: '${nameField}'
+};
+
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {},
+  pageList: fetchList
+});
+
+const {
+  getDataList,
+  currentChangeHandle,
+  sizeChangeHandle,
+  sortChangeHandle,
+  downBlobFile,
+  tableStyle
+} = useTable(state);
+
+const reloadTree = async () => {
+  const { data } = await fetchTreeList();
+  treeData.value = data || [];
+  nextTick(() => {
+    treeRef.value?.setCurrentKey(currentTreeId.value);
+  });
+};
+
+const resetQuery = () => {
+  queryRef.value?.resetFields();
+  selectObjs.value = [];
+  if (currentTreeId.value) {
+    state.queryForm.${childField} = currentTreeId.value;
+  } else {
+    delete state.queryForm.${childField};
+  }
+  getDataList();
+};
+
+const exportExcel = () => {
+  downBlobFile(
+    '/${moduleName}/${functionName}/export',
+    { ...state.queryForm, ids: selectObjs.value },
+    '${functionName}.xlsx'
+  );
+};
+
+const selectionChangeHandle = (objs: { ${pk.attrName}: string }[]) => {
+  selectObjs.value = objs.map(({ ${pk.attrName} }) => ${pk.attrName});
+  multiple.value = !objs.length;
+};
+
+const handleDelete = async (ids: string[]) => {
+  try {
+    await useMessageBox().confirm('此操作将永久删除');
+  } catch {
+    return;
+  }
+
+  try {
+    await delObjs(ids);
+    getDataList();
+    useMessage().success('删除成功');
+  } catch (err: any) {
+    useMessage().error(err.msg);
+  }
+};
+
+const handleTreeNodeClick = (data: TreeNode) => {
+  currentTreeId.value = data.${childPkField.attrName};
+  state.queryForm.${childField} = currentTreeId.value;
+  treeRef.value?.setCurrentKey(currentTreeId.value);
+  getDataList();
+};
+
+const clearTreeSelection = () => {
+  currentTreeId.value = null;
+  treeRef.value?.setCurrentKey(null);
+  delete state.queryForm.${childField};
+};
+
+const handleAddTree = () => {
+  treeFormDialogRef.value?.openDialog(undefined, currentTreeId.value ?? 0);
+};
+
+const handleEditTree = () => {
+  if (!currentTreeId.value) {
+    useMessage().warning('请先选择一个左侧节点');
+    return;
+  }
+  treeFormDialogRef.value?.openDialog(currentTreeId.value);
+};
+
+const handleDeleteTree = async () => {
+  if (!currentTreeId.value) {
+    useMessage().warning('请先选择一个左侧节点');
+    return;
+  }
+
+  try {
+    await useMessageBox().confirm('此操作将永久删除左侧节点');
+  } catch {
+    return;
+  }
+
+  try {
+    await delTreeObjs([currentTreeId.value]);
+    clearTreeSelection();
+    await reloadTree();
+    getDataList();
+    useMessage().success('删除成功');
+  } catch (err: any) {
+    useMessage().error(err.msg);
+  }
+};
+
+const handleAddRow = () => {
+  formDialogRef.value?.openDialog(undefined, currentTreeId.value ?? undefined);
+};
+
+const handleEditRow = (row: any) => {
+  formDialogRef.value?.openDialog(row.${pk.attrName}, currentTreeId.value ?? undefined);
+};
+
+onMounted(async () => {
+  await reloadTree();
+  getDataList();
+});
+</script>