前言
* 由于ruoyi自带的字典功能只能实现下拉字典,而在实际开发中会出现需要级联级联选项的情况,如下图,打码见谅,都是公司的数据。
- 版本:ruoyi-vue 前后端分离版,v3.8.7
-
而这样选项虽然相对固定的,但单独设计和写一个表单来管理会导致表很多,因为不确定以后还会不会再增加另一个级联数据,所以还是需要字典来管理,在网上找了一遍都没找到参考,就自己写吧。
-
本次实现重点在前端,后端部分仅需修改数据库和控制层,实体类和mapper.xml
java后端
数据库
- 数据库sys_dict_data表中添加一个parent_id,用于记录父节点的dict_code,默认值为0,即代表没有父节点,也就是原本ruoyi下拉的效果。记得修改对应实体类和mapper.xml,这里不再赘述。
添加接口获取数据
- SysDictDataController 控制层添加该接口,该接口其实就是将原本用于分页的接口去掉startPage();得来,因为树形表单不需要分页。
-
/** * 获取字典列表(不分页) */ @PreAuthorize("@ss.hasPermi('system:dict:list')") @GetMapping("/dataList") public AjaxResult dataList(SysDictData dictData) { List<SysDictData> list = dictDataService.selectDictDataList(dictData); return success(list); }
修改add接口
-
增加一个判断逻辑,就是当节点选中自身作为父节点时报错。
/** * 修改保存字典类型 */ @PreAuthorize("@ss.hasPermi('system:dict:edit')") @Log(title = "字典数据", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody SysDictData dict) { if (dict.getDictCode().equals(dict.getParentId())) { return error("修改字典'" + dict.getDictLabel() + "'失败,上级字典不能选择自己"); } dict.setUpdateBy(getUsername()); return toAjax(dictDataService.updateDictData(dict)); }
至此,后端修改完成。
前端
字典数据详情页
- 将原来的普通表单效果改为树形表单,我参考的menu和dept两个树形树形表单的效果来实现的,创建了一个新的vue起名dataTree.vue,将原本data.vue中的代码复制过来在此基础做的改动,将原vue文件作为保留(个人习惯,记得修改路由),如果不需要保留也可直接在原文件上修改。
dataTree.vue代码
-
如下,能看懂就看吧,看不懂直接全粘贴就行
-
<template> <div class="app-container"> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="字典名称" prop="dictType"> <el-select v-model="queryParams.dictType"> <el-option v-for="item in typeOptions" :key="item.dictId" :label="item.dictName" :value="item.dictType" /> </el-select> </el-form-item> <el-form-item label="字典标签" prop="dictLabel"> <el-input v-model="queryParams.dictLabel" placeholder="请输入字典标签" clearable @keyup.enter.native="handleQuery" /> </el-form-item> <el-form-item label="状态" prop="status"> <el-select v-model="queryParams.status" placeholder="数据状态" clearable> <el-option v-for="dict in dict.type.sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button> <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <el-row :gutter="10" class="mb8"> <el-col :span="1.5"> <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['system:dict:add']" >新增 </el-button> </el-col> <el-col :span="1.5"> <el-button type="info" plain icon="el-icon-sort" size="mini" @click="toggleExpandAll" >展开/折叠 </el-button> </el-col> <el-col :span="1.5"> <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['system:dict:export']" >导出 </el-button> </el-col> <el-col :span="1.5"> <el-button type="warning" plain icon="el-icon-close" size="mini" @click="handleClose" >关闭 </el-button> </el-col> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> </el-row> <el-table v-if="refreshTable" v-loading="loading" :data="dataList" row-key="dictCode" :default-expand-all="isExpandAll" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" > <el-table-column label="字典标签" width="200" prop="dictLabel"> <template slot-scope="scope"> <span v-if="(scope.row.listClass == '' || scope.row.listClass == 'default') && (scope.row.cssClass == '' || scope.row.cssClass == null)" >{{ scope.row.dictLabel }}</span> <el-tag v-else :type="scope.row.listClass == 'primary' ? '' : scope.row.listClass" :class="scope.row.cssClass" >{{ scope.row.dictLabel }} </el-tag> </template> </el-table-column> <el-table-column label="字典编码" align="center" prop="dictCode"/> <el-table-column label="字典键值" align="center" prop="dictValue"/> <el-table-column label="字典排序" align="center" prop="dictSort"/> <el-table-column label="状态" align="center" prop="status"> <template slot-scope="scope"> <dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/> </template> </el-table-column> <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true"/> <el-table-column label="创建时间" align="center" prop="createTime" width="180"> <template slot-scope="scope"> <span>{{ parseTime(scope.row.createTime) }}</span> </template> </el-table-column> <el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width"> <template slot-scope="scope"> <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:dict:edit']" >修改 </el-button> <el-button size="mini" type="text" icon="el-icon-plus" @click="handleAdd(scope.row)" v-hasPermi="['system:dict:add']" >新增 </el-button> <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['system:dict:remove']" >删除 </el-button> </template> </el-table-column> </el-table> <!-- 添加或修改参数配置对话框 --> <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body> <el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form-item label="字典类型"> <el-input v-model="form.dictType" :disabled="true"/> </el-form-item> <el-form-item label="上级字典" prop="parentId"> <treeselect v-model="form.parentId" :options="dictOptions" :normalizer="normalizer" :show-count="true" placeholder="选择上级字典" /> </el-form-item> <el-form-item label="数据标签" prop="dictLabel"> <el-input v-model="form.dictLabel" placeholder="请输入数据标签"/> </el-form-item> <el-form-item label="数据键值" prop="dictValue"> <el-input v-model="form.dictValue" placeholder="请输入数据键值"/> </el-form-item> <el-form-item label="样式属性" prop="cssClass"> <el-input v-model="form.cssClass" placeholder="请输入样式属性"/> </el-form-item> <el-form-item label="显示排序" prop="dictSort"> <el-input-number v-model="form.dictSort" controls-position="right" :min="0"/> </el-form-item> <el-form-item label="回显样式" prop="listClass"> <el-select v-model="form.listClass"> <el-option v-for="item in listClassOptions" :key="item.value" :label="item.label + '(' + item.value + ')'" :value="item.value" ></el-option> </el-select> </el-form-item> <el-form-item label="状态" prop="status"> <el-radio-group v-model="form.status"> <el-radio v-for="dict in dict.type.sys_normal_disable" :key="dict.value" :label="dict.value" >{{ dict.label }} </el-radio> </el-radio-group> </el-form-item> <el-form-item label="备注" prop="remark"> <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input> </el-form-item> </el-form> <div slot="footer" class="dialog-footer"> <el-button type="primary" @click="submitForm">确 定</el-button> <el-button @click="cancel">取 消</el-button> </div> </el-dialog> </div> </template> <script> import { dataList, getData, delData, addData, updateData } from '@/api/system/dict/data' import { optionselect as getDictOptionselect, getType } from '@/api/system/dict/type' import '@riophae/vue-treeselect/dist/vue-treeselect.css' import Treeselect from '@riophae/vue-treeselect' export default { name: 'Data', dicts: ['sys_normal_disable'], components: { Treeselect }, data() { return { // 遮罩层 loading: true, // 选中数组 ids: [], // 非单个禁用 single: true, // 显示搜索条件 showSearch: true, // 字典表格数据 dataList: [], // 字典树选项 dictOptions: [], // 默认字典类型 defaultDictType: '', // 弹出层标题 title: '', // 是否显示弹出层 open: false, // 是否展开,默认全部折叠 isExpandAll: false, // 重新渲染表格状态 refreshTable: true, // 数据标签回显样式 listClassOptions: [ { value: 'default', label: '默认' }, { value: 'primary', label: '主要' }, { value: 'success', label: '成功' }, { value: 'info', label: '信息' }, { value: 'warning', label: '警告' }, { value: 'danger', label: '危险' } ], // 类型数据字典 typeOptions: [], // 查询参数 queryParams: { dictType: undefined, dictLabel: undefined, status: undefined }, // 表单参数 form: {}, // 表单校验 rules: { dictLabel: [ { required: true, message: '数据标签不能为空', trigger: 'blur' } ], dictValue: [ { required: true, message: '数据键值不能为空', trigger: 'blur' } ], dictSort: [ { required: true, message: '数据顺序不能为空', trigger: 'blur' } ] } } }, created() { const dictId = this.$route.params && this.$route.params.dictId this.getType(dictId) this.getTypeList() }, methods: { /** 查询字典类型详细 */ getType(dictId) { getType(dictId).then(response => { this.queryParams.dictType = response.data.dictType this.defaultDictType = response.data.dictType this.getList() }) }, /** 查询字典类型列表 */ getTypeList() { getDictOptionselect().then(response => { this.typeOptions = response.data }) }, /** 查询字典数据列表 */ getList() { this.loading = true dataList(this.queryParams).then(response => { this.dataList = this.handleTree(response.data, 'dictCode') this.loading = false }) }, /** 转换字典数据结构 */ normalizer(node) { if (node.children && !node.children.length) { delete node.children } return { id: node.dictCode, label: node.dictLabel, children: node.children } }, /** 查询菜单下拉树结构 */ getTreeselect() { dataList(this.queryParams).then(response => { this.dictOptions = [] const dict = { dictCode: 0, dictLabel: '主类目', children: [] } dict.children = this.handleTree(response.data, 'dictCode') this.dictOptions.push(dict) }) }, // 取消按钮 cancel() { this.open = false this.reset() }, // 表单重置 reset() { this.form = { dictCode: undefined, parentId: undefined, dictLabel: undefined, dictValue: undefined, cssClass: undefined, listClass: 'default', dictSort: 0, status: '0', remark: undefined } this.resetForm('form') }, /** 搜索按钮操作 */ handleQuery() { this.queryParams.pageNum = 1 this.getList() }, /** 返回按钮操作 */ handleClose() { const obj = { path: '/system/dict' } this.$tab.closeOpenPage(obj) }, /** 重置按钮操作 */ resetQuery() { this.resetForm('queryForm') this.queryParams.dictType = this.defaultDictType this.handleQuery() }, /** 新增按钮操作 */ handleAdd(row) { this.reset() this.getTreeselect() if (row != undefined && row.dictCode) { this.form.parentId = row.dictCode } else { this.form.parentId = 0 } this.open = true this.title = '添加字典数据' this.form.dictType = this.queryParams.dictType }, /** 展开/折叠操作 */ toggleExpandAll() { this.refreshTable = false this.isExpandAll = !this.isExpandAll this.$nextTick(() => { this.refreshTable = true }) }, /** 修改按钮操作 */ handleUpdate(row) { this.reset() this.getTreeselect() getData(row.dictCode).then(response => { this.form = response.data this.open = true this.title = '修改字典数据' }) }, /** 提交按钮 */ submitForm: function() { this.$refs['form'].validate(valid => { if (valid) { if (this.form.dictCode != undefined) { updateData(this.form).then(response => { this.$store.dispatch('dict/removeDict', this.queryParams.dictType) this.$modal.msgSuccess('修改成功') this.open = false this.getList() }) } else { addData(this.form).then(response => { this.$store.dispatch('dict/removeDict', this.queryParams.dictType) this.$modal.msgSuccess('新增成功') this.open = false this.getList() }) } } }) }, /** 删除按钮操作 */ handleDelete(row) { const dictCodes = row.dictCode || this.ids this.$modal.confirm('是否确认删除字典编码为"' + dictCodes + '"的数据项?').then(function() { return delData(dictCodes) }).then(() => { this.getList() this.$modal.msgSuccess('删除成功') this.$store.dispatch('dict/removeDict', this.queryParams.dictType) }).catch(() => { }) }, /** 导出按钮操作 */ handleExport() { this.download('system/dict/data/export', { ...this.queryParams }, `data_${new Date().getTime()}.xlsx`) } } } </script>
路由修改
- 若添加了dataTree.vue文件,则需要修改路由文件,路由文件地址,src>router>index.js,131行左右
-
{ path: '/system/dict-data', component: Layout, hidden: true, permissions: ['system:dict:list'], children: [ { path: 'index/:dictId(\\d+)', // component: () => import('@/views/system/dict/data'), component: () => import('@/views/system/dict/dataTree'), name: 'Data', meta: { title: '字典数据', activeMenu: '/system/dict' } } ] },
- 将原路由到data的代码,修改为路由到dataTree
字典对应api文件
-
新增一个方法来对应后端控制层接收不分页的数据,也就是对应我们后端新加的那个查询接口。
-
// 查询字典数据列表(不分页) export function dataList(query) { return request({ url: '/system/dict/data/dataList', method: 'get', params: query }) }
字典数据页效果图
-
增删改查都没问题。
- 添加时选择主类目即表示没有上级,就能实现原本的单级下拉效果
使用前提
- 至此还不能实际调用该字典来实现级联选择,还需要进一步修改相关js代码。
-
进入所需使用的页面打印字典数据我们可以看到,字典只包含label、value、和raw,raw就是该字典的全部信息,并没有children,所以我们需要再修改相关逻辑。
src > components > DictData > index.vue
- 进入该文件
-
import Vue from 'vue' import store from '@/store' import DataDict from '@/utils/dict' import { getDicts as getDicts } from '@/api/system/dict/data' import { handleTree } from '@/utils/ruoyi' function searchDictByKey(dict, key) { if (key == null && key == '') { return null } try { for (let i = 0; i < dict.length; i++) { if (dict[i].key == key) { return dict[i].value } } } catch (e) { return null } } function install() { Vue.use(DataDict, { metas: { '*': { labelField: 'dictLabel', valueField: 'dictValue', childrenField: 'children', request(dictMeta) { const storeDict = searchDictByKey(store.getters.dict, dictMeta.type) if (storeDict) { return new Promise(resolve => { resolve(storeDict) }) } else { return new Promise((resolve, reject) => { getDicts(dictMeta.type).then(res => { const dictData = handleTree(res.data, 'dictCode', null, null) store.dispatch('dict/setDict', { key: dictMeta.type, value: dictData }) resolve(dictData) }).catch(error => { reject(error) }) }) } } } } }) } export default { install }
- 主要修改如上图,该位置是整个系统字典调用的入口。我这里主要是调用ruoyi自带的handleTree方法将数据变为树形后再进行后续的进一步处理。
相关js文件
注意:下面的js文件都不会整个粘贴js代码,仅粘贴了改动部分。
-
需要修改如下图路径中的几个js文件
DictMeta.js
-
/** * @classdesc 字典元数据 * @property {String} type 类型 * @property {Function} request 请求 * @property {String} label 标签字段 * @property {Object} children 子标签字段 * @property {String} value 值字段 */ export default class DictMeta { constructor(options) { this.type = options.type this.request = options.request this.responseConverter = options.responseConverter this.labelField = options.labelField this.valueField = options.valueField this.childrenField = options.childrenField // 添加children变量 this.lazy = options.lazy === true } }
- 此步骤修改点如图,添加了框中的两行代码
DictOptions.js
-
export const options = { metas: { '*': { /** * 字典请求,方法签名为function(dictMeta: DictMeta): Promise */ request: (dictMeta) => { console.log(`load dict ${dictMeta.type}`) return Promise.resolve([]) }, /** * 字典响应数据转换器,方法签名为function(response: Object, dictMeta: DictMeta): DictData */ responseConverter, labelField: 'label', valueField: 'value', childrenField: 'children' } }, /** * 默认标签字段 */ DEFAULT_LABEL_FIELDS: ['label', 'name', 'title'], /** * 默认值字段 */ DEFAULT_VALUE_FIELDS: ['value', 'id', 'uid', 'key'], /** * 默认值字段 */ DEFAULT_CHILDREN_FIELDS: ['children', 'childr', 'child'] }
- 改动点,添加代码。
DictConverter.js
- 这里是重点,使用递归,以保证每个children都有label,value,raw和children结构。不使用递归的话,就只有第一级的字典数据有上面四个属性,而下面的层级没有。
export default function dictConverter(dict, dictMeta) {
const label = determineDictField(dict, dictMeta.labelField, ...DictOptions.DEFAULT_LABEL_FIELDS)
const value = determineDictField(dict, dictMeta.valueField, ...DictOptions.DEFAULT_VALUE_FIELDS)
const children = determineDictField(dict, dictMeta.childrenField, ...DictOptions.DEFAULT_CHILDREN_FIELDS)
// 递归处理子字典
const childDicts = dict[children]
let childDictData = []
if (childDicts && childDicts.length > 0) {
childDictData = childDicts.map(childDict => dictConverter(childDict, dictMeta))
}
return new DictData(dict[label], dict[value], dict, childDictData)
// return new DictData(dict[label], dict[value], dict, dict[children])
}
- 改动点,为默认方法添加方法名方便递归调用。
DictData.js
-
/** * @classdesc 字典数据 * @property {String} label 标签 * @property {*} value 标签 * @property {Object} raw 原始数据 * @property {Object} children 子标签 */ export default class DictData { constructor(label, value, raw, children) { this.label = label this.value = value this.raw = raw this.children = children.length > 0 ? children : undefined } }
- 改动点
- 其中三元判断作用是将没有children的节点的children值置为undefined,否则前端调用级联选择器时,就算children的length为0 ,还是会在该节点出现箭头,而打开则是空数据页。
js修改完成
实际组件调用
-
fileTypProps: { children: 'children',//必须且一致 label: 'label',//必须且一致 value: 'value',//必须且一致 emitPath: false,//只取叶子节点值 },
- 到这里就能正常调用字典的值了,props中除了前三个为必须项外,剩下的可根据实际业务添加,如多选、含节点、不含节点等属性。
- 根据value回显label我还没实现,要过年回家没啥时间搞定了,就这样吧,有大佬回显也搞定了@我一下,我去cv一下 (~o ̄3 ̄)~(~o ̄3 ̄)~(~o ̄3 ̄)~。
后话
- 这样实现不会影响原本相关页面调用的字典,因为其他页面的字典值的children为空,而且也并未主动调用children,只有像上面的级联选择器一样主动指出并调用children才会生效。
- 有能力的可以修改ruoyi的代码生成模板使得选中该字典可创建出基础的含级联选择器的表单前端,我只是个刚毕业半年的后端菜鸡,前端一知半解的,只会照葫芦画瓢,还没时间研究代码生成器原理(乐.jpg)。
- 有什么问题都可以在评论区沟通交流。