项目实战:核酸检测平台第四章 冲锋陷阵
摘要:战争,冲在最前面的永远是最危险的人群,新冠之战,冲在最前的则是医护人员、防疫工作者。
核酸检测平台的采集人员APP做为先头部队的重要武器,一定要做的好用、实用,才能解决问题。
本章目标
完成采集人员APP
用到技术:
- 存储过程
- 游标
- 条码、二维码扫码组件
难点
- 选择采集点页面搜索过滤效果
- 行政区划级联选择和默认数据显示
文章目录
- 项目实战:核酸检测平台第四章 冲锋陷阵
- 本章目标
- 概述
- 准备测试数据
- 登录、注册
- 选择采集点
- 开箱
- 转运箱列表页面
- 试管列表、开管
- 试管页面、添加采样人
- 封管、封箱
- 辅助功能
- 变更注册信息
- 试管查箱码
- End
概述
战争,冲在最前面的永远是最危险的人群,新冠之战,冲在最前端的正是医护人员、防疫工作者。
核酸检测平台的采集人员APP做为先头部队的重要武器,一定要做的好用、实用,才能成为核酸采集部队的利器。
虽然我们是做模拟,不去做性能方面的优化,但是在业务逻辑的设计方面一定要尽可能的实用、好用。还要确保数据的准确性。
再来回顾一下核酸采集的流程,这次我们在流程图的最后加上了一列业务逻辑说明。
采集人员模块虽然是一个独立的模块,但还是需要和其它模块协同工作的,其中协同的点有下面几个:
- 箱码、试管码条码要提前生成好,并且箱码、试管码也是要提前生成好数据的。
- 核酸码是由普通用户端填写数据后生成的二维码。
- 封箱后,由转运人员继续后面的操作
总体来说采集人员模块的功能中要注意下面一些点,在功能页面编码的时候要注意。
-
开箱、开管操作时并没有创建数据、而是修改箱码、试管码对应的状态。
-
添加样本(被检测人)信息的时候,有三种添加方式:扫身份证、扫核酸码、手动录入。
- 扫身份证涉及身份证识别模块,可以调用大厂提供的AI接口,最后就是返回文本数据,暂且先不实现。
- 扫核酸码时,核酸码中的数据应是普通用户的信息标识,只是一个数字,这个数字对应的应是people中的标识。所以扫描完成后要到后台提取人员信息。
- 手动录入时,如果people库中已经有数据了,可以在身份证输入完成后,自动提取人员信息;在提交保存信息时,要检测录入人的信息是否在people中,如果没有,应当在people中添加人员信息,便于下次手动录入信息时可以手动录入。
-
添加样本时要检测试管数量,前端界面要提醒剩余样本数量,超过试管数量时要给予提示,为便于采集人员使用,少量超量采集,应当也是允许的。所以在后端添加样本的时候不做强制约束。
-
添加样本时要检查身份证信息重重录入的情况。
-
样本允许修改和删除
-
试管应允许删除,但删除时并不是删除数据,而是还原试管状态。
-
封箱时要检查是否有未封管的试管码,如果有,应禁止封箱。
-
实际运行的模块中有一个变更注册信息功能。
开始不太理解这个功能的意义所在,在用APP的时候如果你留意的话就会发现,选择采集点是从你选择的区开始往下加载数据的,如果变更了注册信息之后,就会切换到其它区。
因为第一章中给大家的APP下载链接是上海市的,为什么要这么做呢?
上海市常住人口2400万,再加上非常住人口,有可能超过3000万,大规模采集的时候一般都是在6小时内完成采集工作,平均算下来一分钟要采集8万份样本,平均一秒种就是将近1400份样本。
采集一份样本在界面上要操作3-5次,与后台接口大概也是3-5次数据操作。所以服务器端平均的并发量在7000左右的级别,峰值并发量可能会更高。这个并发量不算太大,但也不少。完全有可能切分为不同的区采用不同的服务来支撑。
下面我们来开始实现逐步完成这些功能的开发。
准备测试数据
因为该模块和其它模块是有关联的,也就是说有些数据是由其它模块生成之后才到了采集人员模块,都有哪些数据呢?
- 采集点数据
- 箱码数据
- 试管码数据
- 人员信息数据
为了方便在写程序的时候好调试,我们还是需要建出一些测试数据的,其中采集点数据要注意和行政区划数据绑定。
创建测试数据你可以采用手动录入的方式,也可以写个脚本来生成,在这里我用数据库存储过程来写了生成测试数据的脚本.
-- 创建采集点测试数据
-- 创建采集点的是在行政区划中第一个村、社区下创建3个采集点,
-- 所以要先查询出area表中的数据,然后用游标提取出每个村、社区的内容,再生成数据,往point表中添加数据。
-- 为便于区分采集点,采集点名称取村、社区名,在后面加上随机数字。
DROP procedure IF EXISTS generate_points;
CREATE procedure generate_points()
BEGIN
DECLARE _areaId bigint;
DECLARE _pointName varchar(50);
DECLARE _areaName varchar(30);
DECLARE var_done int DEFAULT FALSE;
-- area表中是全国的行政区划,所以不能全查,而是做了一个区的限定。
DECLARE cursor_area CURSOR FOR select areaId,name from area where areacode = 410307 and level=5;
-- 游标结束时会设置var_done为true,后续可以使用var_done来判断游标是否结束
DECLARE CONTINUE HANDLER FOR NOT FOUND SET var_done=TRUE;
open cursor_area;
select_loop:LOOP
FETCH cursor_area INTO _areaId,_areaName;
set _areaName=replace(_areaName,'村民委员会','');
-- 每个村随机生成3个采集点
SET _pointName = concat(_areaName,'采集点',CEILING(RAND()*50));
insert into point (pointName,areaCode)
values (_pointName,_areaId);
SET _pointName = concat(_areaName,'采集点',CEILING(RAND()*50));
insert into point (pointName,areaCode)
values (_pointName,_areaId);
SET _pointName = concat(_areaName,'采集点',CEILING(RAND()*50));
insert into point (pointName,areaCode)
values (_pointName,_areaId);
IF var_done THEN
LEAVE select_loop;
END IF;
END LOOP;
CLOSE cursor_area;
End;
call generate_points();
-- 创建采集箱测试数据,boxCode 从10001开始,创建100个
-- 箱码数据boxCode一般是连续的,并且是不能够重复的,创建测试数据的时候要注意这个问题。
-- 下面的存储过程有两个入参,一个是开始编码,一个是结束编码。
DROP procedure IF EXISTS generate_boxs;
CREATE procedure generate_boxs(IN beginCode bigint,in endCode int)
BEGIN
select_loop:LOOP
IF beginCode <=endCode THEN
-- 箱码要初始status数据为0,表示箱码已打印。其它信息在开箱的时候再更新
insert into box (boxCode,`status`)
values(beginCode,0);
set beginCode = beginCode+1;
else
LEAVE select_loop;
end if;
END LOOP;
END;
call generate_boxs(100001,100100);
-- 创建试管码测试数据
-- 规则与箱码的生成一样
DROP procedure IF EXISTS generate_testtubes;
CREATE procedure generate_testtubes(IN beginCode bigint,in endCode bigint)
BEGIN
select_loop:LOOP
IF beginCode <=endCode THEN
-- 试管码要初始status数据为0,表示试管码已打印。其它信息在开管的时候再更新
insert into testtube (testTubeCode,`status`)
values(beginCode,0);
set beginCode = beginCode+1;
else
LEAVE select_loop;
end if;
END LOOP;
END;
call generate_testtubes(20221001000001,20221001000501);
-- 创建人员信息测试数据
-- 为了在测试的时候方便区分,第个人的名字后面加上了一个编号。
DROP procedure IF EXISTS generate_peoples;
CREATE procedure generate_peoples(IN beginIdcardCode bigint,in endIdcardCode bigint)
BEGIN
declare _index int;
set _index = 1;
select_loop:LOOP
IF beginIdcardCode <=endIdcardCode THEN
insert into people (idcard,name,tel)
values(beginIdcardCode,concat('张三',_index),(18700010000+_index));
set beginIdcardCode = beginIdcardCode+1;
set _index =_index+1;
else
LEAVE select_loop;
end if;
END LOOP;
END;
call generate_peoples(280103199901020001,280103199901020100);
登录、注册
所以软件系统最常见的功能,需要注意的时候正常的系统中密码是一定要进行加密的。加密方法最基本最常见的就是MD5加密,但是他的强度是比较弱的,相对比较容易被破解。高级一点可以采用加随机盐的方式,本篇采用最基本的加密方式。
@RestController
@RequestMapping("/collector")
public class CollectorController {
@Autowired
ICollectorService collectorService;
@PostMapping("login")
public ResultModel<Collector> login(@RequestBody @Valid LoginModel model) throws BusinessException, UnsupportedEncodingException, NoSuchAlgorithmException {
Collector collector = collectorService.login(model);
//虽然采用了token的登录验证方式,但还是要在session中存储一下,取当前登录用户时就可以直接从session取,减少sql查询请求
SessionUtil.setCurrentUser(collector);
return ResultModel.success(collector);
}
}
登录接口参数类型为LoginModel,是单独定义和BO类,放在采集人员模块的pojo.bo包中。
类的字段上还定义了自定义验证规则。要注意的时要想让验证规则生效,还需要在controller参数前面加上@Valid注解。
package com.hawkon.collector.pojo.bo;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
@Data
public class LoginModel {
@NotEmpty(message = "手机号不能为空")
@Size(min = 11, max = 11,message = "手机号必须是11位")
private String tel;
@NotEmpty(message = "密码不能为空")
@Size(min = 6,message = "密码至少6位 ")
private String password;
}
@Service
public class CollectorService implements ICollectorService {
@Autowired
CollectorDao collectorDao;
@Override
public Collector login(LoginModel model) throws BusinessException, UnsupportedEncodingException, NoSuchAlgorithmException {
String md5Password = Md5Util.encode(model.getPassword());
//数据库中存储的是加密的密码,所以要先把输入的密码加密再进行查询
Collector collector = collectorDao.login(model.getTel(), md5Password);
if (collector == null) {
throw new BusinessException("用户名或密码不正确", ResultCodeEnum.LOGIN_ERROR);
}
String token = getToken(collector);
//把token存到cokkie中,并设置过期时间,一天
Cookie cookie = new Cookie("token", token);
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60);
Global.response.addCookie(cookie);
//返回前端之前要把密文的密码清除掉。
collector.setPassword(null);
return collector;
}
/**
* 根据用户信息生成token
* @param user
* @return
*/
public String getToken(Collector user) {
Date start = new Date();
//有效期设置为7天是为开发阶段方便,实际项目应用中一天足够。
long currentTime = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000;//7天有效时间
Date end = new Date(currentTime);
String token = "";
token = JWT.create()
.withAudience(user.getCollectorId().toString())
.withIssuedAt(start)
.withExpiresAt(end)
.sign(Algorithm.HMAC256(user.getPassword()));
return token;
}
}
//MD5加密工具类
public class Md5Util {
public static String encode(String str) throws NoSuchAlgorithmException, UnsupportedEncodingException {
//确定计算方法
MessageDigest md5=MessageDigest.getInstance("MD5");
BASE64Encoder base64en = new BASE64Encoder();
//加密后的字符串
String newstr=base64en.encode(md5.digest(str.getBytes("utf-8")));
return newstr;
}
}
注册的时候要验证电话、身份证号是否已经注册,还要注意,密码默认是身份证后6位,密码也要注意加密一下。
从实际来看这个APP的密码比较随意,弄个身份证后6位了事,密码强度看来非常的差。没错,这种密码机制的涉及在任何一个互联网项目中看起来是不可思议的。但是,对于这个项目来说密码强度过于苛刻其实使用起来并不便捷,况且密码被破解并不会带来太过于严重的后果。所以这样做从实际使用的角度来说,是可以接受的。
@Override
public void register(Collector model) throws Exception {
Collector modelByTel = collectorDao.getCollectorByTel(model.getTel());
if (modelByTel != null) {
throw new BusinessException("电话号码已注册,请直接登录", ResultCodeEnum.REGISTER_ERROR);
}
Collector modelByIdcard = collectorDao.getCollectorByIdCard(model.getIdcard());
if (modelByIdcard != null) {
throw new BusinessException("身份证号已注册", ResultCodeEnum.REGISTER_ERROR);
}
//取身份证后6位进行加密
String md5String = Md5Util.encode(model.getIdcard().substring(model.getIdcard().length() - 6));
model.setPassword(md5String);
model.setCollectorType(CollectorType.Volunteer.getCode());
collectorDao.register(model);
}
Login.vue页面
<script setup>
import {
ref
} from 'vue';
import {
Toast
} from 'vant';
import {
RouterLink,useRouter
} from 'vue-router'
//引用封装过后的axios组件
import api from '@/common/api.js';
const router = useRouter();
const loginForm = ref({
tel: '18638898990',
password: '156011'
});
const now = new Date();
const onSubmit = (values) => {
api.post("/collector/login", loginForm.value)
.then(res => {
//代码到这里一定是登录成功,因为失败的时候会被api.js中的拦截器处理掉。
//成功的时候把返回的数据保存在sessionStorage中,因为sessionStorage只能保存String,所以要用JSON.stringify转换一下。
window.sessionStorage["user"] = JSON.stringify(res.data);
//跳转路由
router.push("/SelectPoint");
})
.catch(res => {
console.log("错误", res)
})
};
</script>
<template>
<van-row>
<van-col span="24">
<h2 style="text-align: center;">全场景疫情病原体检测信息系统</h2>
</van-col>
<van-col span="24">
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field v-model="loginForm.tel" name="tel" label="手机号" placeholder="请输入手机号"
:rules="[{ required: true, message: '请填写手机号' }]" />
<van-field v-model="loginForm.password" type="password" name="password" label="密码"
placeholder="默认密码为身份证后6位" :rules="[{ required: true, message: '请填写密码' }]" />
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
</van-col>
<van-col span="12" style="padding-left: 1em;">
<RouterLink to="/Register">注册</RouterLink>
</van-col>
<van-col span="12" style="text-align: right;padding-right: 1em">
<RouterLink to="/Forget">忘记密码</RouterLink>
</van-col>
</van-row>
</template>
Register.vue页面
注册页面的多级级联比较麻烦,考虑过一次返回到前端,由前端来过滤,但是行政区划直接到第5级数据量还是挺大的,因此还是放弃了。
<script setup>
import {
ref
} from 'vue';
import {
useRouter
} from "vue-router";
import api from "../common/api.js";
const router = useRouter();
const onClickLeft = () => {
router.push("/");
}
const registerForm = ref({});
//重置registerForm上面的指定级别的行政区划代码
const resetArea = (props) => {
if (props) {
props.forEach(key => registerForm.value[key] = null);
}
}
//选择省
//vant的固定用法,控制显示省的选择组件。
const showProvincePicker = ref(false);
const provinces = ref([]);
api.post("/area/getProvinces")
.then(res => {
provinces.value = res.data;
})
//选中省的时候要重置下面的市、县区等。
const confirmProvince = (value) => {
registerForm.value.provinceCode = value.provinceCode;
registerForm.value.provinceName = value.name;
showProvincePicker.value = false;
//选中之后重新加载市的清单
getCitiesByProvinceCode();
resetArea(["cityCode","cityName","areaCode","areaName","streetCode","streetName","committeeCode","committeName"]);
}
const areaFieldName = {
text: 'name'
}
const cities = ref([]);
const getCitiesByProvinceCode = () => {
api.post("/area/getCitiesByProvinceCode", {
provinceCode: registerForm.value.provinceCode
})
.then(res => {
cities.value = res.data;
})
}
const showCityPicker = ref(false);
const confirmCity = (value) => {
registerForm.value.cityCode = value.cityCode;
registerForm.value.cityName = value.name;
showCityPicker.value = false;
getAreasByCityCode();
resetArea(["areaCode","areaName","streetCode","streetName","committeeCode","committeName"]);
}
//区县选择器
const areas = ref([]);
const getAreasByCityCode = () => {
api.post("/area/getAreasByCityCode", {
cityCode: registerForm.value.cityCode
})
.then(res => {
areas.value = res.data;
})
}
const showAreaPicker = ref(false);
const confirmArea = (value) => {
registerForm.value.areaCode = value.areaCode;
registerForm.value.areaName = value.name;
showAreaPicker.value = false;
getStreetsByAreaCode();
resetArea(["streetCode","streetName","committeeCode","committeName"]);
}
//街道/乡镇选择器
const streets = ref([]);
const getStreetsByAreaCode = () => {
api.post("/area/getStreetsByAreaCode", {
areaCode: registerForm.value.areaCode
})
.then(res => {
streets.value = res.data;
})
}
const showStreetPicker = ref(false);
const confirmStreet = (value) => {
registerForm.value.streetCode = value.streetCode;
registerForm.value.streetName = value.name;
//最终提交的行政区划ID,要么是街道,要么是村、社区,需要保存他们的areaId
registerForm.value.areaId =value.areaId;
showStreetPicker.value = false;
getCommitteesByStreetCode();
resetArea(["committeeCode", "committeeName"]);
}
//村/社区选择器
const committees = ref([]);
const getCommitteesByStreetCode = () => {
api.post("/area/getCommitteesByStreetCode", {
streetCode: registerForm.value.streetCode
})
.then(res => {
committees.value = res.data;
})
}
const showCommitteePicker = ref(false);
const confirmCommittee = (value) => {
registerForm.value.committeeCode = value.committeeCode;
registerForm.value.committeeName = value.name;
//最终提交的行政区划ID,要么是街道,要么是村、社区,需要保存他们的areaId
registerForm.value.areaId =value.areaId;
showCommitteePicker.value = false;
}
//表单验证
const repeatValidator = (val) => {
return val == registerForm.value.tel;
}
const register = ()=>{
var registerModel = {
name:registerForm.value.name,
tel:registerForm.value.tel,
idcard:registerForm.value.idcard,
//采集人员注册时可以选择街道,也可以选择到村/社区,无论哪一种,最后取的都是areaId
areaId:registerForm.value.areaId
}
api.post("/collector/register",registerModel)
.then(res=>{
router.push("/");
})
}
</script>
<template>
<van-nav-bar title="注册" left-text="返回" left-arrow @click-left="onClickLeft" />
<van-form style="padding: 0.5rem;" @submit="register">
<h3>第一步:填写身份信息</h3>
<van-cell-group inset>
<van-field required v-model="registerForm.name" name="name" label="姓名" placeholder="姓名"
:rules="[{ required: true, message: '请输入姓名' }]" />
<van-field required v-model="registerForm.idcard" name="idcardPattern" label="身份证号" placeholder="身份证号"
:rules="[{ pattern:/^([1-6][1-9]|50)\d{4}(18|19|20)\d{2}((0[1-9])|10|11|12)(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入18位身份证号',trigger:'onBlur' }]" />
<van-field required v-model="registerForm.tel" name="tel" placeholder="手机号" label="手机号"
:rules="[{ pattern:/^1(3[0-9]|4[5,7]|5[0,1,2,3,5,6,7,8,9]|6[2,5,6,7]|7[0,1,7,8,6]|8[0-9]|9[1,8,9])\d{8}$/, message: '请输入正确的手机号',trigger:'onBlur' }]" />
<van-field required v-model="registerForm.telAgain" name="tel" label="确认手机号" placeholder="确认手机号"
:rules="[{ validator:repeatValidator,trigger:'onBlur', message: '两次手机号不一致'}]" />
</van-cell-group>
<h3>第二步:填写所属机构</h3>
<van-cell-group inset>
<van-field required v-model="registerForm.provinceName" name="provinceCode" label="省" is-link readonly
placeholder="点击选择省" @click="showProvincePicker=true" :rules="[{ required: true, message: '请选择省' }]" />
<van-popup required v-model:show="showProvincePicker" position="bottom">
<van-picker :columns="provinces" :columns-field-names="areaFieldName" @confirm="confirmProvince"
@cancel="showProvincePicker=false" />
</van-popup>
<van-field required v-model="registerForm.cityName" name="cityCode" label="市" placeholder="请选择市" is-link readonly
@click="showCityPicker=true" :rules="[{ required: true, message: '请选择市' }]" />
<van-popup required v-model:show="showCityPicker" position="bottom">
<van-picker :columns="cities" :columns-field-names="areaFieldName" @confirm="confirmCity"
@cancel="showCityPicker=false" />
</van-popup>
<van-field required v-model="registerForm.areaName" name="areaCode" :rules="[{ required: true, message: '请选择区/县' }]"
placeholder="区/县" label="区/县" is-link readonly @click="showAreaPicker=true" />
<van-popup v-model:show="showAreaPicker" position="bottom">
<van-picker :columns="areas" :columns-field-names="areaFieldName" @confirm="confirmArea"
@cancel="showAreaPicker=false" />
</van-popup>
<van-field required v-model="registerForm.streetName" name="streetCode" label="街道/乡镇" placeholder="选择街道/乡镇"
:rules="[{ required: true, message: '请选择街道/乡镇' }]" is-link readonly @click="showStreetPicker=true" />
<van-popup v-model:show="showStreetPicker" position="bottom">
<van-picker :columns="streets" :columns-field-names="areaFieldName" @confirm="confirmStreet"
@cancel="showStreetPicker=false" />
</van-popup>
<van-field v-model="registerForm.committeeName" name="committeeCode" label="村/社区" placeholder="选择村/社区"
is-link readonly @click="showCommitteePicker=true" />
<van-popup v-model:show="showCommitteePicker" position="bottom">
<van-picker :columns="committees" :columns-field-names="areaFieldName" @confirm="confirmCommittee"
@cancel="showCommitteePicker=false" />
</van-popup>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
</template>
<style>
h3 {
margin-top: 1rem;
}
</style>
选择采集点
这个页面有一点有难度的地方,页面中有搜索功能,搜索的时候过滤的是采集点,但是显示的时候不仅仅要过滤采集点,还要过滤对应的县区、街道、村社区,以便快速选中。
这个效果可以在前端做,也可以在后端做,项目的使用量来说,在前端来做过滤对服务器的压力会小一些。
因此后端直接返回所有的采集点,行政区划数据则通过注册的采集人员的行政区划数据显示对应区的行政区划数据。
后端代码:
//controller层
/**
* 获取区下面的所有行政区划,由前端来处理
* @param model
* @return
*/
@PostMapping("getAllAreaByAreaCode")
public ResultModel<List<Area>> getAllAreaByAreaCode(@RequestBody Collector model){
//前端直接传回当前用户的areaid,是直接到社区的,这里要转换为区的编码,并把区下面的所有数据全部取回;
Long areaId = model.getAreaId();
Long areaCode = areaId/1000000;
List<Area> areas = areaService.getAreasByAreaCode(areaCode);
return ResultModel.success(areas);
}
//service层
@Override
public List<Area> getAreasByAreaCode(Long areaCode) {
List<Area> areas = areaDao.getAreasByAreaCode(areaCode);
return areas;
}
//获取采集点
//constroller层
@PostMapping("getPoints")
public ResultModel<List<Point>> getPoints() throws BusinessException {
List<Point> provinces = pointService.getPointsByCurrentUser();
return ResultModel.success(provinces);
}
//service层
@Autowired
PointDao pointDao;
@Override
public List<Point> getPointsByCurrentUser() throws BusinessException {
Collector currentUser = SessionUtil.getCurrentUser();
//因为collector中存的areaId是12位的,可以精确到村社区,
//但是选择采集点是从区开始的,只列出采集人员所在区的采集点。
//采集点上的areaId也是精确到村社区一层的,而查询的时候是需要将区下面的所有采集点查询出来,
//所以查询采集点的时候先取出当前采集人员绑定的areaId,再换算成区的areaCode,
// 例如,采集人员的areaId是410325108204,区的id应是410325,所以用areaId/1000000即可得到区的id
//执行查询的时候,采集点中的areaId存的也是到村/社区的,在sql语句中可以采用Like运算,
//前端页面上的搜索功能可以在前商实现,这样可以减少数据查询的请求次数,减少服务器压力。
Long areaCode = currentUser.getAreaId()/1000000;
return pointDao.getPointsByAreaCode(areaCode);
}
SQL语句:
<select id="getAreasByAreaCode" resultType="com.hawkon.common.pojo.vo.AreaTree">
select *
from area
where areaCode = #{areaCode}
</select>
<select id="getPointsByAreaCode" resultType="com.hawkon.common.pojo.Point">
select *
from point
where areaCode like concat(#{areaCode},'%');
</select>
前端代码
SelectPoint.vue
<script setup>
import {
ref
} from 'vue';
import {
useRouter
} from "vue-router";
const router = useRouter();
import {
Toast
} from 'vant';
import api from '@/common/api.js';
const onClickLeft = () => {
api.post("/collector/logout")
.then(res => {
router.push("/");
})
};
//存放后台取到的所有行政区划数据,数据对象名称和区县的名称重复,这里用al
const allAreaList = ref([]);
const allPoint = ref([]);
//存放过滤后的区县一级数据。
//areas的结构为 areas里是区的数组,区下包含街道数组、街道下包含村、社区数组,村、社区下包含采集点数组。
const areas = ref([]);
//检索的关键字
const key = ref("");
const currentPoint = ref({});
const loadAreaList = () => {
var collector = JSON.parse(sessionStorage["user"])
api.post("/area/getAllAreaByAreaCode", {
areaId: collector.areaId
})
.then(res => {
allAreaList.value = res.data;
queryTrees();
})
}
api.post("/point/getPoints")
.then(res => {
allPoint.value = res.data
loadAreaList();
});
//上面是加载数据,然后是显示数据,因为有搜索功能,所以在显示的数据的时候搜索关键字为空的时候也要一并考虑。
const queryTrees = () => {
//定义街道和村两级map,存储搜索结果涉及的街道和村,便于最后过滤。
//区一级不用过滤。
let streetCodeMap = {};
let committeeCodeMap = {};
//定义采集点map,用areaid做为key,暂时存储一下,在方法的最后方便往commmittee上添加,这样不需要再循环一次了。
let pointsMap = {};
let searched = key.value?true:false;
//定义个数组变量,不要直接操作points.value,会触发监听器。
for (let i = 0; i < allPoint.value.length; i++) {
let item = allPoint.value[i];
//检索时名字包含或没有检索时往pointsMap里添加
if (item.pointName.indexOf(key.value) >= 0 || !searched) {
let areaId = parseInt(item.areaCode);
if (!(pointsMap[areaId])) {
//如果关键字还没值,则需要初始化为数组
pointsMap[areaId] = [];
}
pointsMap[areaId].push((item));
//采集点的areaCode定位的就村,直接把map中对应areaCode设置true
committeeCodeMap[areaId] = true;
//街道一级要把areaID最后三位设置为000.
let streetCode = Math.floor(areaId / 1000) * 1000;
streetCodeMap[streetCode] = true;
}
}
let arr_areas = [];
let arr_streets = [];
let arr_committees = [];
let areaItem = null;
let streetItem = null;
//数据库里读取出来的area都是一层一层按顺序的,可以用areaItem,steetItem两个变量往子项里添加数据。如果不按顺序那就不能这么干了。
for (var i = 0; i < allAreaList.value.length; i++) {
let item = allAreaList.value[i];
if (item.level == 3) {
//区县
arr_areas.push(item);
areaItem = item;
//初始化街道数组
areaItem.streets = [];
areaItem.showChild = searched;
}
if (item.level == 4) {
//乡镇街道,如果搜索条件没有东西或街道在检索结果中
if (streetCodeMap[item.areaId] || !searched) {
//如果检索采集点里有该乡镇的areaId,再往数组里添加
areaItem.streets.push(item);
streetItem = item;
//初始化村、社区数组
streetItem.committees = [];
streetItem.showChild = searched;
}
}
if (item.level == 5) {
if (committeeCodeMap[item.areaId] || !searched) {
//如果检索采集点里有该村、社区的areaId,再往数组里添加
streetItem.committees.push(item);
item.points = pointsMap[item.areaId];
item.showChild = searched;
}
}
}
areas.value = arr_areas;
};
const selectPoint = (point) => {
if (currentPoint.value) {
currentPoint.value.selected = false;
}
currentPoint.value = point;
point.selected = true;
}
const toBoxPage = () => {
if (currentPoint.value.pointId) {
localStorage["currentPoint"] = JSON.stringify(currentPoint.value);
router.push("/Box")
} else {
Toast.fail('请先选择采集点');
}
}
</script>
<template>
<van-nav-bar title="选择采集点" left-text="退出" left-arrow @click-left="onClickLeft" />
<van-search v-model="key" show-action shape="round" background="#4fc08d" placeholder="请输入采集点" @search="queryTrees">
<template #action>
<div @click="queryTrees">搜索</div>
</template>
</van-search>
<div class="point-tree">
<ul>
<li v-for="(area) in areas" :key="area.areaId">
<van-space align="center" @click="area.showChild=!area.showChild" >
<van-icon :name="!area.showChild?'plus':'minus'" />
{{area.name}}
</van-space>
<ul v-show="area.showChild">
<li v-for="(street) in area.streets" :key="street.areaId">
<van-space align="center" @click="street.showChild=!street.showChild" >
<van-icon :name="!street.showChild?'plus':'minus'"
/>
{{street.name}}
</van-space>
<ul v-show="street.showChild">
<li v-for="(committee) in street.committees" :key="committee.areaId">
<van-space align="center" @click="committee.showChild=!committee.showChild" >
<van-icon :name="!committee.showChild?'plus':'minus'"
/>
{{committee.name}}
</van-space>
<ul v-show="committee.showChild">
<li class="bline" v-for="(point) in committee.points" :key="point.pointId">
<van-space align="center" @click="selectPoint(point)">
<van-icon :name="point.selected?'success':''" />
{{point.pointName}}
</van-space>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
<van-tabbar v-model="active">
<van-tabbar-item>
<van-button type="primary" size="normal" @click="toBoxPage">确定</van-button>
</van-tabbar-item>
</van-tabbar>
</template>
<style>
.point-tree {
margin: 1rem;
}
ul {
font-size: 1.5rem;
}
li {
margin-left: 1rem;
}
.bline {
border-bottom: 1px solid #5093eb;
}
li .van-icon-plus {
font-size: 0.5rem;
}
</style>
开箱
开箱后端接口做好箱码的校验和箱码状态的校验即可
//Service层
@Override
public Box openBox(Box model) throws BusinessException {
if(model.getPointId()==null){
throw new BusinessException("参数错误,没有采集点ID", ResultCodeEnum.BUSSINESS_ERROR);
}
Box modelDb = boxDao.getBoxByBoxCode(model.getBoxCode());
if (modelDb == null) {
throw new BusinessException("非法箱码", ResultCodeEnum.BUSSINESS_ERROR);
}
if(modelDb.getStatus()>1){
throw new BusinessException("转运箱已封箱,请检查箱码", ResultCodeEnum.BUSSINESS_ERROR);
}
if(modelDb.getStatus()==1){
return modelDb;
}
Integer collectorId = SessionUtil.getCurrentUser().getCollectorId();
model.setCollectorId(collectorId);
boxDao.openBox(model);
modelDb.setStatus(1);
modelDb.setCollectorId(collectorId);
return modelDb;
}
前端页面Box.vue,因为要使用摄像头实现扫码,我们可以用@zxing/library组件来完成。安装方式: npm install @zxing/library
。
安装完之后还需要把项目改为https方式,否则摄像头将无法开启。
开启Https的方法:
第一步:安装组件,npm install @vitejs/plugin-basic-ssl
第二步:在vite.config.js
中添加代码
//引入类库
import basicSsl from '@vitejs/plugin-basic-ssl'
export default defineConfig({
plugins: [
basicSsl(),//添加插件配置
vue(),
Components({
resolvers: [VantResolver()],
}),
],
....
配置好之后重启前端项目,看到下面的命令提示就OK了。需要注意的是,访问页面的时候也要改成https协议。
@zxing/library组件调用摄像头扫描的方法如下:
页面中显示摄像头拍摄画面的标签
<video ref="video" id="video" class="scan-video" autoplay></video>
扫描的方法:
//打开摄像头
const openCamera = () => {
codeReader.value.getVideoInputDevices().then((videoInputDevices) => {
tipMsg.value = "正在调用摄像头...";
// 因为获取的摄像头有可能是前置有可能是后置,但是一般最后一个会是后置,所以在这做一下处理
// 默认获取第一个摄像头设备id
let firstDeviceId = videoInputDevices[0].deviceId;
if (videoInputDevices.length > 1) {
// 获取后置摄像头
let deviceLength = videoInputDevices.length;
--deviceLength;
firstDeviceId = videoInputDevices[deviceLength].deviceId;
}
decodeFromInputVideoFunc(firstDeviceId);
}).catch((err) => {
tipMsg.value = JSON.stringify(err);
console.error(err);
});
}
//扫描时会不断调用该回调
const decodeFromInputVideoFunc = (firstDeviceId) => {
codeReader.value.reset(); // 重置
codeReader.value.decodeFromInputVideoDeviceContinuously(firstDeviceId, "video", (result, err) => {
tipMsg.value = "正在尝试识别...";
if (result) {
// 获取到的是条码内容,然后在这个if里面写业务逻辑即可
boxCode.value = result.text;
tipMsg.value = "识别成功:" + boxCode.value;
//扫码成功直接调用开箱方法
openBox();
}
if (err && !err) {
tipMsg.value = JSON.stringify(err);
console.error(err);
}
});
}
const closeCamera = () => {
codeReader.value.stopContinuousDecode();
codeReader.value.reset();
}
Box.vue完整的代码
<script setup>
import {
ref
} from 'vue';
import {
useRouter
} from "vue-router";
const router = useRouter();
import {
Toast
} from 'vant';
import api from '@/common/api.js';
import {
BrowserMultiFormatReader
} from "@zxing/library";
const logout = () => {
api.post("/collector/logout")
.then(res => {
router.push("/");
})
};
const collector = ref({});
collector.value = JSON.parse(sessionStorage["user"]);
const point = ref({});
try {
point.value = JSON.parse(localStorage["currentPoint"]);
} catch {
Toast.fail('参数错误');
router.push("/SelectPoint")
}
const openBoxVisable = ref(false);
const scanBoxCode = ref(true);
const codeReader = ref(null);
const boxCode = ref("");
const tipMsg = ref("");
codeReader.value = new BrowserMultiFormatReader();
const beginScanner = () => {
openBoxVisable.value = true;
scanBoxCode.value = true;
openCamera();
}
const openCamera = () => {
codeReader.value.getVideoInputDevices().then((videoInputDevices) => {
tipMsg.value = "正在调用摄像头...";
// 因为获取的摄像头有可能是前置有可能是后置,但是一般最后一个会是后置,所以在这做一下处理
// 默认获取第一个摄像头设备id
let firstDeviceId = videoInputDevices[0].deviceId;
if (videoInputDevices.length > 1) {
// 获取后置摄像头
let deviceLength = videoInputDevices.length;
--deviceLength;
firstDeviceId = videoInputDevices[deviceLength].deviceId;
}
decodeFromInputVideoFunc(firstDeviceId);
}).catch((err) => {
tipMsg.value = JSON.stringify(err);
console.error(err);
});
}
const decodeFromInputVideoFunc = (firstDeviceId) => {
codeReader.value.reset(); // 重置
codeReader.value.decodeFromInputVideoDeviceContinuously(firstDeviceId, "video", (result, err) => {
tipMsg.value = "正在尝试识别...";
if (result) {
// 获取到的是二维码内容,然后在这个if里面写业务逻辑即可
boxCode.value = result.text;
tipMsg.value = "识别成功:" + boxCode.value;
console.log(boxCode.value)
openBox();
}
if (err && !err) {
tipMsg.value = JSON.stringify(err);
console.error(err);
}
});
}
const closeCamera = () => {
codeReader.value.stopContinuousDecode();
codeReader.value.reset();
}
const endScanner = () => {
openBoxVisable.value = false;
closeCamera();
}
const switchScanner = () => {
scanBoxCode.value = !scanBoxCode.value;
if (scanBoxCode.value) {
openCamera();
} else {
closeCamera();
}
}
const openBox = () => {
api.post("/box/openBox", {
boxCode: boxCode.value,
pointId: point.value.pointId
})
.then(res => {
console.log(res)
if (res.code == 0) {
sessionStorage["currentBox"] = JSON.stringify(res.data);
router.push("/TesttubeList");
}
})
.catch(res => {
tipMsg.value = res.errMsg;
})
}
const toBoxList=()=>{
router.push("/BoxList")
}
</script>
<template>
<van-nav-bar title="全场景疫情病原体检测信息系统" right-arrow @click-right="logout">
<template #right>
<van-icon size="18" class-prefix="iconfont i-tuichu" name="extra" />
</template>
</van-nav-bar>
<van-row>
<van-col span="24" style="padding-top: 3rem;">
<h1 class="t-center">{{collector.name}},您好</h1>
<h2 class="t-center">{{point.pointName}}</h2>
</van-col>
</van-row>
<van-row class="big-icon">
<van-col span="12" style="height: 7rem;" class="t-center" @click="beginScanner">
<van-icon size="6rem" class-prefix="iconfont i-ziyuan92" name="extra" />
<h1>开箱</h1>
</van-col>
<van-col span="12" style="height: 7rem;" class="t-center" @click="toBoxList">
<van-icon size="6rem" class-prefix="iconfont i-kaixiangyanhuo" name="extra" />
<h1>列表</h1>
</van-col>
</van-row>
<van-list>
<van-cell title="按试管查转运箱" is-link to="SearchBox"/>
<van-cell title="变更注册信息" is-link to="ChangeInfo"/>
</van-list>
<van-list>
<van-cell title="修改密码" is-link to="ChangePwd"/>
</van-list>
<van-overlay :show="openBoxVisable" class="scanner">
<div v-if="scanBoxCode">
<video ref="video" id="video" class="scan-video" autoplay></video>
<div>{{tipMsg}}</div>
</div>
<div v-else>
<div class="scan-video"></div>
<van-cell-group inset>
<van-row class="padding">
<van-col span="24">
<van-field v-model="boxCode" label="输入箱码" placeholder="请输入箱码" />
</van-col>
</van-row>
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" @click="openBox">确定开箱</van-button>
</van-col>
</van-row>
</van-cell-group>
</div>
<van-cell-group inset style="margin-top:1rem">
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" @click="switchScanner">{{scanBoxCode?'手动输入':'扫描'}}
</van-button>
</van-col>
</van-row>
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" @click="endScanner">取消</van-button>
</van-col>
</van-row>
</van-cell-group>
</van-overlay>
</template>
<style>
.t-center {
text-align: center;
}
.big-icon {
margin-top: 2rem;
background-color: #b8d6ef;
height: 13rem;
}
.scan-video {
width: 100%;
height: 50vh;
}
.scanner {
color: #fff;
}
.padding {
padding: 1rem;
}
</style>
转运箱列表页面
该页面显示还没有转运走的转运箱列表,没查询的时候注意做好转运箱状态的条件筛选即可。
试管列表、开管
开管操作一样需要调用摄像头,前面已经做过,到这里就没什么难的了。CV+改就好了。
扫码之后跳转到手动输入界面,并不是直接开管,而是要选择一下采集类型是单采还是混采。
TestTubeList.vue
<script setup>
import {
ref
} from 'vue';
import {
useRouter
} from "vue-router";
const router = useRouter();
import {
Toast,Dialog
} from 'vant';
import api from '@/common/api.js';
import 'vant/es/dialog/style';
const logout = () => {
api.post("/collector/logout")
.then(res => {
router.push("/");
})
};
if (!(sessionStorage["currentBox"])) {
Toast.fail('非法箱码');
router.push("/box");
}
var _box;
try {
_box = JSON.parse(sessionStorage["currentBox"]);
} catch {
Toast.fail('非法箱码');
router.push("/box");
}
const box = ref(_box)
const back = () => {
router.push("/Box");
}
//读取试管列表
const testtubeList = ref([]);
const getTesttubeList = () => {
api.post("/testtube/getTestTubeListByBoxId", {
boxId: box.value.boxId
})
.then(res => {
testtubeList.value = res.data;
})
}
getTesttubeList();
//开箱操作
import {
BrowserMultiFormatReader
} from "@zxing/library";
const showOpenTestTubeVisable = ref(false);
const scanTestTubeCode = ref(true);
const codeReader = ref(null);
const testTubeCode = ref("");
const tipMsg = ref("");
//采集类型,默认为10人混采
const collectType = ref("10");
codeReader.value = new BrowserMultiFormatReader();
const beginScanner = () => {
showOpenTestTubeVisable.value = true;
scanTestTubeCode.value = true;
openCamera();
}
const openCamera = () => {
codeReader.value.getVideoInputDevices().then((videoInputDevices) => {
tipMsg.value = "正在调用摄像头...";
// 因为获取的摄像头有可能是前置有可能是后置,但是一般最后一个会是后置,所以在这做一下处理
// 默认获取第一个摄像头设备id
let firstDeviceId = videoInputDevices[0].deviceId;
if (videoInputDevices.length > 1) {
// 获取后置摄像头
let deviceLength = videoInputDevices.length;
--deviceLength;
firstDeviceId = videoInputDevices[deviceLength].deviceId;
}
decodeFromInputVideoFunc(firstDeviceId);
}).catch((err) => {
tipMsg.value = JSON.stringify(err);
console.error(err);
});
}
const decodeFromInputVideoFunc = (firstDeviceId) => {
codeReader.value.reset(); // 重置
codeReader.value.decodeFromInputVideoDeviceContinuously(firstDeviceId, "video", (result, err) => {
tipMsg.value = "正在尝试识别...";
if (result) {
// 获取到的是二维码内容,然后在这个if里面写业务逻辑即可
testTubeCode.value = result.text;
tipMsg.value = "识别成功:" + testTubeCode.value;
switchScanner();
}
if (err && !err) {
tipMsg.value = JSON.stringify(err);
console.error(err);
}
});
}
const closeCamera = () => {
codeReader.value.stopContinuousDecode();
codeReader.value.reset();
}
const endScanner = () => {
showOpenTestTubeVisable.value = false;
closeCamera();
}
const switchScanner = () => {
scanTestTubeCode.value = !scanTestTubeCode.value;
if (scanTestTubeCode.value) {
openCamera();
} else {
closeCamera();
}
}
const openTestTube = () => {
api.post("/testtube/openTestTube", {
testTubeCode: testTubeCode.value,
boxId: box.value.boxId,
collectType: collectType.value
})
.then(res => {
console.log(res)
if (res.code == 0) {
toTestTube(res.data);
}
})
.catch(res => {
tipMsg.value = res.errMsg;
})
}
//跳转到试管页面
const toTestTube = (testTube) => {
router.push("/TestTube/" + testTube.testTubeId);
}
//封箱
const closeBox = ()=>{
Dialog.confirm({
title: '操作提醒',
message: '确认要封箱吗?',
})
.then(() => {
api.post("/box/closeBox",{boxId:box.value.boxId})
.then(()=>{
router.push("/Box");
})
})
}
</script>
<template>
<van-nav-bar :title="'试管列表,箱码:'+box.boxCode+(box.status==2?'已封箱':'')" right-text="刷新" @click-right="getTesttubeList"
left-arrow @click-left="back">
</van-nav-bar>
<van-row class="button-group">
<van-col span="12" class="padding1rem" @click="beginScanner">
<van-button round block type="primary">
开管
</van-button>
</van-col>
<van-col span="12" class="padding1rem" @click="closeBox">
<van-button round block type="danger">
封箱
</van-button>
</van-col>
</van-row>
<van-row>
<van-col span="24" style="padding-top: 3rem;">
<van-list>
<van-cell v-for="item in testtubeList" :key="item" is-link @click="toTestTube(item)">
<template #title>
<span class="custom-title">{{item.testTubeCode}}</span>
<van-tag size="large" v-if="item.status==1" type="success">检测中</van-tag>
<van-tag size="large" v-if="item.status==2" type="danger">已封管</van-tag>
</template>
</van-cell>
</van-list>
</van-col>
</van-row>
<van-overlay :show="showOpenTestTubeVisable" class="scanner">
<div v-if="scanTestTubeCode">
<video ref="video" id="video" class="scan-video" autoplay></video>
<div>{{tipMsg}}</div>
</div>
<div v-else>
<div class="scan-video"></div>
<van-cell-group inset>
<van-form style="padding: 0.5rem;" @submit="openTestTube">
<van-row class="padding">
<van-col span="24">
<van-field required v-model="testTubeCode" label="输入试管码" placeholder="请输入试管码"
:rules="[{ required: true, message: '输入试管码' }]" />
</van-col>
<van-col span="24">
{{collectType}}
<van-field required name="checkboxGroup" label="采集类型"
:rules="[{ required: true, message: '请选择采集类型' }]">
<template #input>
<van-radio-group v-model="collectType" direction="horizontal">
<van-radio name="1" >单采</van-radio>
<van-radio name="10">10人混采</van-radio>
<van-radio name="20">20人混采</van-radio>
</van-radio-group>
</template>
</van-field>
</van-col>
</van-row>
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" native-type="submit">确定开管</van-button>
</van-col>
</van-row>
</van-form>
</van-cell-group>
</div>
<van-cell-group inset style="margin-top:1rem">
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" @click="switchScanner">{{scanTestTubeCode?'手动输入':'扫描'}}
</van-button>
</van-col>
</van-row>
<van-row class="padding">
<van-col span="24">
<van-button round block type="primary" @click="endScanner">取消</van-button>
</van-col>
</van-row>
</van-cell-group>
</van-overlay>
</template>
<style>
.padding1rem {
padding: 1rem
}
.button-group {
font-size: 2rem;
margin-right: 1rem;
}
.scan-video {
width: 100%;
height: 50vh;
}
.scanner {
color: #fff;
}
.padding {
padding: 1rem;
}
</style>
后端,试管的controller页面,注意做好参数的验证和状态验证
package com.hawkon.collector.service.impl;
import com.hawkon.collector.dao.BoxDao;
import com.hawkon.collector.dao.TestTubeDao;
import com.hawkon.collector.service.ITestTubeService;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.pojo.Box;
import com.hawkon.common.pojo.TestTube;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TestTubeServiceImpl implements ITestTubeService {
@Autowired
TestTubeDao testTubeDao;
@Autowired
BoxDao boxDao;
@Override
public List<TestTube> getTestTubeListByBoxId(Integer boxId) throws BusinessException {
Box box = boxDao.getBoxByBoxId(boxId);
if (box == null) {
throw new BusinessException("箱码不存在", ResultCodeEnum.BUSSINESS_ERROR);
}
if (box.getStatus() == 0 || box.getStatus() > 2) {
throw new BusinessException("箱码状态异常", ResultCodeEnum.BUSSINESS_ERROR);
}
List<TestTube> list = testTubeDao.getTestTubeListByBoxId(boxId);
return list;
}
@Override
public TestTube openTestTube(TestTube model) throws BusinessException {
if(model.getBoxId()==null){
throw new BusinessException("参数错误,没有箱码ID", ResultCodeEnum.BUSSINESS_ERROR);
}
TestTube modelDb = testTubeDao.getTestTubeByCode(model.getTestTubeCode());
if (modelDb == null) {
throw new BusinessException("非法试管码", ResultCodeEnum.BUSSINESS_ERROR);
}
if(modelDb.getStatus()>1){
throw new BusinessException("试管已封管,请检查试管码", ResultCodeEnum.BUSSINESS_ERROR);
}
if(modelDb.getStatus()==1){
return modelDb;
}
testTubeDao.openTestTube(model);
modelDb.setStatus(1);
return modelDb;
}
@Override
public void closeTestTube(TestTube model) throws BusinessException {
testTubeDao.closeTestTube(model);
}
}
试管列表页面效果图
试管页面、添加采样人
试管页面默认显示已录入的样本信息,可以支持三种录入方式:扫身份证、扫核酸码、手工录入。
我们这里实现后两种。需要注意的是扫身份证和扫核酸码后都要跳到手工录入界面确认信息点击提交后才能够完成添加。
手工录入
点击列表页面的样本信息后可以修改样本信息
TestTube.vue
<script setup>
import {
ref
} from 'vue';
import {
useRouter,
useRoute
} from "vue-router";
const router = useRouter();
const route = useRoute();
import {
Toast
} from 'vant';
import api from '@/common/api.js';
const back = () => {
router.push("/TestTubeList");
}
const testTubeId = ref(parseInt(route.params.testTubeId));
const sampleList = ref([]);
const getSampleList = () => {
api.post("/sample/getSampleByTestTubeId", {
testTubeId: testTubeId.value
}).then(res => {
sampleList.value = res.data;
})
}
getSampleList();
const toSample = (sample) => {
router.push({
name: "Sample",
query: sample
});
}
const people = ref({});
//扫核酸码
import {
BrowserMultiFormatReader
} from "@zxing/library";
const showCodeScanner = ref(false);
const tipMsg = ref("");
const acidCode = ref("");
const codeReader = ref(null);
codeReader.value = new BrowserMultiFormatReader();
const openCodeScanner = () => {
closeEditor();
showCodeScanner.value = true;
openCamera();
}
const closeCodeScanner = () => {
showCodeScanner.value = false;
closeCamera();
active.value = "none";
}
const openCamera = () => {
codeReader.value.getVideoInputDevices().then((videoInputDevices) => {
tipMsg.value = "正在调用摄像头...";
// 因为获取的摄像头有可能是前置有可能是后置,但是一般最后一个会是后置,所以在这做一下处理
// 默认获取第一个摄像头设备id
let firstDeviceId = videoInputDevices[0].deviceId;
if (videoInputDevices.length > 1) {
// 获取后置摄像头
let deviceLength = videoInputDevices.length;
--deviceLength;
firstDeviceId = videoInputDevices[deviceLength].deviceId;
}
decodeFromInputVideoFunc(firstDeviceId);
}).catch((err) => {
tipMsg.value = JSON.stringify(err);
console.error(err);
});
}
const decodeFromInputVideoFunc = (firstDeviceId) => {
codeReader.value.reset(); // 重置
codeReader.value.decodeFromInputVideoDeviceContinuously(firstDeviceId, "video", (result, err) => {
tipMsg.value = "正在尝试识别...";
if (result) {
console.log(result);
acidCode.value = result.text;
tipMsg.value = "识别成功:" + acidCode.value;
closeCodeScanner();
getPeopleInfoByAcidCode(acidCode.value);
}
if (err && !err) {
tipMsg.value = JSON.stringify(err);
console.error(err);
}
});
}
const closeCamera = () => {
codeReader.value.stopContinuousDecode();
codeReader.value.reset();
}
const getPeopleInfoByAcidCode = (acidCode) => {
api.post("/people/getPeopleInfoById", {
peopleId: acidCode
})
.then(res => {
people.value = res.data;
})
};
const getPeopleByIdcard = () => {
if (people.value.idcard && people.value.idcard.length == 18) {
api.post("/people/getPeopleByIdcard", {
idcard: people.value.idcard
})
.then(res => {
if (res.data) {
people.value = res.data;
}
})
}
};
//手工录入
const showEditor = ref(false);
const openEditor = () => {
closeCodeScanner();
showEditor.value = true;
people.value = {
idcardType: "身份证"
}
}
const closeEditor = () => {
showEditor.value = false;
}
const addSample = () => {
api.post("/sample/addSample", {
testTubeId: testTubeId.value,
name: people.value.name,
idcardType: people.value.idcardType,
idcard: people.value.idcard,
tel: people.value.tel,
address: people.value.address
})
.then(res => {
closeEditor();
getSampleList();
})
}
const tabbarThemeVars = ref({
tabbarHeight: "6rem"
})
const active = ref("none");
const openIdcardScanner = () => {
Toast.fail("功能暂未实现")
}
const beginCloseTestTube = () => {
if (sampleList.value.length == 0) {
Dialog.confirm({
title: '操作提醒',
message: '现在试管中样品是空的,确认要封管吗?',
})
.then(() => {
closeTestTube();
})
.catch(() => {
// on cancel
});
} else {
Dialog.confirm({
title: '操作提醒',
message: '确认要封管吗?',
})
.then(() => {
closeTestTube();
})
.catch(() => {
// on cancel
});
}
}
const closeTestTube = () => {
api.post("/testtube/closeTestTube", {
testTubeId: testTubeId.value
})
.then(res => {
router.push("/TestTubeList")
})
}
import 'vant/es/dialog/style';
import {
Dialog
} from 'vant';
</script>
<template>
<van-nav-bar title="试管" left-arrow @click-left="back" right-text="刷新" @click-right="getSampleList">
</van-nav-bar>
<van-row class="padding">
<van-col span="22" offset="2">
<van-button round block type="danger" @click="beginCloseTestTube">
封管
</van-button>
</van-col>
</van-row>
<van-row>
<van-col span="24">
<h1 style="padding:0 0 1rem 1rem">样本数量:{{sampleList.length}}</h1>
</van-col>
<van-col span="24">
<van-list>
<van-cell v-for="item in sampleList" :key="item" is-link @click="toSample(item)">
<!-- 使用 title 插槽来自定义标题 -->
<template #title>
<span class="custom-title">{{item.name}}</span>
<span>{{item.idcard}}</span>
</template>
</van-cell>
</van-list>
</van-col>
</van-row>
<van-overlay :show="showCodeScanner" class="scanner">
<video ref="video" id="video" class="scan-video" autoplay></video>
<div>{{tipMsg}}</div>
</van-overlay>
<van-overlay :show="showEditor" class="scanner">
<van-form @submit="addSample">
<van-cell-group inset class="editor">
<van-field required v-model="people.idcardType" name="idcardType" label="证件类型" placeholder="证件类型"
:rules="[{ required: true, message: '请填写证件类型' }]">
<template #input>
<van-radio-group v-model="people.idcardType" direction="horizontal">
<van-radio name="身份证">身份证</van-radio>
<van-radio name="护照">护照</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field @blur="getPeopleByIdcard" required v-model="people.idcard" name="idcard" label="身份证"
placeholder="身份证"
:rules="[{ pattern:/^([1-6][1-9]|50)\d{4}(18|19|20)\d{2}((0[1-9])|10|11|12)(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入18位身份证号',trigger:'onBlur' }]" />
<van-field required v-model="people.name" name="name" label="姓名" placeholder="姓名"
:rules="[{ required: true, message: '请填写姓名' }]" />
<van-field v-model="people.tel" name="tel" label="电话" placeholder="电话" />
<van-field v-model="people.address" name="address" label="地址" placeholder="地址" />
<div>
<van-row class="padding">
<van-col span="10" offset="2">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</van-col>
<van-col span="10" offset="2">
<van-button round block type="success" @click="closeEditor">
取消
</van-button>
</van-col>
</van-row>
</div>
</van-cell-group>
</van-form>
</van-overlay>
<van-config-provider :theme-vars="tabbarThemeVars">
<van-tabbar v-model="active">
<van-tabbar-item @click="openIdcardScanner" name="sannIdcard">
<span>扫描身份证</span>
<template #icon="props">
<van-icon class-prefix="iconfont i-ziyuan92" name="extra" size="3rem" />
</template>
</van-tabbar-item>
<van-tabbar-item @click="openCodeScanner" name="sannCode">
<span>扫核酸码</span>
<template #icon="props">
<van-icon class-prefix="iconfont i-saoma" name="extra" size="3rem" />
</template>
</van-tabbar-item>
<van-tabbar-item @click="openEditor" name="editor">
<span>手动录入</span>
<template #icon="props">
<van-icon name="edit" size="3rem" />
</template>
</van-tabbar-item>
</van-tabbar>
</van-config-provider>
</template>
<style>
.custom-title {
font-size: 2rem;
margin-right: 1rem;
}
.scan-video {
width: 100%;
height: 60vh;
}
.scanner {
color: #fff;
}
.padding {
padding: 1rem;
}
.editor {
height: 60vh;
margin-top: 10vh;
padding: 1rem;
}
</style>
后端代码。
package com.hawkon.collector.controller;
import com.hawkon.collector.service.ISampleService;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.pojo.ResultModel;
import com.hawkon.common.pojo.Sample;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/sample")
public class SampleController {
@Autowired
ISampleService sampleService;
@PostMapping("getSampleByTestTubeId")
public ResultModel<List<Sample>> getSampleByTestTubeId(@RequestBody Sample model) throws BusinessException {
List<Sample> list = sampleService.getSampleByTestTubeId(model.getTestTubeId());
return ResultModel.success(list);
}
@PostMapping("addSample")
public ResultModel<Object> addSample(@RequestBody @Valid Sample model) throws BusinessException {
sampleService.addSample(model);
return ResultModel.success(null);
}
@PostMapping("updateSample")
public ResultModel<Object> updateSample(@RequestBody @Valid Sample model) throws BusinessException {
sampleService.updateSample(model);
return ResultModel.success(null);
}
@PostMapping("deleteSample")
public ResultModel<Object> deleteSample(@RequestBody @Valid Sample model) throws BusinessException {
sampleService.deleteSample(model);
return ResultModel.success(null);
}
}
package com.hawkon.collector.service.impl;
import com.hawkon.collector.dao.PeopleDao;
import com.hawkon.collector.dao.SampleDao;
import com.hawkon.collector.service.ISampleService;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.pojo.People;
import com.hawkon.common.pojo.Sample;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SampleServiceImpl implements ISampleService {
@Autowired
SampleDao sampleDao;
@Autowired
PeopleDao peopleDao;
@Override
public List<Sample> getSampleByTestTubeId(Integer testTubeId) throws BusinessException {
return sampleDao.getSampleByTestTubeId(testTubeId);
}
@Override
public void addSample(Sample model) throws BusinessException {
People people = peopleDao.getPeopleByIdcard(model.getIdcard());
if(people==null){
peopleDao.insertPeopleFromSample(model);
}
int count = sampleDao.checkSample(model);
if(count>0){
throw new BusinessException("试管内身份证号重复,请核对身份证", ResultCodeEnum.BUSSINESS_ERROR);
}
sampleDao.addSample(model);
}
@Override
public void updateSample(Sample model) throws BusinessException {
People people = peopleDao.getPeopleByIdcard(model.getIdcard());
if(people==null){
peopleDao.insertPeopleFromSample(model);
}
int count = sampleDao.checkSample(model);
if(count>0){
throw new BusinessException("试管内身份证号重复,请核对身份证", ResultCodeEnum.BUSSINESS_ERROR);
}
sampleDao.updateSample(model);
}
@Override
public void deleteSample(Sample model) {
sampleDao.deleteSample(model);
}
}
mybatis文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hawkon.collector.dao.SampleDao">
<select id="getSampleByTestTubeId" resultType="com.hawkon.common.pojo.Sample">
select *
from sample
where testTubeId = #{testTubeId} ;
</select>
<insert id="addSample">
insert into sample
(testTubeId, name, idcard, idcardType, tel, address, collectTime)
values (#{testTubeId}, #{ name}, #{idcard}, #{idcardType}, #{tel}, #{address}, now())
</insert>
<select id="checkSample" resultType="int">
select count(0)
from sample
where testTubeId = #{testTubeId}
and idcard = #{idcard}
<if test="sampleId!=null">
and sampleId <>#{sampleId}
</if>
</select>
<update id="updateSample">
update sample
set name = #{name}
, idcard = #{idcard}
, idcardType = #{idcardType}
, tel = #{tel}
, address = #{address}
where sampleId = #{sampleId}
</update>
<delete id="deleteSample">
delete from sample where sampleId = #{sampleId}
</delete>
</mapper>
封管、封箱
封管操作基本没有什么限制,前端界面注意做好提示,不要因为误操作点点封管按钮造成封管。
封箱操作需要做好状态验证。
@Override
public void closeBox(Box model) throws BusinessException {
model = boxDao.getBoxByBoxId(model.getBoxId());
if(!model.getStatus().equals(1)){
throw new BusinessException("箱码状态异常,无法封箱",ResultCodeEnum.BUSSINESS_ERROR);
}
int count = boxDao.getOpenedTestTubeCount(model.getBoxId());
if(count>0){
throw new BusinessException("有未封管试管,请封管后再封箱",ResultCodeEnum.BUSSINESS_ERROR);
}
boxDao.closeBox(model.getBoxId());
}
辅助功能
变更注册信息
这个页面需要注意打开的时候需要加载已经绑定的行政区划。
这个功能思路不清晰的话写出容易乱七八糟,这个功能的思路如下。
第一步:根据采集人的areaId计算出省、市、区县ID
let userAreaId = userInfo.areaId;
//计算出已绑定的省、市、区行政区划码
let provinceCode = Math.floor(userAreaId / 10000000000);
let cityCode = Math.floor(userAreaId / 100000000);
let areaCode = Math.floor(userAreaId / 1000000);
let streetCode = Math.floor(userAreaId / 1000);
let committeeCode = userAreaId;
const registerForm = ref({
provinceCode,
cityCode,
areaCode,
streetCode,
committeeCode,
areaId: userAreaId
});
第二步:读取省、市、区、街道、村社区清单。
const getAreasByCityCode = (callBack) => {
api.post("/area/getAreasByCityCode", {
cityCode: registerForm.value.cityCode
})
.then(res => {
areas.value = res.data;
if (callBack) {
callBack();
}
})
}
第三步:计算出行政区划对应的区划名称。这一步必须得在第二步得到数据之后才能执行,而第二步的方法不仅加载时候要用,在页面变更的选择的行政区划后也会用这些方法。因此第二步方法定义了一个callback参数。
const setStreet = () => {
for (var i = 0; i < streets.value.length; i++) {
let p = streets.value[i]
if (p.streetCode == registerForm.value.streetCode) {
registerForm.value.streetCode = p.streetCode;
registerForm.value.streetName = p.name;
}
}
}
第四步:在页面初始化的最后一起调用4组数据的加载。
getCitiesByProvinceCode(setCity)
getAreasByCityCode(setArea);
getStreetsByAreaCode(setStreet);
getCommitteesByStreetCode(setCommittee)
看起来这个功能实现起来有点复杂,有人会说了,直接在collector表中把这些code和name都存下来不就行了。没错,这样确实也简单了一些,这里之所以用相对复杂一点的办法也是希望跟着练习的同学能够通过这个场景练习一下解决问题的思路问题。
ChangeInfo.vue
<script setup>
import {
ref
} from 'vue';
import {
useRouter
} from "vue-router";
import api from "../common/api.js";
const router = useRouter();
const back = () => {
router.push("/");
}
const userInfo = JSON.parse(sessionStorage.user);
let userAreaId = userInfo.areaId;
let provinceCode = Math.floor(userAreaId / 10000000000);
let cityCode = Math.floor(userAreaId / 100000000);
let areaCode = Math.floor(userAreaId / 1000000);
let streetCode = Math.floor(userAreaId / 1000);
let committeeCode = userAreaId;
const registerForm = ref({
provinceCode,
cityCode,
areaCode,
streetCode,
committeeCode,
areaId: userAreaId
});
const resetArea = (props) => {
if (props) {
props.forEach(key => registerForm.value[key] = null);
}
}
//选择省
const showProvincePicker = ref(false);
const provinces = ref([]);
api.post("/area/getProvinces")
.then(res => {
provinces.value = res.data;
setProvince();
})
const setProvince = () => {
for (var i = 0; i < provinces.value.length; i++) {
let p = provinces.value[i]
if (p.provinceCode == registerForm.value.provinceCode) {
registerForm.value.provinceCode = p.provinceCode,
registerForm.value.provinceName = p.name;
}
}
}
const setCity = () => {
for (var i = 0; i < cities.value.length; i++) {
let p = cities.value[i]
if (p.cityCode == registerForm.value.cityCode) {
registerForm.value.cityCode = p.cityCode;
registerForm.value.cityName = p.name;
}
}
}
const setArea = () => {
for (var i = 0; i < areas.value.length; i++) {
let p = areas.value[i]
if (p.areaCode == registerForm.value.areaCode) {
registerForm.value.areaCode = p.areaCode;
registerForm.value.areaName = p.name;
}
}
}
const setStreet = () => {
for (var i = 0; i < streets.value.length; i++) {
let p = streets.value[i]
if (p.streetCode == registerForm.value.streetCode) {
registerForm.value.streetCode = p.streetCode;
registerForm.value.streetName = p.name;
}
}
}
const setCommittee = () => {
for (var i = 0; i < committees.value.length; i++) {
let p = committees.value[i]
if (p.committeeCode == registerForm.value.committeeCode) {
registerForm.value.committeeCode = p.committeeCode;
registerForm.value.committeeName = p.name;
}
}
}
const confirmProvince = (value) => {
registerForm.value.provinceCode = value.provinceCode;
registerForm.value.provinceName = value.name;
showProvincePicker.value = false;
getCitiesByProvinceCode();
resetArea(["cityCode", "cityName", "areaCode", "areaName", "streetCode", "streetName", "committeeCode",
"committeName"
]);
}
const areaFieldName = {
text: 'name'
}
const cities = ref([]);
const getCitiesByProvinceCode = (callBack) => {
api.post("/area/getCitiesByProvinceCode", {
provinceCode: registerForm.value.provinceCode
})
.then(res => {
cities.value = res.data;
if (callBack) {
callBack();
}
})
}
const showCityPicker = ref(false);
const confirmCity = (value) => {
registerForm.value.cityCode = value.cityCode;
registerForm.value.cityName = value.name;
showCityPicker.value = false;
getAreasByCityCode();
resetArea(["areaCode", "areaName", "streetCode", "streetName", "committeeCode", "committeeName"]);
}
//区县选择器
const areas = ref([]);
const getAreasByCityCode = (callBack) => {
api.post("/area/getAreasByCityCode", {
cityCode: registerForm.value.cityCode
})
.then(res => {
areas.value = res.data;
if (callBack) {
callBack();
}
})
}
const showAreaPicker = ref(false);
const confirmArea = (value) => {
registerForm.value.areaCode = value.areaCode;
registerForm.value.areaName = value.name;
showAreaPicker.value = false;
getStreetsByAreaCode();
resetArea(["streetCode", "streetName", "committeeCode", "committeeName"]);
}
//街道/乡镇选择器
const streets = ref([]);
const getStreetsByAreaCode = (callBack) => {
api.post("/area/getStreetsByAreaCode", {
areaCode: registerForm.value.areaCode
})
.then(res => {
streets.value = res.data;
if (callBack) {
callBack();
}
})
}
const showStreetPicker = ref(false);
const confirmStreet = (value) => {
registerForm.value.streetCode = value.streetCode;
registerForm.value.streetName = value.name;
registerForm.value.areaId =value.areaId;
showStreetPicker.value = false;
getCommitteesByStreetCode();
resetArea(["committeeCode", "committeeName"]);
}
//村/社区选择器
const committees = ref([]);
const getCommitteesByStreetCode = (callBack) => {
api.post("/area/getCommitteesByStreetCode", {
streetCode: registerForm.value.streetCode
})
.then(res => {
committees.value = res.data;
if (callBack) {
callBack();
}
})
}
const showCommitteePicker = ref(false);
const confirmCommittee = (value) => {
registerForm.value.committeeCode = value.committeeCode;
registerForm.value.committeeName = value.name;
registerForm.value.areaId =value.areaId;
showCommitteePicker.value = false;
}
//表单验证
const repeatValidator = (val) => {
return val == registerForm.value.tel;
}
const changeInfo = () => {
var registerModel = {
areaId: registerForm.value.areaId
}
console.log(registerForm.value);
api.post("/collector/changeInfo", registerModel)
.then(res => {})
}
getCitiesByProvinceCode(setCity)
getAreasByCityCode(setArea);
getStreetsByAreaCode(setStreet);
getCommitteesByStreetCode(setCommittee)
</script>
<template>
<van-nav-bar title="变更注册信息" left-text="返回" left-arrow @click-left="back" />
<van-form style="padding: 0.5rem;" @submit="changeInfo">
<van-cell-group inset>
<van-field required v-model="registerForm.provinceName" name="provinceCode" label="省" is-link readonly
placeholder="点击选择省" @click="showProvincePicker=true" :rules="[{ required: true, message: '请选择省' }]" />
<van-popup required v-model:show="showProvincePicker" position="bottom">
<van-picker :columns="provinces" :columns-field-names="areaFieldName" @confirm="confirmProvince"
@cancel="showProvincePicker=false" />
</van-popup>
<van-field required v-model="registerForm.cityName" name="cityCode" label="市" placeholder="请选择市" is-link
readonly @click="showCityPicker=true" :rules="[{ required: true, message: '请选择市' }]" />
<van-popup required v-model:show="showCityPicker" position="bottom">
<van-picker :columns="cities" :columns-field-names="areaFieldName" @confirm="confirmCity"
@cancel="showCityPicker=false" />
</van-popup>
<van-field required v-model="registerForm.areaName" name="areaCode"
:rules="[{ required: true, message: '请选择区/县' }]" placeholder="区/县" label="区/县" is-link readonly
@click="showAreaPicker=true" />
<van-popup v-model:show="showAreaPicker" position="bottom">
<van-picker :columns="areas" :columns-field-names="areaFieldName" @confirm="confirmArea"
@cancel="showAreaPicker=false" />
</van-popup>
<van-field required v-model="registerForm.streetName" name="streetCode" label="街道/乡镇" placeholder="选择街道/乡镇"
:rules="[{ required: true, message: '请选择街道/乡镇' }]" is-link readonly @click="showStreetPicker=true" />
<van-popup v-model:show="showStreetPicker" position="bottom">
<van-picker :columns="streets" :columns-field-names="areaFieldName" @confirm="confirmStreet"
@cancel="showStreetPicker=false" />
</van-popup>
<van-field v-model="registerForm.committeeName" name="committeeCode" label="村/社区" placeholder="选择村/社区"
is-link readonly @click="showCommitteePicker=true" />
<van-popup v-model:show="showCommitteePicker" position="bottom">
<van-picker :columns="committees" :columns-field-names="areaFieldName" @confirm="confirmCommittee"
@cancel="showCommitteePicker=false" />
</van-popup>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
</template>
<style>
h3 {
margin-top: 1rem;
}
</style>
试管查箱码
说实话,不是太理解APP中为什么要有这个功能,从练习的角度来说,这个功能是采集人模块唯一需要用到多表连接的功能,搞一下吧。
前端、后端代码就不贴了,SQL代码如下:
<select id="searchBoxByTestTubeCode" resultType="com.hawkon.collector.pojo.vo.BoxVO">
select b.*,c.name as collector
,tf.name as transfer
,r.name as reciever
,u.name as uploader
,(select count(0) from testTube where testTube.boxId = b.boxId) as testTubeCount
,(select count(0) from testTube tt inner join sample s on tt.testTubeId = s.testTubeId where tt.boxId = b.boxId) as peopleCount
from box b left join testtube t on b.boxId = t.boxId
left join collector c on b.collectorId = c.collectorId
left join transfer tf on b.transferId = tf.transferId
left join reciever r on b.recieverId = r.recieverId
left join uploader u on b.uploaderId = u.uploaderId
where t.testTubeCode = #{testTubeCode}
</select>
End
采集人员模块的功能差不多就是这些了,文章中并没有把所有的代码都贴出来,跟着做的同学其它模块自己完成就好了。
如果有同学在跟着做,并且有问题想要交流的话,可以到我的微信公众号(姚Sir面试间)里来回复“核酸检测”,获得与我讨论项目的方式。
本系列其它文章:
第一章 逆向工程
第二章 大卸八块
第三章 利其器