---
name: vben3-tree-wrapper-crud
description: 生成基于 TreeWrapper + EditTable + Drawer 的 Vben v3 CRUD 页面模板。适用于需要左侧菜单树 + 右侧数据表格的列表页面,含状态切换、文件上传预览下载、权限抽屉、批量删除等完整功能。当用户需要在成堪数智平台创建带左树的 CRUD 页面时使用此 skill。
---
# Vben v3 TreeWrapper CRUD 页面模板
> 基于 `apps/micro-apps/system/src/views/manager/` 提取的 TreeWrapper + EditTable + Drawer + DrawerAuth 四件套模板。
> 适用于**左侧有系统/菜单树、右侧展示关联数据的 CRUD 页面**。
---
## 一、适用场景
| 场景 | 是否适用 |
|------|---------|
| 左侧菜单树,点击过滤右侧表格数据 | ✅ 使用此模板 |
| 需要文件上传、预览、下载 | ✅ 使用此模板 |
| 需要角色/权限授权抽屉 | ✅ 使用此模板 |
| 需要开关启禁用状态 | ✅ 使用此模板 |
| 纯表格 CRUD,无左树 | ❌ 使用 vben3.md 基础版 |
| 机构树 + 普通 CRUD | ❌ 使用 vben3.md 带左树版(CommonPage) |
---
## 二、文件清单与生成顺序
```
views/{module}/
├── comp/
│ ├── tree.vue # ① 先创建:BussinessTree 树组件
│ └── treeWrapper.vue # ② 复用:左树+右内容布局容器
├── config.ts # ③ 配置:表格+表单+权限树
├── drawer.vue # ④ 抽屉:新增/编辑
├── drawerAuth.vue # ⑤ 可选:角色授权抽屉
└── index.vue # ⑥ 最后:主页面组装
```
**关键导入来源**:
- `@vben/adapter` → `EditTable`, `Drawer`, `BussinessTree`
- `@vben/wflow` → `AddForm`
- `@vben/common-ui` → `Page`
- `@vben/icons` → `getIconFont`
- `@vben/utils` → `cloneDeep`, `session`, `commWay`
- `element-plus` → `ElButton`, `ElMessage`, `ElPopconfirm`, `ElSwitch`, `ElRow`, `ElCol`
---
## 三、与标准 CRUD 模板的关键差异
| 特性 | 此模板 | 标准 CRUD(vben3.md) |
|------|--------|----------------------|
| 布局 | `TreeWrapper`(ElRow 5:19) | `CommonPage` 或 `Page` |
| 左树 | 自定义 `tree.vue` + `BussinessTree` | 无 / CommonPage 内置 |
| 树↔表格数据流 | `activeMenuId` → `menuId` 参数 | `organizationId` → globParams |
| 新增预设 | 当前树节点 ID → drawer `menusArr` | 直接开空表单 |
| 权限抽屉 | `drawerAuth.vue` + `TreeTransfer` | 无 |
---
## 四、数据流
```
TreeWrapper
├── tree.vue @node-click → handleNodeClick(node)
│ └── activeMenuId = node.id
│ └── editTable.getData({ ...values, menuId: node.id })
│
└── EditTable
├── @operation-click → operationClick(data)
│ ├── 'add'/'edit' → drawer.openModal(data) // data.menusArr 预设菜单
│ ├── 'delete' → deleteClick(row)
│ └── 'menuAuth' → drawerAuth.openModal(data)
│
└── drawer/drawerAuth @on-confirm → getData() → refreshTree()
```
---
## 五、模板代码
> 完整模板代码见 [templates.md](templates.md),包含所有 6 个文件的完整内容。
### 5.1 treeWrapper.vue(直接复用,无需修改)
```vue
```
### 5.2 tree.vue 要点
基于 `BussinessTree`,核心配置:
- `searchOptions.show: true` — 搜索框
- `treeOptions.api.query` — 调用你的树数据 API,返回含 `id/name/children` 的数组
- `treeOptions.api.isUseApi: true`
- 需要 `defineExpose({ refreshTree })` 供父组件刷新
- 树数据存入 `session.setItem("menuNode", list)` 供 drawer 的 ApiTreeSelect 使用
### 5.3 index.vue 核心事件
```typescript
const events = {
operationClick(data) {
// add/edit → drawer.openModal(data); data.menusArr = [activeMenuId]
// delete → deleteClick(row)
// menuAuth → drawerAuth.openModal(data)
},
changeStatus(row) { /* updateApi → getData */ },
deleteClick(row?) { /* row?.id 或 getCheckboxRecords → removeApi → getData */ },
async getData() { /* gridApi.formApi.getValues → editTable.getData → refreshTree */ },
async handleNodeClick(node) { /* activeMenuId = node.id → getData({ menuId }) */ },
};
```
模板中 `` 插槽实现 ElSwitch 启禁用切换,`` 插槽放置批量删除按钮。
### 5.4 drawer.vue 核心逻辑
- `openModal` 回调中:编辑时调 `idByFile` 回填菜单权限;新增时用 `data.menusArr` 预设
- `onConfirm`:收集表单值 → 处理文件(`commWay.getFileRes`) → 处理菜单关联 → 调 API
### 5.5 drawerAuth.vue 核心逻辑
- `init` 时加载全部角色列表
- `openModal` 时根据已授权角色拆分到左右两侧树
- `onConfirm` 收集右侧树数据调授权 API
---
## 六、生成步骤
1. **创建 API 文件** `src/api/{module}.ts`:分页查询 + 新增 + 修改 + 删除 + 详情查询
2. **创建目录** `src/views/{module}/comp/`
3. **复制 treeWrapper.vue**:直接复用
4. **创建 tree.vue**:替换 `menuNode` API 为你的树数据接口
5. **创建 config.ts**:替换 `{模块中文名}`、`{module}`、表格列、搜索字段、表单字段、操作按钮、API 导入
6. **创建 drawer.vue**:替换 API 导入
7. **创建 drawerAuth.vue**:按需,替换授权 API
8. **创建 index.vue**:替换 API 导入和权限标识
9. **配置路由**:添加新页面路由
---
## 七、TODO 替换清单
| 搜索 | 替换为 |
|------|-------|
| `{module}` | 模块标识(如 `user`) |
| `{模块中文名}` | 中文名称(如 `用户`) |
| `#/api/fileMessage` | API 文件路径 |
| `filePage` / `addfile` / `updateFile` / `removeFile` / `idByFile` | 你的 CRUD 方法 |
| `menuNode` | 你的树数据 API |
| `getRolePage` / `fileAuth` | 你的角色/授权 API |
| `@ums:role:` | 替换 `role` 为模块权限前缀 |
---
> 所有模板使用 `{module}` 作为模块标识占位符,`{模块中文名}` 作为中文名占位符。
> 生成新页面时,按 TODO 注释替换即可。
---
## 1. tree.vue(BussinessTree 树组件)
```vue
```
---
## 2. treeWrapper.vue(布局容器,直接复用)
```vue
```
---
## 3. index.vue(主页面)
```vue
删除
```
---
## 4. config.ts(配置中心)
```typescript
import { cloneDeep } from '@vben/utils';
import { session } from "@vben/utils";
// TODO: 替换为你的分页查询 API
import { filePage } from '#/api/fileMessage';
let globParams: Record = {};
export const setGlobParams = (params: any) => {
globParams = { ...globParams, ...params };
};
export const tableConfig = {
options: {
tableTitle: '{模块中文名}列表',
gridOptions: {
checkboxConfig: { reserve: true },
columns: [
// TODO: 替换为你的表格列
{ field: 'name', minWidth: 200, title: '名称' },
{ field: 'ext', width: 80, title: '类型' },
{ field: 'createTime', width: 160, title: '创建时间' },
{ field: 'isEnable', title: '状态', width: 90, slots: { default: "isEnable" }, sortable: false },
],
proxyConfig: {
ajax: {
query: async (params: any, formValues: any) => {
// TODO: 替换为你的分页 API
const res: any = await filePage({
...params.page,
...formValues,
...globParams,
});
if (res.code !== 1) {
return { items: [], total: 0 };
}
return {
items: cloneDeep(res.result.records),
total: +res.result.total || 0,
};
},
},
},
},
formOptions: {
commonConfig: { labelWidth: 60 },
showCollapseButton: false,
collapsed: false,
schema: [
// TODO: 替换为你的搜索字段
{
component: 'Input',
fieldName: 'keyword',
label: '关键字',
componentProps: { clearable: true, placeholder: '请输入关键字' },
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-4',
submitButtonOptions: { auth: '@ums:{module}:search' },
resetButtonOptions: { auth: '@ums:{module}:reset' },
},
},
formObj: {
operateOptions: [
// TODO: 替换为你的操作按钮
{ code: 'edit', text: '编辑', type: 'primary', directives: [{ name: 'auth', value: '@ums:{module}:edit' }] },
{ code: 'delete', text: '删除', type: 'danger', directives: [{ name: 'auth', value: '@ums:{module}:delete' }] },
{ code: 'menuAuth', text: '权限配置', type: 'primary', directives: [{ name: 'auth', value: '@ums:{module}:auth' }] },
],
},
isView: false,
showOperate: true,
showSeq: true,
buttonAuth: { addBtn: '@ums:{module}:add' },
};
export const drawerConfig = {
formConfig: {
wrapperClass: 'grid-cols-1',
layout: 'horizontal',
schemaConfig: [
{
schema: [
{
formItemClass: 'form-item-col3 hidden',
fieldName: 'id',
label: '',
dependencies: { show: false },
},
// TODO: 替换为你的表单字段
{
formItemClass: 'form-item-col3',
component: 'Input',
componentProps: { placeholder: '请输入名称', clearable: true },
fieldName: 'name',
label: '名称',
rules: 'required',
},
{
component: 'ApiTreeSelect',
componentProps: {
labelField: 'name',
valueField: 'id',
childrenField: 'children',
api: () => session.getItem("menuNode"),
multiple: true,
},
fieldName: 'menus',
label: '上级菜单',
rules: 'required',
},
{
formItemClass: 'form-item-col3',
component: 'Input',
componentProps: { placeholder: '请输入排序', clearable: true },
fieldName: 'seq',
label: '排序',
rules: 'required',
},
{
formItemClass: 'form-item-col3',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
},
defaultValue: true,
fieldName: 'isEnable',
label: '是否启用',
rules: 'required',
},
{
formItemClass: 'form-item-col3',
labelAlign: 'top',
component: 'Input',
componentProps: {
rows: 4, maxLength: 500,
placeholder: '请输入描述', type: 'textarea',
},
fieldName: 'description',
label: '描述',
},
],
},
],
},
tableConfig: { show: false },
mapConfig: { show: false },
layoutConfig: { column: 1 },
};
// 权限树公共配置
const treeConfig = {
searchOptions: { show: true, placeholder: '搜索' },
treeOptions: {
options: {
expandOnClickNode: false,
data: [] as any,
nodeKey: 'id',
checkStrictly: false,
},
},
};
export const authTreeOptions = {
title: '未授权角色',
...treeConfig,
treeOptions: {
...treeConfig.treeOptions,
options: {
...treeConfig.treeOptions.options,
showCheckbox: true,
props: { children: 'children', label: 'name', value: 'id' },
},
},
};
export const authSelectTreeOptions = {
title: '已授权角色',
...treeConfig,
treeOptions: {
...treeConfig.treeOptions,
options: {
...treeConfig.treeOptions.options,
showCheckbox: true,
props: { children: 'children', label: 'name', value: 'id' },
},
},
};
```
---
## 5. drawer.vue(新增/编辑抽屉)
```vue
```
---
## 6. drawerAuth.vue(角色授权抽屉,可选)
```vue
```