目录
- [1. 项目准备](#1. 项目准备)
-
- [1.1. 项目介绍](#1.1. 项目介绍)
- [1.2. 项目搭建](#1.2. 项目搭建)
- [2. 点位管理](#2. 点位管理)
-
- [2.1. 库表设计、生成](#2.1. 库表设计、生成)
- [2.2. 代码生成](#2.2. 代码生成)
- [2.3. 代码改造](#2.3. 代码改造)
-
- [2.3.1. 区域管理](#2.3.1. 区域管理)
- [2.3.2. 合作商管理](#2.3.2. 合作商管理)
- [2.3.3. 点位管理](#2.3.3. 点位管理)
- [2.3.4. 数据完整性](#2.3.4. 数据完整性)
- [3. 人员管理](#3. 人员管理)
-
- [3.1. 码生成](#3.1. 码生成)
- [3.2. 代码改造](#3.2. 代码改造)
-
- [3.2.1. 人员列表改造](#3.2.1. 人员列表改造)
- [3.2.2. 阿里OSS模块](#3.2.2. 阿里OSS模块)
- [3.2.3. x-file-storage](#3.2.3. x-file-storage)
- [4. 设备模块](#4. 设备模块)
-
- [4.1. 代码生成](#4.1. 代码生成)
- [4.2. 代码改造](#4.2. 代码改造)
-
- [4.2.1. 设备管理改造](#4.2.1. 设备管理改造)
- [4.2.2. 设备状态改造](#4.2.2. 设备状态改造)
- [4.2.3. 点位查看详情](#4.2.3. 点位查看详情)
- [5. 策略模块](#5. 策略模块)
-
- [5.1. 代码生成](#5.1. 代码生成)
- [5.2. 代码改造](#5.2. 代码改造)
-
- [5.2.1. 策略管理改造](#5.2.1. 策略管理改造)
- [5.3.2. 设备策略分配](#5.3.2. 设备策略分配)
- [6. 商品模块](#6. 商品模块)
-
- [6.1. 代码生成](#6.1. 代码生成)
- [6.2. 代码改造](#6.2. 代码改造)
-
- [6.2.1. 商品管理改造](#6.2.1. 商品管理改造)
- [6.2.2. 批量导入(EasyExcel)](#6.2.2. 批量导入(EasyExcel))
- [6.2.3. 货道关联商品](#6.2.3. 货道关联商品)
1. 项目准备
1.1. 项目介绍
帝可得是一个基于物联网概念下的智能售货机运营管理系统

-
物联网(IoT)
让各种物品通过互联网连接起来,实现信息的交换和通信。
-
智能家居
-
共享充电桩
-
智能售货机
-
-
智能售货机
是物联网技术的一个典型应用。
- 物联网技术
- 智能分析与推荐
- 人员设备绑定管理
- 线上线下融合
-
一个完整的售货机系统由五端组成
-
管理员:对基础数据(区域、点位、设备、货道、商品等)进行管理,创建工单、查看订单、统计报表等。
-
运维人员:投放设备、撤除设备、维修设备。
-
运营人员:补货。
-
合作商:仅提供点位,坐收渔翁之利。
-
消费者: 在小程序或屏幕端下单购买商品。

-
-
系统后台基础数据表关系说明

AI(Artificial Intelligence):即人工智能,是指通过计算机系统模拟人类思维和行为一种技术,它通过机器学习、深度学习等算法,使计算机具备对数据分析、理解、推理和决策的能力。
-
Prompt
提示(Prompt)是我们对大模型提出的问题,下面是Prompt的组成
-
角色:给 AI 定义一个最匹配任务的角色,比如:「你是一位专业的博客作者」
-
指示:对任务进行描述,比如:「撰写一篇关于最新AI技术发展的文章」
-
上下文:给出与任务相关的其它背景信息
-
例子:必要时给出举例,[实践证明其对输出正确性有帮助]
-
输入:任务的输入信息;在提示词中明确的标识出输入
-
输出:输出的格式描述,以便后继模块自动解析模型的输出结果,比如(JSON、Java)
例如
-
角色:你是一位专业的博客作者。
-
指示:撰写一篇关于最新AI技术发展的文章。
-
上下文:文章应该涵盖AI技术的当前状态和未来趋势。
-
例子:可以引用最近的AI技术突破和行业专家的见解。
-
输入:当前AI技术的相关信息和数据。
-
输出:一篇结构清晰、观点鲜明的文章草稿。
-
-
1.2. 项目搭建
-
前端项目
-
前端仓库地址 :
git clone https://gitee.com/yudian1991/dkd-vue.git这里记得前端的请求端口要在
vite.config.js中和后端改得一样
-
-
后端项目
-
克隆后端仓库代码
git clone https://gitee.com/yudian1991/dkd-parent.git -
链接上自己仓库
如图执行了3-6,会删除原来git仓库的提交记录。
操作 命令 1. 直接删除原有远程仓库 git remote remove origin 2. 添加 Gitee 远程仓库关联 git remote add origin https://gitee.com/你的用户名/你的仓库名.git 3.创建无父提交的新分支 git checkout --orphan new_branc 4.暂存文件 git add . 5.删除原主分支(master) git branch -D master 6.将新分支重命名为 master git branch -m master 7. 提交版本 git commit -m "初始化仓库" 8.推送到 Gitee git push --set-upstream origin master -
Mysql配置
在admin模块的
application.yml文件,修改数据库url,用户、密码、端口号 -
Redis配置
在admin模块的
application-druid.yml文件,修改密码、端口号 -
启动项目
++记得将项目jdk版本换到17++
-
-
前后端都启动好,登录后如图

2. 点位管理
2.1. 库表设计、生成
-
业务场景
公司计划在北京的高流量商业和居民区与潜在合作商洽谈,确定点位部署智能售货机,提供便捷的购买服务。点位管理主要涉及到三个功能模块,业务流程如下:

-
表设计
-
区域表设计
包括:主键id、区域名称、备注说明(点位数在点位表中存储)

-
合作商表
包括:主键id、合作商名称、账号、密码、分成比例、联系人、联系电话(点位数在点位表中尺寸存储)

-
点位表
包括:主键id、点位名称、商圈类型、详细地址、
区域外键、合作商外键,其中++商圈类型++ 和++详细地址++是静态数据,我们可以通过数据字典,在点位表中只存储对应数字编号
-
-
BaseEntity基类
若依里面有BaseEntity基类,我们可以通过继承这个基类来保证公共字段的命名的一致性,而且区域表的备注,也可以直接使用基类中的
remark字段
-
AI提示词
你是一位软件工程师,帮我生成MySQL的表结构 需求如下: 1,区域表,表名tb_region,字段有主键id、区域名称 2,合作商表,表名tb_partner,字段有主键id、合作商名称、联系人、联系电话、分成比例(int类型)、账号、密码 3,点位表,表名tb_node,字段有主键id、点位名称、详细地址、商圈类型(int类型) 其他要求: 1,每张表中都有创建时间(create_time)、修改时间(date_time)、创建人(create_by)、修改人(update_by)、备注(remark)这些字段 2,每张表的主键都是自增的 3,区域与点位是一对多的关系,合作商与点位是一对多的关系,请用字段表示出来,并建立外键约束 4,请为所有字段都添加上comment 5,帮我给生成的表中插入一些北京城市相关区域、点位、合作商的测试数据-
表生成拆解
主要描述表名和sql表名、字段(部分字段标注类型)
-
其他要求
- 公共字段填充(字段名对应若依BaseEntity基类)
- 主键描述
- 表关系描述
- 字段说明生成
- 测试数据生成
-
-
下面是我用ai生成的建表语句
-
区域表
sql-- 区域表(tb_region):存储北京各区域信息 CREATE TABLE tb_region ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '区域主键ID(自增)', region_name VARCHAR(50) NOT NULL COMMENT '区域名称(如:朝阳区、海淀区)', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(默认当前时间)', date_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间(默认当前时间,更新时自动刷新)', create_by VARCHAR(30) DEFAULT 'system' COMMENT '创建人(默认系统)', update_by VARCHAR(30) DEFAULT 'system' COMMENT '修改人(默认系统)', remark VARCHAR(255) DEFAULT '' COMMENT '备注信息' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='区域表(存储城市区域信息)'; -
合作商表
sql-- 合作商表(tb_partner):存储合作商基础信息 CREATE TABLE tb_partner ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '合作商主键ID(自增)', partner_name VARCHAR(100) NOT NULL COMMENT '合作商名称', contact_person VARCHAR(30) NOT NULL COMMENT '联系人', contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话(支持固话/手机号)', profit_share INT NOT NULL COMMENT '分成比例(整数,如:10表示10%)', account VARCHAR(50) NOT NULL COMMENT '登录账号', password VARCHAR(60) NOT NULL COMMENT '登录密码(建议存储加密后的值)', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(默认当前时间)', date_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间(默认当前时间,更新时自动刷新)', create_by VARCHAR(30) DEFAULT 'system' COMMENT '创建人(默认系统)', update_by VARCHAR(30) DEFAULT 'system' COMMENT '修改人(默认系统)', remark VARCHAR(255) DEFAULT '' COMMENT '备注信息' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合作商表(存储合作商基础信息)'; -
点位表
sql-- 点位表(tb_node):存储北京各点位信息,关联区域和合作商(一对多关系) CREATE TABLE tb_node ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '点位主键ID(自增)', node_name VARCHAR(100) NOT NULL COMMENT '点位名称', detailed_address VARCHAR(255) NOT NULL COMMENT '详细地址', business_circle_type INT NOT NULL COMMENT '商圈类型(1:核心商圈;2:社区商圈;3:写字楼商圈;4:交通枢纽商圈;5:文旅商圈)', region_id INT NOT NULL COMMENT '关联区域表主键(区域-点位:一对多)', partner_id INT NOT NULL COMMENT '关联合作商表主键(合作商-点位:一对多)', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(默认当前时间)', date_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间(默认当前时间,更新时自动刷新)', create_by VARCHAR(30) DEFAULT 'system' COMMENT '创建人(默认系统)', update_by VARCHAR(30) DEFAULT 'system' COMMENT '修改人(默认系统)', remark VARCHAR(255) DEFAULT '' COMMENT '备注信息', -- 外键约束:关联区域表 CONSTRAINT fk_node_region FOREIGN KEY (region_id) REFERENCES tb_region (id) ON DELETE RESTRICT ON UPDATE CASCADE, -- 外键约束:关联合作商表 CONSTRAINT fk_node_partner FOREIGN KEY (partner_id) REFERENCES tb_partner (id) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点位表(存储具体业务点位信息)'; -
测试数据插入
sql-- 插入北京区域测试数据(覆盖核心城区+部分郊区) INSERT INTO tb_region (region_name, remark) VALUES ('朝阳区', '北京核心城区,商业发达'), ('海淀区', '高校集中,科技产业聚集'), ('东城区', '老城区,历史文化底蕴深厚'), ('西城区', '金融核心区,政务机构集中'), ('丰台区', '城南核心,交通枢纽集中'), ('石景山区', '生态宜居,产业转型中'), ('通州区', '北京城市副中心'), ('顺义区', '临空经济核心区'), ('昌平区', '科教新区,温泉资源丰富'), ('大兴区', '新机场所在地,产业新城'); -- 插入北京合作商测试数据(模拟不同类型合作商) INSERT INTO tb_partner (partner_name, contact_person, contact_phone, profit_share, account, password, remark) VALUES ('北京京联商业管理有限公司', '张三', '13800138001', 15, 'jinglian_admin', 'e10adc3949ba59abbe56e057f20f883e', '专注商业综合体运营'), ('北京中创文旅发展有限公司', '李四', '13900139002', 12, 'zhongchuang_admin', 'e10adc3949ba59abbe56e057f20f883e', '文旅项目合作商'), ('北京恒基物业有限公司', '王五', '13700137003', 10, 'hengji_admin', 'e10adc3949ba59abbe56e057f20f883e', '物业配套服务合作商'), ('北京星耀科技服务有限公司', '赵六', '13600136004', 18, 'xingyao_admin', 'e10adc3949ba59abbe56e057f20f883e', '科技园区运营合作商'), ('北京盛世商管集团', '孙七', '13500135005', 20, 'shengshi_admin', 'e10adc3949ba59abbe56e057f20f883e', '大型商圈运营集团'); -- 注:密码字段存储的是 MD5 加密后的 "123456",实际生产环境建议使用 BCrypt 等更安全的加密方式 -- 插入北京点位测试数据(关联区域和合作商,覆盖不同商圈类型) INSERT INTO tb_node (node_name, detailed_address, business_circle_type, region_id, partner_id, remark) VALUES -- 朝阳区点位(关联区域1,不同合作商) ('国贸中心点位', '北京市朝阳区建国门外大街1号国贸中心B1层', 1, 1, 1, '核心商圈核心点位,人流量大'), ('三里屯太古里点位', '北京市朝阳区三里屯路19号太古里南区', 1, 1, 1, '年轻潮流聚集地'), ('望京SOHO点位', '北京市朝阳区望京街4号SOHO塔1底商', 3, 1, 4, '写字楼集中区域'), -- 海淀区点位(关联区域2) ('中关村创业大街点位', '北京市海淀区中关村大街15号创业大街', 3, 2, 4, '科技创业园区配套'), ('五道口购物中心点位', '北京市海淀区成府路35号', 1, 2, 1, '高校周边核心商圈'), -- 东城区点位(关联区域3) ('王府井步行街点位', '北京市东城区王府井大街200号', 1, 3, 2, '历史文化商圈,旅游人群集中'), ('南锣鼓巷点位', '北京市东城区南锣鼓巷16号', 5, 3, 2, '文旅特色商圈'), -- 西城区点位(关联区域4) ('金融街购物中心点位', '北京市西城区金融大街18号', 1, 4, 1, '高端商务商圈'), ('什刹海点位', '北京市西城区什刹海前海西街17号', 5, 4, 2, '文旅休闲商圈'), -- 通州区点位(关联区域7,城市副中心) ('通州万达点位', '北京市通州区新华西街58号万达广场', 1, 7, 5, '副中心核心商业点位'), ('运河商务区点位', '北京市通州区运河东大街与通胡大街交叉口', 3, 7, 4, '商务办公集中区域'), -- 大兴区点位(关联区域10,新机场) ('大兴机场航站楼点位', '北京市大兴区北京大兴国际机场航站楼3层', 4, 10, 3, '交通枢纽商圈,旅客流量大'), ('天宫院龙湖天街点位', '北京市大兴区新源大街30号', 1, 10, 5, '城南新兴商业商圈');
-
-
生成表的表关系示意图
-
关系字段:region_id、partner_id
-
数据字典 :business_type,我这里是
business_circle_type
-
2.2. 代码生成
使用若依代码生成器,生成区域管理、合作商管理、点位管理前后端基础代码,并导入到项目中
-
创建目录菜单

-
添加数据字典
-
添加数据字典
根据AI给你生成的字段名称来命名字典类型名称,当然你也可以自己去数据库改字典类型名称,只要和数据库相符合就行了

-
添加数据字段
从上面AI生成的点位表建表中可以看出来,数字和商圈的对应关系
sqlbusiness_circle_type INT NOT NULL COMMENT '商圈类型(1:核心商圈;2:社区商圈;3:写字楼商圈;4:交通枢纽商圈;5:文旅商圈)',
-
-
配置代码生成信息
-
区域管理
下面是区域管理的代码生成配置,主要是参考原型图或需求文档来进行配置,数据表配置大家可以自己先参照原型图配置,再和老师的对照。

-
合作商管理
这里的编辑勾选和插入一样,若依默认新增和编辑是一个弹框,所以我们的编辑都要和插入保持一样的勾选,后面我们再进行改造

-
点位管理

-
-
下载代码并导入项目

-
导入菜单sql
sys_menu表中菜单依旧被插入

-
前后端代码导入
-
-
启动项目,功能能正常运行成功

2.3. 代码改造
2.3.1. 区域管理
-
外观修改
前端的改造很简单,这里就只列出来哪些要改,后面的前端呈现改造我就不再复述,也不再修改了

-
功能修改
- 查看详情,需要显示当前区域下所有点位列表(稍后完成)
- 在查询区域列表时,同时显示每个区域的点位数
-
实现方案
-
同步存储
在区域表中有点位数的字段,当点位发生变化时,同步区域表中的点位数。
优点查询效率高,缺点更新麻烦,更新操作中断可能导致数据不一致

-
关联查询
编写关联查询语句,在mapper 层封装。区域和点位表,记录个数都不是很多,所以++我们采用关联查询这种方案++。
优点是数据的实时性和准确性,缺点查询速度较慢
-
-
具体实现
-
Controller层
在
/com/dkd/manage/domain/vo文件夹创建vo类java@Data public class RegionVO extends Region { // 点位名称 private Integer nodeCount; }调用selectRegionVoList方法,并使用将返回改为RegionVO
java@GetMapping("/list") public TableDataInfo list(Region region) { startPage(); List<RegionVO> voList = regionService.selectRegionVoList(region); return getDataTable(voList); } -
Severvice层
-
接口类
java@Override public List<RegionVO> selectRegionVoList(Region region){ return regionMapper.selectRegionVoList(region); } -
实现类
javapublic List<RegionVO> selectRegionVoList(Region region);
-
-
Mapper层
-
Mapper
java// 查询区域列表 public List<RegionVO> selectRegionVoList(Region region); -
查询sql语句的编写
统计node表的sql
sqlselect region_id, count(*) as node_count from node group by region_id;
-
与区域表进行关联查询
sqlselect id, region_name, ifnull(node_count, 0) as node_count, remark from region r left join (select region_id, count(*) as node_count from node group by region_id) n on r.id = n.region_id;
-
xml文件
xml<select id="selectRegionVoList" resultType="com.dkd.manage.domain.vo.RegionVO"> select r.id, r.region_name, ifnull(n.node_count, 0) as node_count, r.remark from region r left join (select region_id, count(*) as node_count from node group by region_id) n on r.id = n.region_id <where> <if test="regionName != null and regionName != ''"> and r.region_name like concat('%', #{regionName}, '%')</if> </where> </select>
-
重启后端,发现返回数据中正常返回nodeCount,则说明后端改造成功

前端就很简单了,在相应的位置加上如下代码即可
html<el-table-column label="点位数" align="center" prop="nodeCount" />效果如图

-
2.3.2. 合作商管理
-
数据库密码加密
在
manage/service/impl/PartnerServiceImpl.java的调用插入数据前,使用springSecurity工具类,对前端传入的密码进行加密(加完密可以自己添加一条数据测试一下)java@Override public int insertPartner(Partner partner) { // 使用springSecurity工具类,对前端传入的密码进行加密 partner.setPassword(SecurityUtils.encryptPassword(partner.getPassword())); partner.setCreateTime(DateUtils.getNowDate()); return partnerMapper.insertPartner(partner); } -
列表查询
-
Controller层
-
vo类
javapublic class PartnerVO extends Partner { // 点位名称 private Integer nodeCount; } -
controller
java@GetMapping("/list") public TableDataInfo list(Partner partner) { startPage(); List<PartnerVO> list = partnerService.selectPartnerVOList(partner); return getDataTable(list); }
-
-
Service层
-
接口类
javaList<PartnerVO> selectPartnerVOList(Partner partner); -
实现类
java@Override public List<PartnerVO> selectPartnerVOList(Partner partner) { return partnerMapper.selectPartnerVOList(partner); }
-
-
Mapper层
-
mapper
javaList<PartnerVO> selectPartnerVOList(Partner partner); -
sql语句
sqlselect p.*, ifnull( n.node_count, 0) as node_count from partner p left join (select partner_id, count(*) as node_count from node group by partner_id) n on p.id = n.partner_id; -
xml
xml<select id="selectPartnerVOList" resultType="com.dkd.manage.domain.vo.PartnerVO"> select p.*, ifnull( n.node_count, 0) as node_count from partner p left join (select partner_id, count(*) as node_count from node group by partner_id) n on p.id = n.partner_id <where> <if test="partnerName != null and partnerName != ''"> and p.partner_name like concat('%', #{partnerName}, '%')</if> </where> </select>
-
-
前端再改一下,就完成了

-
-
重置密码
-
接口文档

-
前端修改
-
src\api\manage\partner.js,添加api请求方法js// 重置合作商密码 export const resetPartnerPwd = (id) => { return request({ url: '/manage/partner/resetPwd/' + id, method: 'put' }) } -
调用api
html<el-button link type="primary" @click="resetPwd(scope.row)" v-hasPermi="['manage:partner:edit']">重置密码</el-button>js// 重置密码 function resetPwd(row) { const _id = row.id; proxy.$modal.confirm(`您是否确认重置合作商"${row.partnerName}"的密码?`).then(function() { return resetPartnerPwd(_id); }).then(() => { proxy.$modal.msgSuccess("重置密码成功"); }).catch(() => {}); }
-
-
后端修改
修改合作商密码其实逻辑和修改合作商信息一样,我们按照接口文档在创建一个修改密码的接口,再调用
partnerService.updatePartner(partner)就可以了。java@PreAuthorize("@ss.hasPermi('manage:partner:edit')") @Log(title = "重置合作商密码", businessType = BusinessType.UPDATE) @PutMapping("resetPwd/{id}") public AjaxResult resetPwd(@PathVariable Long id) { Partner partner = new Partner(); partner.setId(id); partner.setPassword(SecurityUtils.encryptPassword("123456")); return toAjax(partnerService.updatePartner(partner)); }
-
2.3.3. 点位管理
-
区域改造
区域名称应该是下拉框,但是后端代码中是分页查询,我们可以前端请求时将分页调大,就可以避免分页的情况(只适用于数据量比较少的情况)
-
区域搜索改下拉框
html<el-form-item label="区域名称" prop="regionId"> <el-select v-model="queryParams.regionId" placeholder="请选择区域"> <el-option v-for="item in regionList" :key="item.id" :label="item.regionName" :value="item.id" /> </el-select> </el-form-item> -
弹窗区域改下拉框
html<el-form-item label="所属区域" prop="regionId"> <el-select v-model="form.regionId" placeholder="请选择区域"> <el-option v-for="item in regionList" :key="item.id" :label="item.regionName" :value="item.id" ></el-option> </el-select> </el-form-item> -
调用方法
jsimport { listRegion } from "@/api/manage/region"; // ... // 存储返回值 const regionList = ref([]); // 查询所有的条件对 const listRegionOptions = ref({ pageNum: 1, pageSize: 1000 }); const getRegionList = () => { listRegion(listRegionOptions.value).then(response => { regionList.value = response.rows }); }; getRegionList();
-
-
合作商改造
搜索模块的合作商模块不需要,弹框合作商模块和上面步骤基本一样,可以让AI来改。
-
列表显示从id改为名称
html<el-table-column label="区域" align="center" prop="regionId"> <template #default="scope"> {{ getRegionNameById(scope.row.regionId) }} </template> </el-table-column> <!-- ... --> <el-table-column label="合作商" align="center" prop="partnerId"> <template #default="scope"> {{ getPartnerNameById(scope.row.partnerId) }} </template> </el-table-column>js// 根据区域ID获取区域名称 const getRegionNameById = (id) => { if (!id) return ''; const region = regionList.value.find(item => item.id === id); return region ? region.regionName : ''; }; // 根据合作商ID获取合作商名称 const getPartnerNameById = (id) => { if (!id) return ''; const partner = partnerList.value.find(item => item.id === id); return partner ? partner.partnerName : ''; }; -
列表查询
这里其实也可以像我们上面一样,直接用前端已有的数据显示,这里就当我们练习一下嵌套查询了
-
接口文档
我在官方公众号下载的接口文档明显和老师视频中的不一样,具体是返回数据不一样,这个我就以老师的接口文档为准
-
我下载的接口文档

-
老师视频中的接口文档

-
-
功能需求
- 查看详情,需要显示当前点位下所有设备列表(稍后完成)
- 在区域详情中,需要显示每个点位的设备数
- 在点位列表查询中,关联显示区域、合作商等信息
-
实现方案
-
关联查询:对于设备数量的统计,我们需要执行关联查询,在mapper 层封装。
-
关联实体:对于区域和合作商的数据,我们会采用Mybatis提供的嵌套查询功能。
降低了多表查询的sql复杂度,避免笛卡尔积造成的内存溢出的风险

-
-
生成设备表
比较苦逼的是,我的sql表是自己AI生成的,所以设备表sql也不能用老师的,我得自己生成
-
设备表建表语句
sqlCREATE TABLE vending_machine ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '设备主键ID(自增)', inner_code VARCHAR(15) NOT NULL COMMENT '设备编号', channel_max_capacity INT NOT NULL COMMENT '设备容量', node_id INT NOT NULL COMMENT '关联点位表主键(点位-设备:一对多)', addr VARCHAR(100) NOT NULL COMMENT '详细地址', last_supply_time DATETIME DEFAULT NULL COMMENT '上次补货时间', business_type INT NOT NULL COMMENT '商圈类型(与点位表business_circle_type一致)', region_id INT NOT NULL COMMENT '关联区域表主键', partner_id INT NOT NULL COMMENT '关联合作商表主键', vm_type_id INT NOT NULL COMMENT '设备型号ID', vm_status INT NOT NULL COMMENT '设备状态(0:未投放;1:运营;3:撤机)', running_status VARCHAR(100) DEFAULT '' COMMENT '运行状态', longitudes DOUBLE NOT NULL COMMENT '经度', latitude DOUBLE NOT NULL COMMENT '纬度', client_id VARCHAR(50) DEFAULT '' COMMENT '客户端连接ID(做emq认证用)', policy_id BIGINT DEFAULT NULL COMMENT '策略ID', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(默认当前时间)', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间(默认当前时间,更新时自动刷新)', create_by VARCHAR(30) DEFAULT 'system' COMMENT '创建人(默认系统)', update_by VARCHAR(30) DEFAULT 'system' COMMENT '修改人(默认系统)', remark VARCHAR(255) DEFAULT '' COMMENT '备注信息', -- 外键约束:关联点位表 CONSTRAINT fk_vending_machine_node FOREIGN KEY (node_id) REFERENCES node (id) ON DELETE RESTRICT ON UPDATE CASCADE, -- 外键约束:关联区域表 CONSTRAINT fk_vending_machine_region FOREIGN KEY (region_id) REFERENCES region (id) ON DELETE RESTRICT ON UPDATE CASCADE, -- 外键约束:关联合作商表 CONSTRAINT fk_vending_machine_partner FOREIGN KEY (partner_id) REFERENCES partner (id) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表(存储自动售货机设备信息)'; -
设备表测试数据插入语句
sqlINSERT INTO vending_machine ( inner_code, channel_max_capacity, node_id, addr, last_supply_time, business_type, region_id, partner_id, vm_type_id, vm_status, running_status, longitudes, latitude, client_id, policy_id, remark, create_time, update_time, create_by, update_by ) VALUES -- 1. 关联"国贸中心点位"(node_id=14,对应点位表id=14) ('VM-BJ-CY-001', 60, 14, '北京市朝阳区建国门外大街1号国贸中心B1层', '2025-12-15 10:30:00', 1, 1, 1, 1, 1, '正常运行', 116.4628, 39.9134, 'emq-vm-001', 1, '核心商圈高流量设备', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 2. 关联"三里屯太古里点位"(node_id=15,对应点位表id=15) ('VM-BJ-CY-002', 50, 15, '北京市朝阳区三里屯路19号太古里南区', '2025-12-16 14:20:00', 1, 1, 1, 2, 1, '正常运行', 116.4686, 39.9438, 'emq-vm-002', 1, '年轻群体定向设备', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 3. 关联"望京SOHO点位"(node_id=16,对应点位表id=16) ('VM-BJ-CY-003', 40, 16, '北京市朝阳区望京街4号SOHO塔1底商', '2025-12-14 09:15:00', 3, 1, 4, 3, 1, '正常运行', 116.4866, 39.9964, 'emq-vm-003', 2, '写字楼早高峰补货', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 4. 关联"中关村创业大街点位"(node_id=17,对应点位表id=17) ('VM-BJ-HD-001', 45, 17, '北京市海淀区中关村大街15号创业大街', '2025-12-16 11:00:00', 3, 2, 4, 3, 1, '正常运行', 116.3105, 39.9836, 'emq-vm-004', 2, '科技园区配套设备', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 5. 关联"王府井步行街点位"(node_id=19,对应点位表id=19) ('VM-BJ-DC-001', 55, 19, '北京市东城区王府井大街200号', '2025-12-15 16:40:00', 1, 3, 2, 1, 1, '正常运行', 116.4038, 39.9154, 'emq-vm-005', 1, '旅游人群高频设备', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 6. 关联"大兴机场航站楼点位"(node_id=25,对应点位表id=25) ('VM-BJ-DX-001', 70, 25, '北京市大兴区北京大兴国际机场航站楼3层', '2025-12-17 08:00:00', 4, 10, 3, 4, 1, '正常运行', 116.3803, 39.5092, 'emq-vm-006', 3, '交通枢纽大容载设备', DEFAULT, DEFAULT, DEFAULT, DEFAULT), -- 7. 关联"通州万达点位"(node_id=23,对应点位表id=23) ('VM-BJ-TZ-001', 50, 23, '北京市通州区新华西街58号万达广场', '2025-12-16 15:30:00', 1, 7, 5, 2, 1, '正常运行', 116.6564, 39.9042, 'emq-vm-007', 1, '副中心商业点位', DEFAULT, DEFAULT, DEFAULT, DEFAULT);
-
-
代码实现
-
Controller层
-
vo
java@Data public class NodeVO extends Node { // 设备数量 private Integer VmCount; // 区域信息 private Region region; // 合作商信息 private Partner partner; } -
controller
java@GetMapping("/list") public TableDataInfo list(Node node) { startPage(); List<NodeVO> VOlist = nodeService.selectNodeVOList(node); return getDataTable(VOlist); }
-
-
Service层
-
接口类
javapublic List<NodeVO> selectNodeVOList(Node node); -
实现类
java@Override public List<NodeVO> selectNodeVOList(Node node){ return nodeMapper.selectNodeVOList(node); };
-
-
Mapper层
-
sql
sql-- 点位表-设备表-关联查询 SELECT n.id, n.node_name, n.detailed_address, n.business_circle_type, n.region_id, n.partner_id, n.create_time, n.date_time, n.create_by, n.update_by, n.remark, COUNT(vm.id) AS device_count FROM node n LEFT JOIN vending_machine vm ON n.id = vm.node_id GROUP BY n.id; -- 再根据点位表的区域外键region_id查询区域的信息 select * from region where id = 1; -- 再根据合作商外键parter_id外键查询合作商信息 select * from partner where id = 1; -
mapper
javapublic List<NodeVO> selectNodeVOList(Node node); -
xml
association
- property:对应影视的实体类
- javaType:实体类的java类型
- column:嵌套查询的外键字段
- select:需要嵌套的方法的引用路径
java<resultMap type="NodeVO" id="NodeVOResult"> <result property="id" column="id" /> <result property="nodeName" column="node_name" /> <result property="detailedAddress" column="detailed_address" /> <result property="businessCircleType" column="business_circle_type" /> <result property="regionId" column="region_id" /> <result property="partnerId" column="partner_id" /> <result property="createTime" column="create_time" /> <result property="dateTime" column="date_time" /> <result property="createBy" column="create_by" /> <result property="updateBy" column="update_by" /> <result property="remark" column="remark" /> <result property="vmCount" column="vm_count" /> <association property="region" javaType="Region" column="region_id" select="com.dkd.manage.mapper.RegionMapper.selectRegionById" /> <association property="partner" javaType="Partner" column="partner_id" select="com.dkd.manage.mapper.PartnerMapper.selectPartnerById" /> </resultMap> <!-- ... --> <select id="selectNodeVOList" resultMap="NodeVOResult"> SELECT n.id, n.node_name, n.detailed_address, n.business_circle_type, n.region_id, n.partner_id, n.create_time, n.date_time, n.create_by, n.update_by, n.remark, COUNT(vm.id) AS device_count FROM node n LEFT JOIN vending_machine vm ON n.id = vm.node_id GROUP BY n.id; <where> <if test="nodeName != null and nodeName != ''"> and n.node_name like concat('%', #{nodeName}, '%')</if> <if test="regionId != null "> and n.region_id = #{regionId}</if> <if test="partnerId != null "> and n.partner_id = #{partnerId}</if> </where> GROUP BY n.id; </select> -
返回的数据(其中一条)
json{ "createBy": "system", "createTime": "2025-12-16 14:41:07", "updateBy": "system", "updateTime": null, "remark": "年轻潮流聚集地", "id": 15, "nodeName": "三里屯太古里点位", "detailedAddress": "北京市朝阳区三里屯路19号太古里南区", "businessCircleType": 1, "regionId": 1, "partnerId": 1, "dateTime": "2025-12-16T14:41:07.000+08:00", "region": { "createBy": "system", "createTime": "2025-12-16 14:41:07", "updateBy": "system", "updateTime": null, "remark": "北京核心城区,商业发达", "id": 1, "regionName": "朝阳区", "dateTime": "2025-12-16T14:41:07.000+08:00" }, "partner": { "createBy": "system", "createTime": "2025-12-16 14:41:07", "updateBy": "system", "updateTime": null, "remark": "专注商业综合体运营", "id": 1, "partnerName": "北京京联商业管理有限公司", "contactPerson": "张三", "contactPhone": "13800138001", "profitShare": 15, "account": "jinglian_admin", "password": "$2a$10$UYSwQWO6cE9EPK8TchjEhedmQ/4qYFqDywJ1NzP3yQU55H5pMuXF.", "dateTime": "2025-12-17T19:52:19.000+08:00", "nodeCount": null }, "vmCount": null },
-
-
-
2.3.4. 数据完整性
-
在删除区域或合作商数据时,关联的点位数据该如何处理?
再创建点位表时,和区域表、合作商表有外键约束,所以删除删除区域或合作商数据时,对应关联的点位数据也会被删除

-
右击=>修改表,我们可以在外键设置中看到操作规则
-
操作规则
外键操作规则 核心含义(父表删除 / 更新时) 适用场景 NO ACTION(默认) 子表存在关联数据 → 直接报错,阻止父表操作 不允许父表数据被随意删除 / 更新(如订单表关联用户表,用户删除需先删订单) RESTRICT 与 NO ACTION 效果一致:子表有关联数据 → 报错限制操作 严格约束父子表关系(如学生表关联班级表,班级删除需先移除关联学生) SET NULL 自动将子表关联的外键字段设为 NULL 父表数据删除后,子表数据仍需保留(如文章表关联分类表,分类删除后文章分类设为 "未分类") SET DEFAULT 自动将子表关联的外键字段设为默认值 父表数据变更后,子表外键需统一指向默认值(如员工表关联部门表,部门删除后员工默认归属 "行政部") CASCADE 自动同步删除 / 更新子表中所有关联数据 父子表数据强绑定(如订单表关联订单明细表,订单删除则明细同步删除) 我们这里选择
restrict操作约束
-
-
全局异常处理器
修改操作规则后就会报错如图,这实质是后端返回的不满足约束的报错返回,但是用户不一定能看懂,我们需要使用全局异常处理器来优化提示

在
com/dkd/framework/web/exception/GlobalExceptionHandler.java,中添加java// 数据完整性异常 @ExceptionHandler(DataIntegrityViolationException.class) public AjaxResult handleDataIntegrityViolationException(DataIntegrityViolationException e) { log.error(e.getMessage(), e); if (e.getMessage().contains("foreigin")) { return AjaxResult.error("无法删除,有其他数据引用"); } return AjaxResult.error("数据完整性异常"); }效果

3. 人员管理
-
业务流程

-
库表设计
-
关系字段:role_id、region_id
-
数据字典:status(1启用、0停用)
-
冗余字段:region_name、role_code、role_name、status

-
-
sql
这里又需要根据我前面的表来生成对应的sql语句
-
角色表(role)
sql-- 创建role表 CREATE TABLE role ( role_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '角色id', role_code VARCHAR(50) NOT NULL COMMENT '角色编码', role_name VARCHAR(50) NOT NULL COMMENT '角色名称' ) COMMENT '工单角色表'; -- 插入role表测试数据(对应示例) INSERT INTO role (role_id, role_code, role_name) VALUES (1, '1001', '工单管理员'), (2, '1002', '运营员'), (3, '1003', '维修员'); -
员工表(emp)
sql-- 创建emp表 CREATE TABLE emp ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', user_name VARCHAR(50) NOT NULL COMMENT '员工名称', region_id INT COMMENT '所属区域id(外键关联region.id)', region_name VARCHAR(50) COMMENT '区域名称(冗余字段)', role_id INT COMMENT '角色id(外键关联role.role_id)', role_code VARCHAR(10) COMMENT '角色编号(冗余字段)', role_name VARCHAR(50) COMMENT '角色名称(冗余字段)', mobile VARCHAR(15) COMMENT '联系电话', image VARCHAR(500) COMMENT '员工头像', status TINYINT DEFAULT 1 COMMENT '是否启用(1=启用,0=禁用)', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', -- 外键约束 FOREIGN KEY (region_id) REFERENCES region(id), FOREIGN KEY (role_id) REFERENCES role(role_id) ) COMMENT '工单员工表'; -- 插入emp表测试数据 INSERT INTO emp (user_name, region_id, region_name, role_id, role_code, role_name, mobile) VALUES -- 工单管理员(对应role_id=1) ('张三', 1, '朝阳区', 1, '1001', '工单管理员', '13800138001'), -- 运营员(对应role_id=2) ('李四', 2, '海淀区', 2, '1002', '运营员', '13800138002'), -- 维修员(对应role_id=3) ('王五', 3, '东城区', 3, '1003', '维修员', '13800138003'), ('赵六', 4, '西城区', 3, '1003', '维修员', '13800138004'), ('孙七', 11, '成都市', 2, '1002', '运营员', '13800138005');
-
3.1. 码生成
-
添加目录菜单

-
添加数据字典

-
配置代码生成信息
-
人员列表
关于为什么新增删除留region、role的id而不留对应的name,这里是和列表相反的,因为在原型图中,在新增/删除弹窗这两个是下拉框的方式编辑,和后端查询时传递的是id,而不是name。

-
工单角色

-
-
导入项目
-
sql执行
这里注意只执行人员列表sql,不用执行工单角色sql

-
3.2. 代码改造
3.2.1. 人员列表改造
这里的下拉框改造和前面的下拉框改造其实都可以优化一下,执行时机可以放在点击新增或修改按钮后,这样就可以减少发送不必要的请求,减轻服务器压力。
-
角色下拉框改造
html<el-form-item label="角色" prop="roleId"> <el-select v-model="form.roleId" placeholder="请选择角色"> <el-option v-for="item in roleList" :key="item.roleId" :label="item.roleName" :value="item.roleId" /> </el-select>jsimport { listRole } from "@/api/manage/role"; import { loadAllParams } from "@/api/page"; // ... const roleList = ref([]); // 查询角色列表 const getRoleList = () => { listRole(loadAllParams).then(response => { roleList.value = response.rows; }); } getRoleList(); getList(); -
区域下拉框改造
这里也就顺便回答一下上面的疑惑,为什么要导出role角色表的代码,其实就是为了这里可以调用现成的
listRegion方法html<el-form-item label="所属区域" prop="regionId"> <el-select v-model="form.regionId" placeholder="请选择所属区域"> <el-option v-for="item in regionList" :key="item.id" :label="item.regionName" :value="item.id" /> </el-select> </el-form-item>jsimport { listRegion } from "@/api/manage/region"; // ... const regionList = ref([]); // 查询区域列表 const getRegionList = () => { listRegion(loadAllParams).then(response => { regionList.value = response.rows; }); } getRegionList(); -
条件显示创建时间
html<el-form-item label="创建时间" prop="createTime" v-if="form.id"> {{ form.createTime }} </el-form-item> -
添加后的数据不显示问题

回到这张图,可以看出来这是因为我们的冗余字段在修改、添加时后端没有完成数据添加(因为若依我们生成代码的时候没有选啊),我们选的是id的插入和现实,而我们网数据库推的也是id,所以当然不会显示了。

-
修改方案
在
NodeServiceImpl.java中java@Autowired private RegionMapper regionMapper; @Autowired private RoleMapper roleMapper; // ... // 新增员工列表 @Override public int insertEmp(Emp emp) { // 补充区域名 emp.setRegionName(regionMapper.selectRegionById(emp.getRegionId()).getRegionName()); // 补充角色 Role role = roleMapper.selectRoleByRoleId(emp.getRoleId()); emp.setRoleName(role.getRoleName()); emp.setRoleCode(role.getRoleCode()); emp.setCreateTime(DateUtils.getNowDate()); return empMapper.insertEmp(emp); } // 修改员工列表 @Override public int updateEmp(Emp emp) { // 补充区域名 emp.setRegionName(regionMapper.selectRegionById(emp.getRegionId()).getRegionName()); // 补充角色 Role role = roleMapper.selectRoleByRoleId(emp.getRoleId()); emp.setRoleName(role.getRoleName()); emp.setRoleCode(role.getRoleCode()); emp.setUpdateTime(DateUtils.getNowDate()); return empMapper.updateEmp(emp); } -
效果如图

-
3.2.2. 阿里OSS模块
阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。
这个OSS我再另外一篇博客也写过:【JavaWeb】------文件上传、OSS、文件配置_java 配置oss-CSDN博客
这里就再过一遍,但是有些步骤我会跳过,有需要的同学可以去上面的博客链接查看更仔细的不再。
-
拿上面的测试数据test2为例,储存的图片路径是本地,我们现在需要将数据储存到云端
json{ // ... "mobile": "15555555555", "image": "/profile/upload/2025/12/18/IMG_20251130_01203194_0_guid(13427db4f78a4724bef2c0c9ae162923)_gallery_20251218194755A001.png", "status": 1 } -
阿里云使用步骤
-
Bucket:存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。
-
SDK:Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。

-
-
快速接入(官方SDK)
通过以下步骤快速接入 OSS Java SDK V1:

-
准备环境
安装 Java 7 及以上版本。通过
java -version命令查看 Java 版本 -
添加Maven依赖
-
添加依赖坐标
xml<!-- 阿里云oss --> <dependency> <groupId>com.aliyun.oss</groupId> <artifactId>aliyun-sdk-oss</artifactId> <version>3.17.4</version> </dependency> -
添加JAXB相关依赖
这个相关依赖若依工程已经包含了,如果是自己单独的项目。要引入这个依赖。
xml<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>javax.activation</groupId> <artifactId>activation</artifactId> <version>1.1.1</version> </dependency> <!-- no more than 2.3.3--> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.3</version> </dependency>
-
-
配置访问凭证
下面的环境变量添加完毕后需要重启idea,让idea能够成功加载环境变量
-
配置AK & SK
bashsetx OSS_ACCESS_KEY_ID "YOUR_ACCESS_KEY_ID" setx OSS_ACCESS_KEY_SECRET "YOUR_ACCESS_KEY_SECRET" -
验证环境变量是否生效
bashecho %oss_ACCESS_KEY_ID% echo %OSS_ACCESS_KEYE_SECRET%
-
-
初始化客户端
-
官网文档打印bucket名的案例
以下示例代码使用华东1(杭州)地域的外网访问域名初始化客户端,并列举该地域下的Bucket列表作为验证。
下面的Endpoint改为你要使用的Bucket的节点,比如我的bucket在成都,就需要将
cn-hangzhou替换为cn-chengdu,但是这个案例无所谓改不改,因为我们这个案例并没有使用bucket,只是查询了我这个账号有哪些桶javaimport com.aliyun.oss.*; import com.aliyun.oss.common.auth.*; import com.aliyun.oss.common.comm.SignVersion; import com.aliyun.oss.model.Bucket; import java.util.List; /** * OSS SDK 快速接入示例 * 演示如何初始化 OSS 客户端并列出所有 Bucket */ public class Test { public static void main(String[] args) { // 从环境变量获取访问凭证 String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID"); String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET"); // 设置OSS地域和Endpoint String region = "cn-hangzhou"; String endpoint = "oss-cn-hangzhou.aliyuncs.com"; // 创建凭证提供者 DefaultCredentialProvider provider = new DefaultCredentialProvider(accessKeyId, accessKeySecret); // 配置客户端参数 ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration(); // 显式声明使用V4签名算法 clientBuilderConfiguration.setSignatureVersion(SignVersion.V4); // 初始化OSS客户端 OSS ossClient = OSSClientBuilder.create() .credentialsProvider(provider) .clientConfiguration(clientBuilderConfiguration) .region(region) .endpoint(endpoint) .build(); // 列出当前用户的所有Bucket List<Bucket> buckets = ossClient.listBuckets(); System.out.println("成功连接到OSS服务,当前账号下的Bucket列表:"); if (buckets.isEmpty()) { System.out.println("当前账号下暂无Bucket"); } else { for (Bucket bucket : buckets) { System.out.println("- " + bucket.getName()); } } // 释放资源 ossClient.shutdown(); System.out.println("OSS客户端已关闭"); } }我这里创建了员工Test来测试我修改好的案例,运行成功后,成功打印了我的bucket列表

-
官网上传文件案例
文档网址:使用Java SDK完成简单上传的流式与文件上传-对象存储-阿里云
官方案例:
javaimport com.aliyun.oss.*; import com.aliyun.oss.common.auth.*; import com.aliyun.oss.common.comm.SignVersion; import com.aliyun.oss.model.PutObjectRequest; import com.aliyun.oss.model.PutObjectResult; import java.io.FileInputStream; import java.io.InputStream; public class Demo { public static void main(String[] args) throws Exception { // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。 String endpoint = "https://oss-cn-hangzhou.aliyuncs.com"; // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。 EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); // 填写Bucket名称,例如examplebucket。 String bucketName = "examplebucket"; // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。 String objectName = "exampledir/exampleobject.txt"; // 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。 // 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。 String filePath= "D:\\localpath\\examplefile.txt"; // 填写Bucket所在地域。以华东1(杭州)为例,Region填写为cn-hangzhou。 String region = "cn-hangzhou"; // 创建OSSClient实例。 // 当OSSClient实例不再使用时,调用shutdown方法以释放资源。 ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration(); clientBuilderConfiguration.setSignatureVersion(SignVersion.V4); OSS ossClient = OSSClientBuilder.create() .endpoint(endpoint) .credentialsProvider(credentialsProvider) .clientConfiguration(clientBuilderConfiguration) .region(region) .build(); try { InputStream inputStream = new FileInputStream(filePath); // 创建PutObjectRequest对象。 PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream); // 创建PutObject请求。 PutObjectResult result = ossClient.putObject(putObjectRequest); } catch (OSSException oe) { System.out.println("Caught an OSSException, which means your request made it to OSS, " + "but was rejected with an error response for some reason."); System.out.println("Error Message:" + oe.getErrorMessage()); System.out.println("Error Code:" + oe.getErrorCode()); System.out.println("Request ID:" + oe.getRequestId()); System.out.println("Host ID:" + oe.getHostId()); } catch (ClientException ce) { System.out.println("Caught an ClientException, which means the client encountered " + "a serious internal problem while trying to communicate with OSS, " + "such as not being able to access the network."); System.out.println("Error Message:" + ce.getMessage()); } finally { if (ossClient != null) { ossClient.shutdown(); } } } }我是这样写的(其余部分和上面案例一样)
java// Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。 String endpoint = "https://oss-cn-chengdu.aliyuncs.com"; // ... // 填写Bucket名称,例如examplebucket。 String bucketName = "dkd-jiaqi"; // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。 String objectName = "测试/avator.png"; // 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。 String filePath= "D:\\Desktop\\图片\\avator\\avator.png"; // 填写Bucket所在地域。以华东1(杭州)为例,Region填写为cn-hangzhou。 String region = "cn-chengdu";运行这个测试文件,文件就上传成功了

-
3.2.3. x-file-storage
x-file-storage官网 : https://x-file-storage.xuyanwu.cn/
一行代码将文件存储到本地、阿里云 OSS、华为云 OBS、七牛云 Kodo、腾讯云 COS......其它兼容 S3 协议的存储平台

-
快速入门
-
导入x-file-storage依赖坐标
java<dependency> <groupId>org.dromara.x-file-storage</groupId> <artifactId>x-file-storage-spring</artifactId> <version>2.3.0</version> </dependency> -
再引入对应平台的依赖(以阿里云为例)
java<dependency> <groupId>com.aliyun.oss</groupId> <artifactId>aliyun-sdk-oss</artifactId> <version>3.16.1</version> </dependency> -
application.yml配置文件中先添加以下基础配置,再添加对应平台的配置(下面的代码以我上面的配置为例,ak和sk替换成自己的)yml# 文件上传 dromara: x-file-storage: #文件存储配置 default-platform: aliyun-oss-1 #默认使用的存储平台 thumbnail-suffix: ".min.jpg" #缩略图后缀,例如【.min.jpg】【.png】 #对应平台的配置写在这里,注意缩进要对齐 aliyun-oss: - platform: aliyun-oss-1 # 存储平台标识 enable-storage: true # 启用存储 access-key: ?? secret-key: ?? end-point: oss-cn-chengdu.aliyuncs.com bucket-name: dkd-jiaqi domain: https://dkd-jiaqi.oss-cn-chengdu.aliyuncs.com/ base-path: dkd-iamges/-
更多配置
ymldromara: x-file-storage: #文件存储配置 default-platform: huawei-obs-1 #默认使用的存储平台 thumbnail-suffix: ".min.jpg" #缩略图后缀,例如【.min.jpg】【.png】 huawei-obs: - platform: huawei-obs-1 # 存储平台标识 enable-storage: true # 启用存储 access-key: ?? secret-key: ?? end-point: ?? bucket-name: ?? domain: ?? # 访问域名,注意"/"结尾,例如:http://abc.obs.com/ base-path: test/ # 基础路径 - platform: huawei-obs-2 # 存储平台标识,这与这里不能重复 enable-storage: true # 启用存储 access-key: ?? secret-key: ?? end-point: ?? bucket-name: ?? domain: ?? # 访问域名,注意"/"结尾,例如:http://abc.obs.com/ base-path: test2/ # 基础路径 aliyun-oss: - platform: aliyun-oss-1 # 存储平台标识 enable-storage: true # 启用存储 access-key: ?? secret-key: ?? end-point: ?? bucket-name: ?? domain: ?? # 访问域名,注意"/"结尾,例如:https://abc.oss-cn-shanghai.aliyuncs.com/ base-path: test/ # 基础路径
-
-
在项目启动类商添加注解
java@EnableFileStorage @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) public class DkdApplication { // ... }
-
-
修改若依上传
-
在
CommonController.java中的/common中注入x-file-storage示例·java@Autowired private FileStorageService fileStorageService; -
在
/common/upload的try中可以看到若依自带的上传代码逻辑java// 上传文件路径 String filePath = RuoYiConfig.getUploadPath(); // 上传并返回新文件名称 String fileName = FileUploadUtils.upload(filePath, file); String url = serverConfig.getUrl() + fileName; AjaxResult ajax = AjaxResult.success(); ajax.put("url", url); ajax.put("fileName", fileName); ajax.put("newFileName", FileUtils.getName(fileName)); ajax.put("originalFilename", file.getOriginalFilename()); return ajax; -
修改代码如下
java// 指定oss保存文件路径,如dkd-iamges/2025/12/19/文件名 String objectName = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "/"; // 上传图片,返回文件信息 FileInfo fileInfo = fileStorageService.of(file).setPath(objectName).upload(); AjaxResult ajax = AjaxResult.success(); ajax.put("url", fileInfo.getUrl()); // 注意:这里的值需要改为URL,因为前端的访问路径会做一个判断,如果以http开头的就直接显示图片,则直接返回文件名 ajax.put("fileName", fileInfo.getUrl()); ajax.put("newFileName", fileInfo.getUrl()); ajax.put("originalFilename", file.getOriginalFilename()); return ajax;
-
-
遗留问题:这里的图片上传其实还有一个BUG,上传图片后不点击确定按钮,图片依旧上传到bucket里面了,但是数据库的图片路径因为没有点击确定,所以数据库图片储存路径依旧没有改变。
-
解决方案:这个问题可以跳过前端禁用图片自动提交并配合后端接口修改来解决,也就是图片上传后不提交数据,而是等点击按钮后,提交FormData给后端,后端先储存到oss,再将储存返回的路径储存到数据库,返回将执行结果返回给前端。
-
优化解决方案:一般来说,我们替换图片后,还需要将bucket中的原来的图片删除,避免无用的数据累积,这个逻辑也可以写再后端代码中。
4. 设备模块
4.1. 代码生成
-
业务场景:管理员在系统录入设备信息后,员工将负责设备(智能售货机)的投放和商品补货工作
-
设备管理主要涉及到三个功能模块,业务流程如下:

-
对应关系

-
库表设计
-
数据字典:vm_status(0未投放、1运营、3撤机)
-
冗余字段:addr、business_type、region_id、partner_id

-
-
sql语句
-
vm_type表(设备类型表)
vm_type表:4 条数据,完全覆盖vending_machine中vm_type_id的所有取值(1-4),无关联缺失。sqlCREATE TABLE vm_type ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键(关联vending_machine.vm_type_id)', name VARCHAR(15) NOT NULL COMMENT '型号名称', model VARCHAR(20) NOT NULL COMMENT '型号编号', image VARCHAR(500) DEFAULT '' COMMENT '设备图片', vm_row INT NOT NULL COMMENT '货道排数', vm_col INT NOT NULL COMMENT '货道列数', channel_max_capacity INT NOT NULL COMMENT '设备总容量(与vending_machine.channel_max_capacity一致)', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', create_by VARCHAR(64) DEFAULT '' COMMENT '创建人', update_by VARCHAR(64) DEFAULT '' COMMENT '修改人', remark TEXT COMMENT '备注' ) COMMENT '设备类型表'; INSERT INTO vm_type (name, model, image, vm_row, vm_col, channel_max_capacity) VALUES -- 对应 vm_type_id=1(标准款售货机:通用商用机型) ('标准款售货机', 'VM-MODEL-001', 'https://picsum.photos/id/1/800/600', 6, 10, 60), -- 对应 vm_type_id=2(青春款售货机:颜值高、适合年轻群体) ('青春款售货机', 'VM-MODEL-002', 'https://picsum.photos/id/26/800/600', 5, 10, 50), -- 对应 vm_type_id=3(办公款售货机:小巧、适配写字楼场景) ('办公款售货机', 'VM-MODEL-003', 'https://picsum.photos/id/42/800/600', 5, 9, 45), -- 对应 vm_type_id=4(枢纽款售货机:大容量、适配交通枢纽) ('枢纽款售货机', 'VM-MODEL-004', 'https://picsum.photos/id/65/800/600', 7, 10, 70); -
channel表(货道表)
channel表表:共生成320 个货道(匹配 7 台设备的总容量):
- VM-BJ-CY-001(60 容量):6 排 ×10 列 = 60 个货道
- VM-BJ-CY-002(50 容量):5 排 ×10 列 = 50 个货道
- VM-BJ-CY-003(40 容量):4 排 ×10 列 = 40 个货道
- VM-BJ-HD-001(45 容量):4 排 ×10 列 + 5 排 ×9 列 = 49 个货道
- VM-BJ-DC-001(55 容量):5 排 ×10 列 + 6 排 ×5 列 = 55 个货道
- VM-BJ-DX-001(70 容量):7 排 ×10 列 = 70 个货道
- VM-BJ-TZ-001(50 容量):5 排 ×10 列 = 50 个货道
sqlCREATE TABLE channel ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', channel_code VARCHAR(10) NOT NULL COMMENT '货道编号(如C0101=1排1列)', sku_id BIGINT DEFAULT 0 COMMENT '商品ID(0表示未铺货)', vm_id BIGINT NOT NULL COMMENT '售货机ID(关联vending_machine.id)', inner_code VARCHAR(15) NOT NULL COMMENT '售货机编号(冗余vending_machine.inner_code)', max_capacity INT NOT NULL COMMENT '货道最大容量', current_capacity INT DEFAULT 0 COMMENT '货道当前容量', last_supply_time DATETIME DEFAULT '1970-01-01 00:00:00' COMMENT '上次补货时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', FOREIGN KEY (vm_id) REFERENCES vending_machine(id) ON DELETE CASCADE ) COMMENT '售货机货道表'; INSERT INTO channel (channel_code, sku_id, vm_id, inner_code, max_capacity, current_capacity, last_supply_time) SELECT -- 货道编号:C+排数(2位)+列数(2位),如C0101、C0509 CONCAT('C', LPAD(rows.row_num, 2, '0'), LPAD(cols.col_num, 2, '0')), FLOOR(1 + RAND() * 30), -- 随机商品ID(1-30,模拟多种商品) vm.id, -- 自动获取售货机真实ID(避免外键报错) vm.inner_code, -- 自动冗余售货机编号 5, -- 单货道最大容量(可根据业务调整为3/5/10等) FLOOR(1 + RAND() * 5), -- 当前容量(1-5随机,模拟补货后剩余) vm.last_supply_time -- 自动复用售货机的上次补货时间 FROM -- 关联所有售货机数据 vending_machine vm, -- 生成1-10排(覆盖所有设备的排数需求) (SELECT 1 row_num UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) rows, -- 生成1-10列(根据设备容量动态筛选) (SELECT 1 col_num UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10) cols -- 核心条件:只生成"排数×列数 ≤ 设备总容量"的货道(避免货道数量超出设备容量) WHERE (rows.row_num * 10) <= vm.channel_max_capacity -- 特殊处理45容量的设备(VM-BJ-HD-001):前4排10列,第5排9列(4×10+9=45) OR (vm.channel_max_capacity = 45 AND rows.row_num = 5 AND cols.col_num <= 9);
-
-
代码生成
-
创建目录菜单

-
添加数据字典

-
配置代码生成信息
-
设备类型表

-
设备表

-
货道表
货道表用默认的配置就可以了
-
-
下载代码并导入项目
不用导入货道表的菜单sql

-
4.2. 代码改造
4.2.1. 设备管理改造
-
设备类型改造
大多都是和前面重复的前端改造,比较简单和繁琐,这里就不再多提了,下面是将货道的修改。
html<el-form-item label="货道数" prop="vmRow"> <template #default="scope"> <el-input-number v-model="form.vmRow" min="1" max="10" />行 <el-input-number v-model="form.vmCol" min="1" max="10" />列 </template> </el-form-item> <el-form-item label="设备容量" prop="channelMaxCapacity"> <el-input-number v-model="form.channelMaxCapacity" min="1" max="100" /> </el-form-item> -
表格修改
通过模拟数据字典的方式,展示合作商、设备型号
html<el-table-column label="设备型号" align="center" prop="vmTypeId" > <template #default="scope"> <div v-for="item in vmTypeList" :key="item.id" > <span v-if="item.id == scope.row.vmTypeId"> {{ item.model }} </span> </div> </template> </el-table-column> <el-table-column label="详细地址" align="center" prop="addr" /> <el-table-column label="合作商" align="center" prop="partnerId" > <template #default="scope"> <div v-for="item in partnerList" :key="item.id" > <span v-if="item.id == scope.row.partnerId"> {{ item.partnerName }} </span> </div> </template> </el-table-column>jsimport {listVm_type} from "@/api/manage/vm_type"; import {listPartner} from "@/api/manage/partner"; import { loadAllParams } from "@/api/page"; // ... const vmTypeList = ref([]); const partnerList = ref([]); // 查询设备列表 const getVmTypeList = () => { listVm_type(loadAllParams).then(response => { vmTypeList.value = response.rows; }); } // 查询合作商列表 const getPartnerList = () => { listPartner(loadAllParams).then(response => { partnerList.value = response.rows; }); } getPartnerList(); getVmTypeList();效果如图:

-
弹框修改
html<el-form ref="vmRef" :model="form" :rules="rules" label-width="80px"> <el-form-item label="设备编号" prop="innerCode" v-if="form.id"> {{ form.innerCode }} </el-form-item> <el-form-item label="供货时间" prop="lastSupplyTime" v-if="form.id"> {{ form.lastSupplyTime }} </el-form-item> <el-form-item label="设备类型" prop="vmTypeId"> <!-- 新增 --> <el-select v-model="form.vmTypeId" placeholder="请选择设备类型" v-if="!form.id"> <el-option v-for="item in vmTypeList" :key="item.id" :label="item.name" :value="item.id"/> </el-select> <!-- 修改 --> <div v-else v-for="item in vmTypeList" :key="item.id" > <span v-if="item.id == form.vmTypeId" >{{ item.name }}</span> </div> </el-form-item> <el-form-item label="设备容量" prop="channelMaxCapacity" v-if="form.id"> {{ form.channelMaxCapacity }} </el-form-item> <el-form-item label="点位" prop="nodeId"> <el-select v-model="form.nodeId" placeholder="请选择点位"> <el-option v-for="item in nodeList" :key="item.id" :label="item.nodeName" :value="item.id" /> </el-select> </el-form-item> <el-form-item label="合作商" v-if="form.id"> <div v-for="item in partnerList" :key="item.id" > <span v-if="item.id == form.partnerId"> {{ item.partnerName }} </span> </div> </el-form-item> <el-form-item label="所属区域" v-if="form.id"> <div v-for="item in regionList" :key="item.id" > <span v-if="item.id == form.regionId"> {{ item.regionName }} </span> </div> </el-form-item> <el-form-item label="设备地址" v-if="form.id"> {{ form.addr }} </el-form-item> </el-form>jsconst nodeList = ref([]); const regionList = ref([]); // 查询区域列表 const getRegionList = () => { listRegion(loadAllParams).then(response => { regionList.value = response.rows; }); } // 查询点位列表 const getNodeList = () => { listNode(loadAllParams).then(response => { nodeList.value = response.rows; }); } getNodeList(); getRegionList(); -
新增设备改造
新增设备时,根据点位补充冗余字段信息,还需要根据售货机类型创建所属货道(也就是说我们上面新增操作提交的信息不够)

-
在
VendingMachineServiceImpl中,修改添加业务逻辑-
修改代码
java@Transactional @Override public int insertVendingMachine(VendingMachine vendingMachine) { // 1. 新增设备 // 1.1. 调用UUIDUtils, 生成innerCode String innerCode = UUIDUtils.getUUID(); vendingMachine.setInnerCode(innerCode); // innerCode赋值 // 1.2. 调用vmTypeService, 获取货道最大容量 VmType vmType = vmTypeService.selectVmTypeById(vendingMachine.getVmTypeId()); Long ChannelMaxCapacity = vmType.getChannelMaxCapacity(); vendingMachine.setChannelMaxCapacity(ChannelMaxCapacity); // ChannelMaxCapacity赋值 // 1.3. 查询点位表,复制荣誉信息 Node node = nodeService.selectNodeById(vendingMachine.getNodeId()); BeanUtil.copyProperties(node, vendingMachine, "id"); // hutool拷贝 vendingMachine.setAddr(node.getDetailedAddress()); // addr赋值 vendingMachine.setBusinessType(node.getBusinessCircleType()); // businessType赋值 // 1.4. 设置默认设备状态和时间 vendingMachine.setVmStatus(DkdContants.VM_STATUS_NODEPLOY); // 0:未投放 => 换成了常量 vendingMachine.setCreateTime(DateUtils.getNowDate()); vendingMachine.setUpdateTime(DateUtils.getNowDate()); // 设置默认经纬度(0) vendingMachine.setLongitudes(0L); vendingMachine.setLatitude(0L); // 1.5. 保存 int result = vendingMachineMapper.insertVendingMachine(vendingMachine); // 新增设备 // 2. 新增货道 // 2.1. 声明货道相关变量 List<Channel> channels = new ArrayList<Channel>(); // 2.2. 货道编号拼接赋值 for(int i = 1; i <= vmType.getVmRow(); i++){ for(int j = 1; j <= vmType.getVmCol(); j++){ Channel channel = new Channel(); // 2.3 封装channel对象 channel.setChannelCode(i + "-" + j); // 货道编号 channel.setVmId(vendingMachine.getId()); // 售货机 id channel.setSkuId(0L); channel.setInnerCode(vendingMachine.getInnerCode()); // 设备编号 channel.setMaxCapacity(vmType.getChannelMaxCapacity()); // 货道最大容量 channel.setCreateTime(DateUtils.getNowDate()); channel.setUpdateTime(DateUtils.getNowDate()); channels.add(channel); // 货道添加进集合 } } // 2.4. 批量保存货道 channelService.insertChannelBatch(channels); return result; }
-
-
这里我们使用的是批量插入提高插入性能,所以我们要在
channelService中添加批量插入方法insertChannelBatch-
ChannelService接口类
java// 批量新增货道 public int insertChannelBatch(List<Channel> channelList); -
实现类
java// 批量新增货道 @Override public int insertChannelBatch(List<Channel> channelList) { return channelMapper.insertChannelBatch(channelList); } -
mapper
java/* 批量新增货机货道 */ public int insertChannelBatch(List<Channel> channelList); -
xml
xml<insert id="insertChannelBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="id"> INSERT INTO channel (channel_code, sku_id, vm_id, inner_code, max_capacity, current_capacity, last_supply_time, create_time, update_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.channelCode}, #{item.skuId}, #{item.vmId}, #{item.innerCode}, #{item.maxCapacity}, #{item.currentCapacity}, #{item.lastSupplyTime}, #{item.createTime}, #{item.updateTime}) </foreach> </insert>
-
-
-
修改设备改造
修改设备时,根据点位同步更新冗余字段信息
java@Override public int updateVendingMachine(VendingMachine vendingMachine) { // 查询点位表,补充区域、点位、合作商等信息 Node node = nodeService.selectNodeById(vendingMachine.getNodeId()); BeanUtil.copyProperties(node, vendingMachine, "id", "createTime", "updateTime"); // hutool拷贝 vendingMachine.setAddr(node.getDetailedAddress()); // addr赋值 vendingMachine.setUpdateTime(DateUtils.getNowDate()); return vendingMachineMapper.updateVendingMachine(vendingMachine); }
4.2.2. 设备状态改造
为设备状态管理功能创建前端页面,并在若依框架中定义相应的路由和菜单项,然后基于原型完成视图组件基础布局展示改造。
-
复制组件
创建
src\views\manage\vmStatus\index.vue,并将vm\index.vue的内容复制过来 -
添加菜单目录

-
修改表格
-
测试数据修改
我上面的测试数据有些不准确,这里我像老师一样修改成json字符串

-
修改页面
html<template> <div class="app-container"> <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="设备编号" prop="innerCode"> <el-input v-model="queryParams.innerCode" placeholder="请输入设备编号" clearable @keyup.enter="handleQuery" /> </el-form-item> <el-form-item> <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button> <el-button icon="Refresh" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <el-table v-loading="loading" :data="vmList"> <el-table-column type="index" label="序号" width="55" align="center" /> <el-table-column label="设备编号" prop="innerCode" align="center" /> <el-table-column label="详细地址" prop="addr" align="center" /> <el-table-column label="运营状态" align="center" > <template #default="scope"> <dict-tag :options="vm_status" :value="scope.row.vmStatus"/> </template> </el-table-column> <el-table-column label="设备状态" align="center" > <template #default="scope"> <span v-if="scope.row.runningStatus"> {{ JSON.parse(scope.row.runningStatus).status == true ? '正常' : '异常' }} </span> <span v-else>异常</span> </template> </el-table-column> <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <template #default="scope"> <el-button link type="primary" @click="getVminfo(scope.row)" v-hasPermi="['manage:vm:query']">查看详情</el-button> </template> </el-table-column> </el-table> <el-dialog title="设备详情" v-model="dialogVisible" width="500px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false" > <div class="vm-detail-content"> </div> <template #footer> <el-button type="primary" @click="dialogVisible = false">确定</el-button> </template> </el-dialog> <pagination v-show="total>0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" /> </div> </template> <script setup name="Vm"> import { listVm } from "@/api/manage/vm" const { proxy } = getCurrentInstance() const { vm_status } = proxy.useDict('vm_status') const vmList = ref([]) const loading = ref(true) const showSearch = ref(true) const total = ref(0); // 查询参数 const queryParams = ref({ pageNum: 1, pageSize: 10, innerCode: null }); // 当前选中的设备信息 const currentVm = ref({}); // 弹窗显示状态 const dialogVisible = ref(false); // 查询详情 const getVminfo = (row) => { currentVm.value = { ...row }; dialogVisible.value = true; } /** 查询设备管理列表 */ function getList() { loading.value = true; listVm(queryParams.value).then(response => { vmList.value = response.rows; total.value = response.total; loading.value = false; }); } /** 搜索按钮操作 */ function handleQuery() { queryParams.value.pageNum = 1; getList(); } /** 重置按钮操作 */ function resetQuery() { proxy.resetForm("queryRef"); handleQuery(); } // 初始化列表 getList(); </script>修改效果:

-
4.2.3. 点位查看详情
-
代码修改
src\views\manage\node\index.vue中修改html<!-- 查看详情弹框 --> <el-dialog title="查看详情" v-model="nodeOpen" width="600px" append-to-body> <el-table :data="vmList" border> <el-table-column label="序号" align="center" type="index" /> <el-table-column label="设备编号" align="center" prop="innerCode"/> <el-table-column label="设备状态" align="center" prop="status"> <template #default="scope"> <span>{{ scope.row.status == 1 ? '正常' : '异常' }}</span> </template> </el-table-column> <el-table-column label="最后一次供货时间" align="center" prop="lastSupplyTime" /> </el-table> </el-dialog> ... <script setup name="Node"> import { listVm } from "@/api/manage/vm"; import { loadAllParams } from "@/api/page"; // ... const vmList = ref([]); const nodeOpen = ref(false) /** 查看详情按钮操作 */ function getNodeInfo(row) { loadAllParams.nodeId = row.id listVm(loadAllParams).then(response => { vmList.value = response.rows nodeOpen.value = true }); } <script/> -
修改效果

5. 策略模块
5.1. 代码生成
-
业务场景
管理员在系统中可以对每一台设备设置一个固定折扣,用于营销作用。
-
业务流程
策略管理主要涉及到二个功能模块,业务流程如下:

-
数据模型

-
策略表sql语句
sqlCREATE TABLE policy ( policy_id BIGINT PRIMARY KEY COMMENT '策略id', policy_name VARCHAR(30) NOT NULL COMMENT '策略名称', discount INT COMMENT '策略方案,如:80代表8折', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间' ) COMMENT '策略表'; INSERT INTO policy (policy_id, policy_name, discount, create_time, update_time) VALUES (1, '夏季8折优惠', 80, '2025-06-01 10:00:00', '2025-06-01 10:00:00'), (2, '新设备首单9折', 90, '2025-09-15 14:30:00', '2025-09-15 14:30:00'), (3, '周末全场75折', 75, '2025-10-01 09:15:00', '2025-10-05 11:20:00'); -
代码生成
-
创建目录菜单

-
配置代码生成信息

-
下载代码并导入项目
修改完毕如图

-
5.2. 代码改造
5.2.1. 策略管理改造
-
布局改造

-
策略改造
管理员点击查看详情,展示策略名称和该策略下的设备列表
html<!-- 查看详情对话框 --> <el-dialog :title="title" v-model="policyOpen" width="500px" append-to-body> <el-form-item label="策略名称" prop="policyName"> <el-input v-model="form.policyName" placeholder="请输入策略名称" disabled /> </el-form-item> <h3 style="text-align: center;">包含设备</h3> <el-table ref="" :data="vmList" :rules="rules" label-width="80px"> <el-table-column label="序号" type="index" prop="policyName" width="50" align="center" /> <el-table-column label="点位地址" prop="addr" align="center" /> <el-table-column label="设备编号" prop="innerCode" align="center" /> </el-table> </el-dialog>jsimport { loadAllParams } from "@/api/page"; const policyOpen = ref(false); const vmList = ref([]); /** 查看详情按钮操作 */ function getPolicyinfo(row) { reset(); form.value = row; loadAllParams.policyId = row.policyId listVm(loadAllParams).then(response => { vmList.value = response.rows policyOpen.value = true }); }效果:
我看我全部设备都查出来了,应该是后端sql有问题,没有policyId的条件查询

-
纠正sql
在
VendingMachineController.java中的id="selectVendingMachineList"的select标签中添加如下代码xml<if test="policyId != null "> and policy_id = #{policyId}</if>
5.3.2. 设备策略分配
在设备管理页面中点击策略,对设备设置一个固定折扣,用于营销作用
-
前端代码修改
html<el-button link type="primary" @click="handleUpdatePolicy(scope.row)" v-hasPermi="['manage:vm:edit']">策略</el-button> <!-- ... --> <!-- 策略管理对话框 --> <el-dialog title="策略管理" v-model="openPolicy" width="500px" append-to-body> <!-- 选择策略 --> <el-form-item label="选择策略" prop="policyId"> <el-select v-model="form.policyId" placeholder="请选择策略"> <el-option v-for="item in policyList" :key="item.policyId" :label="item.policyName" :value="item.policyId" /> </el-select> </el-form-item> <template #footer> <div class="dialog-footer"> <el-button type="primary" @click="submitForm">确 定</el-button> <el-button @click="cancel">取 消</el-button> </div> </template> </el-dialog>jsimport { listPolicy } from "@/api/manage/policy"; // 取消按钮 function cancel() { open.value = false; openPolicy.value = false; reset(); } /** 提交按钮 */ function submitForm() { if (form.value.id != null) { updateVm(form.value).then(response => { proxy.$modal.msgSuccess("修改成功"); open.value = false; openPolicy.value = false; getList(); }); } else { addVm(form.value).then(response => { proxy.$modal.msgSuccess("新增成功"); open.value = false; openPolicy.value = false; getList(); }); } } const policyList = ref([]); const openPolicy = ref(false); const handleUpdatePolicy = (row) => { // 为表单赋值设备id和策略id form.value.id = row.id; form.value.policyId = row.policyId; // 查询策略列表 listPolicy(loadAllParams).then(response => { policyList.value = response.rows; openPolicy.value = true; }); } -
解决后端500错误
request.js:99 Uncaught (in promise) Error: Cannot invoke "com.dkd.manage.domain.Node.getDetailedAddress()" because "node" is null点位id没有传入后端,导致空指针报错,修改
VendingMachineControllerjava@Override public int updateVendingMachine(VendingMachine vendingMachine) { if(vendingMachine.getNodeId() != null){ // 查询点位表,补充区域、点位、合作商等信息 Node node = nodeService.selectNodeById(vendingMachine.getNodeId()); BeanUtil.copyProperties(node, vendingMachine, "id", "createTime", "updateTime"); // hutool拷贝 vendingMachine.setAddr(node.getDetailedAddress()); // addr赋值 } vendingMachine.setUpdateTime(DateUtils.getNowDate()); return vendingMachineMapper.updateVendingMachine(vendingMachine); }
6. 商品模块
6.1. 代码生成
-
业务场景
智能售货机的货道管理、商品类型以及具体商品信息的管理
-
业务流程
商品管理主要涉及到三个功能模块,业务流程如下:

-
数据模型
关系字段:class_id、sku_id、vm_id

-
sql
-
sku_class
sqlCREATE TABLE IF NOT EXISTS sku_class ( class_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', class_name VARCHAR(50) NOT NULL COMMENT '类型名称', parent_id INT DEFAULT 0 COMMENT '上级id(0表示一级分类)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类型表'; INSERT INTO sku_class (class_name, parent_id) VALUES ('饮料类', 0), ('碳酸饮料', 1), ('果汁饮料', 1), ('零食类', 0), ('膨化食品', 4); -
sku
sqlCREATE TABLE IF NOT EXISTS sku ( sku_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', sku_name VARCHAR(50) NOT NULL COMMENT '商品名称', sku_image VARCHAR(500) DEFAULT '' COMMENT '商品图片', brand_name VARCHAR(50) DEFAULT '' COMMENT '品牌', unit VARCHAR(20) DEFAULT '' COMMENT '规格/净含量', price INT NOT NULL COMMENT '商品价格(单位:分)', class_id INT NOT NULL COMMENT '商品类型id(关联sku_class.class_id)', is_discount TINYINT(1) DEFAULT 0 COMMENT '是否打折促销(0-否,1-是)', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', FOREIGN KEY (class_id) REFERENCES sku_class(class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; INSERT INTO sku (sku_name, sku_image, brand_name, unit, price, class_id, is_discount) VALUES ('可乐', 'https://example.com/cola.jpg', '可口可乐', '330ml', 350, 2, 1), ('橙汁', 'https://example.com/orange.jpg', '统一', '500ml', 450, 3, 0), ('薯片', 'https://example.com/chips.jpg', '乐事', '100g', 600, 5, 1);
-
-
代码生成
-
创建目录菜单

-
配置代码生成信息
-
商品表

-
商品类型表

-
-
下载代码并导入项目

-
6.2. 代码改造
6.2.1. 商品管理改造
-
商品类型改造
我们在数据库表中设置class_name为唯一字段

然后添加已经存在的class_name会报错数据完整性异常,这是我们上面使用的数据异常处理器拦截了,我们需要将异常提示改为更加准确一点

在
dkd/framework/web/exception/GlobalExceptionHandler.java中添加异常处理java// 数据完整性异常 @ExceptionHandler(DataIntegrityViolationException.class) public AjaxResult handleDataIntegrityViolationException(DataIntegrityViolationException e) { log.error(e.getMessage(), e); // ... if(e.getMessage().contains("Duplicate")){ return AjaxResult.error("无法保存、名字已存在"); } return AjaxResult.error("数据完整性异常"); }效果如图:

-
基础布局
-
商品价格优化
html<el-table-column label="商品价格" align="center" prop="price"> <template #default="scope"> <el-tag>¥{{ scope.row.price / 100 }}</el-tag> </template> </el-table-column> -
商品类型改造
html<el-table-column label="商品类型" align="center" prop="classId" > <template #default="scope"> <div v-for="item in skuClassList"> <span :key="item.classId" v-if="item.classId == scope.row.classId"> {{ item.className }} </span> </div> </template> </el-table-column>jsimport { listSkuClass } from "@/api/manage/skuClass"; import { loadAllParams } from "@/api/page"; const skuClassList = ref([]); const getSkuClassList = () => { listSkuClass(loadAllParams()).then(response => { skuClassList.value = response.rows; }); } -
弹框改造
html<!-- 添加或修改商品管理对话框 --> <el-dialog :title="title" v-model="open" width="500px" append-to-body> <el-form ref="skuRef" :model="form" :rules="rules" label-width="80px"> <el-form-item label="商品名称" prop="skuName"> <el-input v-model="form.skuName" placeholder="请输入商品名称" /> </el-form-item> <el-form-item label="品牌" prop="brandName"> <el-input v-model="form.brandName" placeholder="请输入品牌" /> </el-form-item> <el-form-item label="商品价格" prop="price"> <el-input-number v-model="form.price" :min="0.01" :max="999.99" precision="2" step="0.5" />元 </el-form-item> <el-form-item label="商品类型" prop="classId"> <el-select v-model="form.classId" placeholder="请选择商品类型"> <el-option v-for="item in skuClassList" :key="item.classId" :label="item.className" :value="item.classId" /> </el-select> </el-form-item> <el-form-item label="规格" prop="unit"> <el-input v-model="form.unit" placeholder="请输入规格/净含量" /> </el-form-item> <el-form-item label="商品图片" prop="skuImage"> <image-upload v-model="form.skuImage"/> </el-form-item> </el-form> <template #footer> <div class="dialog-footer"> <el-button type="primary" @click="submitForm">确 定</el-button> <el-button @click="cancel">取 消</el-button> </div> </template> </el-dialog>js/** 提交按钮 */ function submitForm() { proxy.$refs["skuRef"].validate(valid => { if (valid) { form.value.price *= 100; // 将价格从元转换为分 // ... } }); }
-
-
商品删除
在删除商品时,需要判断此商品是否被售货机的货道关联,如果关联则无法删除
物理外键约束和逻辑外键约束
-
SkuServiceImpl.javajava@Override public int deleteSkuBySkuIds(Long[] skuIds) { // 1.判断商品的id集合是否有关联货道 int count = channelService.countChannelBySkuIds(skuIds); if(count > 0){ throw new ServiceException("此商品被货道关联,无法删除"); } // 2.没有关联货道才能删除 return skuMapper.deleteSkuBySkuIds(skuIds); } -
IChannelService.javajava// 批量新增货道 public int insertChannelBatch(List<Channel> channelList); -
ChannelServiceImpl.javajava// 根据商品id集合,统计货道数量 @Override public int countChannelBySkuIds(Long[] skuIds){ return channelMapper.countChannelBySkuIds(skuIds); } -
ChannelMapper.javajava// 根据商品id集合,统计货道数量 int countChannelBySkuIds(Long[] skuIds); -
ChannelMapper.xmlxml<select id="countChannelBySkuIds" resultType="java.lang.Integer"> select count(1) from channel where sku_id in <foreach item="id" collection="array" open="(" separator="," close=")"> #{id} </foreach> </select>
-
6.2.2. 批量导入(EasyExcel)
若依对excel表格的封装
在SkuController中,商品导出为excel表格的代码如下
java
@PreAuthorize("@ss.hasPermi('manage:sku:export')")
@Log(title = "商品管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, Sku sku)
{
List<Sku> list = skuService.selectSkuList(sku);
ExcelUtil<Sku> util = new ExcelUtil<Sku>(Sku.class);
util.exportExcel(response, list, "商品管理数据");
}
其中,ExcelUtil为若依对apache.poi进行封装的工具类
-
前端导入改造
html<el-col :span="1.5"> <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['manage:sku:export']" >导出</el-button> </el-col> <!-- ... --> <!-- 数据导入对话框 --> <el-dialog title="数据导入" v-model="excelOpen" width="500px" append-to-body> <el-form ref="importRef" :model="importForm" :rules="importRules" label-width="80px"> <el-form-item label="导入文件" prop="file"> <el-upload ref="uploadRef" :action="uploadExcelUrl" :headers="headers" :on-success="handleImportSuccess" :on-error="handleImportError" :before-upload="beforeUpload" :limit="1" :auto-upload="false"> <el-button size="small" type="primary">上传文件</el-button> </el-upload> </el-form-item> <el-form-item label="" prop="tip"> <el-tag type="info">支持扩展名:xls、xlsx,且文件大小不能超过10MB</el-tag> </el-form-item> </el-form> <template #footer> <div class="dialog-footer"> <el-button @click="excelOpen = false">取 消</el-button> <el-button type="primary" @click="submitUpload">上 传</el-button> </div> </template> </el-dialog>jsimport { getToken } from "@/utils/auth"; // 引入获取token的方法 const excelOpen = ref(false); // 显示对象 const uploadRef = ref({}); // 上传对象 const uploadFileSize = ref(10); // 上传文件对象 const uploadExcelUrl = ref(import.meta.env.VITE_APP_BASE_API + "/manage/sku/import"); // 上传地址 // 上传头信息 const headers = ref({ Authorization: "Bearer " + getToken() }); /** 点击导入按钮操作 */ function handleImport() { excelOpen.value = true; } // 打开上传弹框 const submitUpload = () => { uploadRef.value.submit(); } // 上传前校验 const beforeUpload = (file) => { const isExcel = file.type === 'application/vnd.ms-excel' || file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; if (!isExcel) { proxy.$modal.msgError('上传文件必须是Excel文件!'); return false; } if (file.size > uploadFileSize.value * 1024 * 1024) { proxy.$modal.msgError('上传文件大小不能超过' + uploadFileSize.value + 'MB!'); return false; } proxy.$modal.loading("上传中..."); return true; } // 导入成功回调 const handleImportSuccess = (response, file) => { if(response.code == 200){ proxy.$modal.msgSuccess("导入成功:" + response.msg); excelOpen.value = false; getList(); }else{ proxy.$modal.msgError("导入失败:" + response.msg); } uploadRef.value.clearFiles(); proxy.$modal.closeLoading(); } // 导入失败回调 const handleImportError = (response, file) => { proxy.$modal.msgError("导入失败:" + response.msg); uploadRef.value.clearFiles(); proxy.$modal.closeLoading(); } -
后端导入改造
-
Controller
java/** * 导入商品管理列表 */ @PreAuthorize("@ss.hasPermi('manage:sku:import')") @Log(title = "商品管理", businessType = BusinessType.IMPORT) @PostMapping("/import") public AjaxResult importData(MultipartFile file) throws Exception { ExcelUtil<Sku> util = new ExcelUtil<Sku>(Sku.class); List<Sku> skuList = util.importExcel(file.getInputStream()); return toAjax(skuService.insertSkuBatch(skuList)); } -
Service
-
接口类
java// 批量新增 int insertSkuBatch(List<Sku> skuList); -
实现类
java// 批量新增 @Override public int insertSkuBatch(List<Sku> skuList) { return skuMapper.insertSkuBatch(skuList); }
-
-
Mapper
-
mapper
java// 批量新增 int insertSkuBatch(List<Sku> skuList); -
xml
xml<insert id="insertSkuBatch"> insert into sku (sku_name, sku_image, brand_name, unit, price, class_id, is_discount, create_time, update_time) values <foreach item="item" index="index" collection="list" separator=","> (#{item.skuName}, #{item.skuImage}, #{item.brandName}, #{item.unit}, #{item.price}, #{item.classId}, #{item.isDiscount}, #{item.createTime}, #{item.updateTime}) </foreach> </insert>
-
-
数据完整性错误
原因是我导出时,excel表格中缺少了price,经过检查,我发现:
-
错误原因
@Excel(readConverterExp = "单=位:分") 注解仅用于导入解析,此注解的作用是:在 导入 Excel 时,将字符串(如 "100")解析为 100分,并赋值给 price 字段。它 不影响导出行为,因此不会自动将 price 转换为"元"显示。
-
修改
Sku.java
java/** 商品价格*/ @Excel(name = "商品价格(元)") private Long price;SkuController中
java/** * 导出商品管理列表 */ @PreAuthorize("@ss.hasPermi('manage:sku:export')") @Log(title = "商品管理", businessType = BusinessType.EXPORT) @PostMapping("/export") public void export(HttpServletResponse response, Sku sku) { List<Sku> list = skuService.selectSkuList(sku); // 将 price 从"分"转为"元" for (Sku item : list) { if (item.getPrice() != null) { item.setPrice(item.getPrice() / 100L); // 转为"元"单位 } } ExcelUtil<Sku> util = new ExcelUtil<Sku>(Sku.class); util.exportExcel(response, list, "商品管理数据"); } /** * 导入商品管理列表 */ @PreAuthorize("@ss.hasPermi('manage:sku:import')") @Log(title = "商品管理", businessType = BusinessType.IMPORT) @PostMapping("/import") public AjaxResult importData(MultipartFile file) throws Exception { ExcelUtil<Sku> util = new ExcelUtil<Sku>(Sku.class); List<Sku> skuList = util.importExcel(file.getInputStream()); // 将 price 从"元"转为"分" for (Sku item : skuList) { if (item.getPrice() != null) { item.setPrice(item.getPrice() * 100L); // 转为"分"单位 } } return toAjax(skuService.insertSkuBatch(skuList)); }在测试的过程中,我还发现商品名称没有设置为唯一字段,这里也需要设置一下
-
-
导入表没有创建时间的问题
如图,通过表格导入的数据没有创建时间字段

这是因为我们批量插入时,导入的excel文件中并没有此创建时间这个字段,所以匹配不到,但是我们可以通过批量插入数据时不写入创建时间和更新时间,仅通过数据库新增数据时将插入时间和更新时间自动设为当前时间来解决这个问题
xml<insert id="insertSkuBatch"> insert into sku (sku_name, sku_image, brand_name, unit, price, class_id, is_discount) values <foreach item="item" index="index" collection="list" separator=","> (#{item.skuName}, #{item.skuImage}, #{item.brandName}, #{item.unit}, #{item.price}, #{item.classId}, #{item.isDiscount}) </foreach> </insert>
-
-
EasyExcel
EasyExcel是阿里巴巴开源的框架,它以使用简单、功能强大和节省内存而著称,特别适合于需要进行大量数据导入和导出的场景
-
若依集成easyexcel实现excel表格增强:https://doc.ruoyi.vip/ruoyi-vue/document/cjjc.html
-
EasyExcel官网: https://easyexcel.alibaba.com
-
EasyExcel仓库:https://gitee.com/easyexcel/easyexcel
-
导入坐标
xml<!-- easyexcel --> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>4.0.3</version> </dependency> -
ExcelUtil.java新增easyexcel导出导入方法路径在
com/dkd/common/test/utils/poi/ExcelUtil.java,粘贴道文件最下方即可javaimport com.alibaba.excel.EasyExcel; /** * 对excel表单默认第一个索引名转换成list(EasyExcel) * * @param is 输入流 * @return 转换后集合 */ public List<T> importEasyExcel(InputStream is) throws Exception { return EasyExcel.read(is).head(clazz).sheet().doReadSync(); } /** * 对list数据源将其里面的数据导入到excel表单(EasyExcel) * * @param list 导出数据集合 * @param sheetName 工作表的名称 * @return 结果 */ public void exportEasyExcel(HttpServletResponse response, List<T> list, String sheetName) { try { EasyExcel.write(response.getOutputStream(), clazz).sheet(sheetName).doWrite(list); } catch (IOException e) { log.error("导出EasyExcel异常{}", e.getMessage()); } } -
模拟测试,以操作日志为例,修改相关类。
SysOperlogController.java 改为
exportEasyExceljava@Log(title = "操作日志", businessType = BusinessType.EXPORT) @PreAuthorize("@ss.hasPermi('monitor:operlog:export')") @PostMapping("/export") public void export(HttpServletResponse response, SysOperLog operLog) { List<SysOperLog> list = operLogService.selectOperLogList(operLog); ExcelUtil<SysOperLog> util = new ExcelUtil<SysOperLog>(SysOperLog.class); util.exportEasyExcel(response, list, "操作日志"); }这里以sku为例,在
Sku.java中,修改注解java@ExcelIgnoreUnannotated // 忽略没有注解的字段 @ColumnWidth(16) // 设置列宽 @HeadRowHeight(14) // 设置表头行高 @HeadFontStyle(fontHeightInPoints = 11) // 设置表头字体样式,如字体大小 public class Sku extends BaseEntity { private static final long serialVersionUID = 1L; /** 主键 */ private Long skuId; /** 商品名称 */ @Excel(name = "商品名称") @ExcelProperty(value = "商品名称") private String skuName; /** 商品图片 */ @Excel(name = "商品图片") @ExcelProperty(value = "商品图片") private String skuImage; /** 品牌 */ @Excel(name = "品牌") @ExcelProperty(value = "匹配") private String brandName; /** 规格/净含量 */ @Excel(name = "规格/净含量") @ExcelProperty(value = "规格/净含量") private String unit; /** 商品价格*/ @Excel(name = "商品价格(元)") @ExcelProperty(value = "商品价格(元)") private Long price; /** 商品类型id */ @Excel(name = "商品类型id") @ExcelProperty(value = "商品类型id") private Long classId; /** 是否打折促销(0-否,1-是) */ private Integer isDiscount; // ... }在
SkuController.java中,修改导入和导出的方法名javaList<Sku> skuList = util.importEasyExcel(file.getInputStream()); // ... util.exportEasyExcel(response, list, "商品管理数据");
-
6.2.3. 货道关联商品
-
前端代码开发
-
api(channel.js)
我这里的后端要改一下,因为我
vmType在后端中是vm_typejsimport request from '@/utils/request'; // 查询货道列表 export function getGoodsList(innerCode) { return request({ url: '/manage/channel/list/' + innerCode, method: 'get', }); } // 查询设备类型 export function getGoodsType(typeId) { return request({ url: '/manage/vmType/' + typeId, method: 'get', }); } // 提交获取的货道 export function channelConfig(data) { return request({ url: '/manage/channel/config', method: 'put', data: data, }); } -
视图组件
-
组件
components/ChannelDialog.vuehtml<template> <!-- 货道弹层 --> <el-dialog width="940px" title="货道设置" v-model="visible" :close-on-click-modal="false" :close-on-press-escape="false" @open="handleGoodOpen" @close="handleGoodcClose" > <div class="vm-config-channel-dialog-wrapper"> <div class="channel-basic"> <span class="vm-row">货道行数:{{ vmType.vmRow }}</span> <span class="vm-col">货道列数:{{ vmType.vmCol }}</span> <span class="channel-max-capacity" >货道容量(个):{{ vmType.channelMaxCapacity }}</span > </div> <el-scrollbar ref="scroll" v-loading="listLoading" class="scrollbar"> <el-row v-for="vmRowIndex in vmType.vmRow" :key="vmRowIndex" type="flex" :gutter="16" class="space" > <el-col v-for="vmColIndex in vmType.vmCol" :key="vmColIndex" :span="vmType.vmCol <= 5 ? 5 : 12" > <ChannelDialogItem :current-index="computedCurrentIndex(vmRowIndex, vmColIndex)" :channel="channels[computedCurrentIndex(vmRowIndex, vmColIndex)]" @openSetSkuDialog="openSetSkuDialog" @openRemoveSkuDialog="openRemoveSkuDialog" > </ChannelDialogItem> </el-col> </el-row> </el-scrollbar> <el-icon v-if="vmType.vmCol > 5" class="arrow arrow-left" :class="scrollStatus === 'LEFT' ? 'disabled' : ''" @click="handleClickPrevButton" ><ArrowLeft /></el-icon> <el-icon v-if="vmType.vmCol > 5" class="arrow arrow-right" :class="scrollStatus === 'RIGHT' ? 'disabled' : ''" @click="handleClickNextButton" ><ArrowRight /></el-icon> </div> <div class="dialog-footer"> <el-button type="primary" class="el-button--primary1" @click="handleClick" > 确认 </el-button> </div> <!-- 商品选择 --> <el-dialog width="858px" title="选择商品" v-model="skuDialogVisible" :close-on-click-modal="false" :close-on-press-escape="false" append-to-body @open="handleListOpen" @close="handleListClose" > <div class="vm-select-sku-dialog-wrapper"> <!-- 搜索区 --> <el-form ref="form" class="search" :model="listQuery" :label-width="formLabelWidth" > <el-form-item label="商品名称:"> <el-row type="flex" justify="space-between"> <el-col> <el-input v-model="listQuery.skuName" placeholder="请输入" clearable class="sku-name" @input="resetPageIndex" /> </el-col> <el-col> <el-button type="primary" class="el-button--primary1" @click="handleListOpen" > <el-icon><Search /></el-icon> 查询 </el-button> </el-col> </el-row> </el-form-item> </el-form> <el-scrollbar ref="scroll2" v-loading="listSkuLoading" class="scrollbar" > <el-row v-loading="listSkuLoading" :gutter="20"> <el-col v-for="(item, index) in listSkuData.rows" :key="index" :span="5" > <div class="item"> <!-- TODO: 只有一行的时候考虑 --> <div class="sku" :class="index < 5 ? 'space' : ''" @click="handleCurrentChange(index)" > <img v-show="currentRow.skuId === item.skuId" class="selected" src="@/assets/vm/selected.png" /> <img class="img" :src="item.skuImage" /> <div class="name" :title="item.skuName"> {{ item.skuName }} </div> </div> </div> </el-col> </el-row> </el-scrollbar> <el-icon v-if="pageCount > 1" class="arrow arrow-left" :class="pageCount === 1 ? 'disabled' : ''" @click="handleClickPrev" ><ArrowLeft /></el-icon> <el-icon v-if="pageCount > 1" class="arrow arrow-right" :class="listQuery.pageIndex === pageCount ? 'disabled' : ''" @click="handleClickNext" ><ArrowRight /></el-icon> </div> <div class="dialog-footer"> <el-button type="primary" class="el-button--primary1" @click="handleSelectClick" > 确认 </el-button> </div> </el-dialog> <!-- end --> </el-dialog> <!-- end --> </template> <script setup> import { require } from '@/utils/validate'; const { proxy } = getCurrentInstance(); // 滚动插件 import { ElScrollbar } from 'element-plus'; // 接口 import { getGoodsList, getGoodsType, channelConfig, } from '@/api/manage/channel'; import { listSku } from '@/api/manage/sku'; // 内部组件 import ChannelDialogItem from './ChannelDialogItem.vue'; import { watch } from 'vue'; // 获取父组件参数 const props = defineProps({ // 弹层隐藏显示 goodVisible: { type: Boolean, default: false, }, // 触发的货道信息 goodData: { type: Object, default: () => {}, }, }); // 获取父组件的方法 const emit = defineEmits(['handleCloseGood']); // ******定义变量****** const visible = ref(false); //货道弹层显示隐藏 const scrollStatus = ref('LEFT'); const listLoading = ref(false); const vmType = ref({}); //获取货道基本信息 const channels = ref({}); //货道数据 const scroll = ref(null); //滚动条ref // 监听货道弹层显示/隐藏 watch( () => props.goodVisible, (val) => { visible.value = val; } ); // ******定义方法****** // 获取货道基本信息 const handleGoodOpen = () => { getVmType(); channelList(); }; // 获取货道基本信息 const getVmType = async () => { const { data } = await getGoodsType(props.goodData.vmTypeId); vmType.value = data; }; // 获取货道列表 const channelList = async () => { listLoading.value = true; const { data } = await getGoodsList(props.goodData.innerCode); channels.value = data; listLoading.value = false; }; const computedCurrentIndex = (vmRowIndex, vmColIndex) => { return (vmRowIndex - 1) * vmType.value.vmCol + vmColIndex - 1; }; // 关闭货道弹窗 const handleGoodcClose = () => { visible.value = false emit('handleCloseGood'); }; const handleClickPrevButton = () => { scroll.value.wrapRef.scrollLeft = 0; scrollStatus.value = 'LEFT'; }; const handleClickNextButton = () => { scroll.value.wrapRef.scrollLeft = scroll.value.wrapRef.scrollWidth; scrollStatus.value = 'RIGHT'; }; const currentIndex = ref(0); const channelCode = ref(''); const skuDialogVisible = ref(false); //添加商品弹层 // 删除选中的商品 const openRemoveSkuDialog = (index, code) => { currentIndex.value = index; channelCode.value = code; channels.value[currentIndex.value].skuId = '0'; channels.value[currentIndex.value].sku = undefined; }; // 添加商品 const listQuery = ref({ pageIndex: 1, pageSize: 10, }); //搜索商品 const listSkuLoading = ref(false); //商品列表loading const listSkuData = ref({}); //商品数据 const currentRow = ref({}); const pageCount = ref(0); //总页数 const channelModelView = ref({}); // 商品弹层列表 const handleListOpen = async () => { listSkuLoading.value = true; listQuery.value.skuName = listQuery.value.skuName || undefined; const data = await listSku(listQuery.value); listSkuData.value = data; pageCount.value = Math.ceil(data.total / 10); listSkuLoading.value = false; }; // 打开商品选择弹层 const openSetSkuDialog = (index, code) => { currentIndex.value = index; channelCode.value = code; skuDialogVisible.value = true; }; // 关闭商品详情 const handleListClose = () => { skuDialogVisible.value = false; }; // 商品上一页 const handleClickPrev = () => { if (listQuery.value.pageIndex === 1) { return; } listQuery.value.pageIndex--; handleListOpen(); }; // 商品下一页 const handleClickNext = () => { if (listQuery.value.pageIndex === pageCount.value) { return; } listQuery.value.pageIndex++; handleListOpen(); }; // 搜索 const resetPageIndex = () => { listQuery.value.pageIndex = 1; handleListOpen(); }; // 商品选择 const handleCurrentChange = (i) => { // TODO:点击取消选中功能 currentRow.value = listSkuData.value.rows[i]; }; // 确认商品选择 const handleSelectClick = (sku) => { handleListClose(); channels.value[currentIndex.value].skuId = currentRow.value.skuId; channels.value[currentIndex.value].sku = { skuName: currentRow.value.skuName, skuImage: currentRow.value.skuImage, }; }; // 确认货道提交 const handleClick = async () => { channelModelView.value.innerCode = props.goodData.innerCode; channelModelView.value.channelList = channels.value.map((item) => { return { innerCode: props.goodData.innerCode, channelCode: item.channelCode, skuId: item.skuId, }; }); const res = await channelConfig(channelModelView.value); if (res.code === 200) { proxy.$modal.msgSuccess('操作成功'); visible.value = false emit('handleCloseGood'); } }; </script> // <style lang="scss" scoped src="../index.scss"></style> -
组件
ChannelDialogItem.vuehtml<template> <div v-if="channel" class="item"> <div class="code"> {{ channel.channelCode }} </div> <div class="sku"> <img class="img" :src="channel.sku&&channel.sku.skuImage ? channel.sku.skuImage : require('@/assets/vm/default_sku.png')" /> <div class="name" :title="channel.sku ? channel.sku.skuName : '暂无商品'"> {{ channel.sku ? channel.sku.skuName : '暂无商品' }} </div> </div> <div> <el-button type="text" class="el-button--primary-text" @click="handleSetClick" > 添加 </el-button> <el-button type="text" class="el-button--danger-text" :disabled="!channel.sku ? true : false" @click="handleRemoveClick" > 删除 </el-button> </div> </div> </template> <script setup> import { require } from '@/utils/validate'; const props = defineProps({ currentIndex: { type: Number, default: 0, }, channel: { type: Object, default: () => {}, }, }); const emit = defineEmits(['openSetSkuDialog','openRemoveSkuDialog']); // 添加商品 const handleSetClick = () => { emit('openSetSkuDialog', props.currentIndex, props.channel.channelCode); }; // 删除产品 const handleRemoveClick = () => { emit('openRemoveSkuDialog', props.currentIndex, props.channel.channelCode); }; </script> <style scoped lang="scss"> @import '@/assets/styles/variables.module.scss'; .item { position: relative; width: 150px; height: 180px; background: $base-menu-light-background; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.06); border-radius: 4px; text-align: center; .code { position: absolute; top: 10px; left: 0; width: 43px; height: 23px; line-height: 23px; background: #829bed; border-radius: 0px 10px 10px 0px; font-size: 12px; color: $base-menu-light-background; } .sku { height: 135px; padding-top: 16px; background-color: #f6f7fb; border-radius: 4px; .img { display: inline-block; width: 84px; height: 78px; margin-bottom: 10px; object-fit: contain; } .name { padding: 0 16px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } } } </style> -
样式
css@import '@/assets/styles/variables.module.scss'; :deep(.el-dialog) { .vm-config-channel-dialog-wrapper, .vm-select-sku-dialog-wrapper { .scrollbar { width: 814px; height: 384px; margin: 0 auto; .el-scrollbar__wrap { scroll-behavior: smooth; } .el-row { flex-wrap: nowrap; } .el-col-12 { width: 50%; flex: 0; } .el-scrollbar__bar.is-horizontal { display: none; } } } } .vm-config-channel-dialog-wrapper, .vm-select-sku-dialog-wrapper { position: relative; width: 847px; margin: 0 auto; .channel-basic { display: flex; align-items: center; width: 847px; height: 56px; margin-bottom: 16px; background: $--color-function3; .vm-row { margin-left: 43px; } .vm-col { margin-left: 55px; } .channel-max-capacity { flex: 1; margin-left: 54px; } .business-top10 { margin-right: 22px; } } .space { margin-bottom: 20px; } // TODO: 样式和vm-select-sku-dialog冗余了 .arrow { position: absolute; top: 50%; width: 50px !important; height: 50px !important; color: $--color-black; cursor: pointer; } .disabled { color: $--border-color-base; cursor: auto; } .arrow-left { left: -45px; } .arrow-right { right: -45px; } } .vm-select-sku-dialog-wrapper { width: 750px; .scrollbar { width: 750px; height: auto; .el-row { display: flex; flex-wrap: wrap; } :deep(.el-scrollbar__bar.is-horizontal) { display: none; } } .sku { position: relative; width: 134px; height: 134px; padding-top: 16px; background-color: #f6f7fb; -webkit-box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06); box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.06); border-radius: 4px; text-align: center; cursor: pointer; .selected { position: absolute; top: 0; left: 0; } .img { display: inline-block; width: 83px; height: 84px; margin-bottom: 5px; object-fit: contain; } } .el-col-5 { width: 20%; flex: 0; } .el-col-24 { flex: none; margin-right: 16px; } } -
index.vue中添加
html<el-button link type="primary" @click="handleGoods(scope.row)" v-hasPermi="['manage:vm:edit']">货道</el-button> <!-- 货道组件 --> <ChannelDialog :goodVisible="goodVisible" :goodData="goodData" @handleCloseGood="handleCloseGood"></ChannelDialog> <!-- end --> <style lang="scss" scoped src="./index.scss"></style>js// ********************货道******************** // 货道组件 import ChannelDialog from './components/ChannelDialog.vue'; const goodVisible = ref(false); //货道弹层显示隐藏 const goodData = ref({}); //货道信息用来拿取 vmTypeId和innerCode // 打开货道弹层 const handleGoods = (row) => { goodVisible.value = true; goodData.value = row; }; // 关闭货道弹层 const handleCloseGood = () => { goodVisible.value = false; }; // ********************货道end******************** -
配套后端代码修改
我上面获取货道的接口写得有些和老师写的前端代码前端代码对不上,这里修改一下
VmTypeControllerjava/** * 获取设备类型详细信息 */ @PreAuthorize("@ss.hasPermi('manage:vm_type:query')") @GetMapping(value = "/{id}") public AjaxResult getInfo(@PathVariable("id") Long id) { return success(vmTypeService.selectVmTypeById(id)); }这样,货道信息就能正常显示了(如果不能正常显示注意检查数据库表中,设备类型表、设备表的对应关系,有没有异常数据)

-
-
-
后端代码开发
-
管理员对智能售货机内部的货道进行商品摆放的管理

-
根据售货机编号查询货道列表
-
Controller
-
ChannelVO
java@Data public class ChannelVO extends Channel { // 商品对象 private Sku sku; } -
Controller
java/** * 据货道内部编号查询货道列表 */ @PreAuthorize("@ss.hasPermi('manage:channel:list')") @GetMapping("/list/{innerCode}") public AjaxResult listByInnerCode(@PathVariable String innerCode) { return success(channelService.selectChannelVOListByInnerCode(innerCode)); }
-
-
Service
-
接口类
java// 根据货道内部编号查询货道列表 List<ChannelVO> selectChannelVOListByInnerCode(String innerCode); -
实现类
java// 根据货道内部编号查询货道列表 @Override public List<ChannelVO> selectChannelVOListByInnerCode(String innerCode) { return channelMapper.selectChannelVOListByInnerCode(innerCode); }
-
-
Mapper
-
mapper
java// 根据货道内部编号查询货道列表 List<ChannelVO> selectChannelVOListByInnerCode(String innerCode); -
xml
xml<resultMap type="ChannelVO" id="ChannelVOResult"> <result property="id" column="id" /> <result property="channelCode" column="channel_code" /> <result property="skuId" column="sku_id" /> <result property="vmId" column="vm_id" /> <result property="innerCode" column="inner_code" /> <result property="maxCapacity" column="max_capacity" /> <result property="currentCapacity" column="current_capacity" /> <result property="lastSupplyTime" column="last_supply_time" /> <result property="createTime" column="create_time" /> <result property="updateTime" column="update_time" /> <association property="sku" javaType="Sku" column="sku_id" select="com.dkd.manage.mapper.SkuMapper.selectSkuBySkuId"/> </resultMap> <!-- ... --> <select id="selectChannelById" parameterType="Long" resultMap="ChannelResult"> <include refid="selectChannelVo"/> where id = #{id} </select>
-
-
实现效果(注意不同表的关联关系,测试的户籍要对应正确):

-
-
完成货道配置,实现货道关联商品

-
Controller
-
Dto
ChannelSkuDto.java
java@Data public class ChannelSkuDto{ private String innerCode; // 售货机编号 private String channelCode; // 货道编号 private Long skuId; // 商品id }ChannelConfigDto
java@Data public class ChannelConfigDto { private String innerCode; // 售货机编号 private List<ChannelSkuDto> channelList; // 货道编号 } -
Controller
java// 批量设置货道 @PutMapping("/config") @PreAuthorize("@ss.hasPermi('manage:channel:edit')") @Log(title = "售货机货道", businessType = BusinessType.UPDATE) public AjaxResult setChannel(@RequestBody ChannelConfigDto channelConfigDto) { return success(channelService.setChannel(channelConfigDto)); }
-
-
Service
-
接口类
java// 设置货道 int setChannel(ChannelConfigDto channelConfigDto); -
实现类
java@Override public int setChannel(ChannelConfigDto channelConfigDto) { // 1. 将dto转为po对象 List<Channel> channelList = channelConfigDto.getChannelList().stream().map( dto -> { // 根据售货机编号和货道编号查询货道信息 Channel channel = channelMapper.getChannelInfo(dto.getInnerCode(), dto.getChannelCode()); if(channel != null){ // 2.1 封装channel对象 channel.setSkuId(dto.getSkuId()); channel.setUpdateTime(DateUtils.getNowDate()); // 2.2 批量修改货道 channelMapper.updateChannel(channel); } return channel; } ).collect(Collectors.toList()); // 2. 批量修改货道 return channelMapper.updateChannelBatch(channelList); }
-
-
Mapper
-
mapper
java// 根据货道内部编号和货道编号查询货道信息 @Select("select * from channel where inner_code=#{innerCode} and channel_code=#{channelCode}") Channel getChannelInfo(@Param("innerCode") String innerCode,@Param("channelCode") String channelCode); // 批量更新 public int updateChannelBatch(List<Channel> channelList); -
xml
xml<update id="updateChannelBatch" parameterType="java.util.List"> <foreach collection="list" item="item" separator=";"> update channel <trim prefix="SET" suffixOverrides=","> <if test="item.channelCode != null and item.channelCode != ''">channel_code = #{item.channelCode},</if> <if test="item.skuId != null">sku_id = #{item.skuId},</if> <if test="item.vmId != null">vm_id = #{item.vmId},</if> <if test="item.innerCode != null and item.innerCode != ''">inner_code = #{item.innerCode},</if> <if test="item.maxCapacity != null">max_capacity = #{item.maxCapacity},</if> <if test="item.currentCapacity != null">current_capacity = #{item.currentCapacity},</if> <if test="item.lastSupplyTime != null">last_supply_time = #{item.lastSupplyTime},</if> <if test="item.createTime != null">create_time = #{item.createTime},</if> <if test="item.updateTime != null">update_time = #{item.updateTime},</if> </trim> where id = #{item.id} </foreach> </update>
-
-
实现效果

-
-