这里将会记录一些我在企业开发中的一些项目需求

——————————————-快速检索和翻页请见右侧目录—————————–>

——————————————-快速检索和翻页请见右侧目录—————————–>

——————————————-快速检索和翻页请见右侧目录—————————–>


Redisson分布式锁


需求描述

最近自己做项目的时候,需要对一个查询菜单项目的缓存加锁,以优化查询性能。但是后续由于搭建了Redis集群的原因,多线程锁不能满足需求了(多线程锁只能锁住当前JVM,没办法锁住集群其他JVM),需要使用分布式锁完成该性能优化。

思路与实现

下面是我本来的代码,用于实现多线程锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public Map<String, List<Catalog2Vo>> getCtegoryJsonFromDbWithRedisLock() {
// distributed lock
String uuid = UUID.randomUUID().toString();
redisTemplate.opsForValue().setIfAbsent("lock", uuid, 100, TimeUnit.SECONDS);
if (StringUtils.isEmpty(redisTemplate.opsForValue().get("lock"))) {
// if lock is empty, wait and try again
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCategoryJsonFromDbWithRedisLock();
}else {
Map<String, List<Catalog2Vo>> categoryJsonFromDB;
try{
categoryJsonFromDB = getCategoryJsonFromDB();
} finally {
// lua script, release lock
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long lock = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), List.of("lock"), uuid);
}
return categoryJsonFromDB;
}
}

这里具体做了这些事:

  1. 生成一个唯一的UUID作为锁的值。
  2. 使用Redis的setIfAbsent方法尝试获取锁,如果成功获取锁则返回true,否则返回false。在这里,设置了一个100秒的锁过期时间。
  3. 如果未能获取到锁(即get("lock")返回了空),则等待100毫秒后重新尝试获取锁,直到成功获取到为止。
  4. 如果获取到了锁,则执行数据库操作,获取分类信息。
  5. 最后,通过执行一段Lua脚本释放锁。该脚本首先检查锁的值是否与当前的UUID相匹配,如果匹配则删除锁,释放资源。这一步确保了只有持有锁的线程或实例才能释放锁,避免了错误释放他人持有的锁。

现在需要将该部分内容修改为分布式锁,手写分布式锁既繁琐又容易出错,因此我这里选择直接用现成的已有的成熟方案:Redisson的分布式锁完成这部分修改:

1
2
3
4
5
6
7
8
9
10
11
12
public Map<String, List<Catalog2Vo>> getCategoryJsonFromDbWithRedissonLock() {
// distributed lock
RLock lock = redissonClient.getLock("CategoryJson-lock");
lock.lock(10, TimeUnit.SECONDS);
Map<String, List<Catalog2Vo>> categoryJsonFromDB;
try{
categoryJsonFromDB = getCategoryJsonFromDB();
} finally {
lock.unlock();
}
return categoryJsonFromDB;
}

Redisson配置完成后,getLock方法可以直接设置一个分布式锁,极大简化了代码,提升了效率。

Redisson实现分布式锁,是利用了 Redis 的原子性操作和发布/订阅功能来实现分布式环境下的可靠锁定机制。

  1. 基于 Redis 的原子操作:Redisson 利用 Redis 的原子性操作来实现分布式锁。Redis 的 SETNX 命令可以将键设置为具有指定值的字符串,但仅在键不存在时。这意味着只有一个客户端能够成功地将键设置为具有锁定值的状态。
  2. 锁超时和自动续期:Redisson 允许设置锁的超时时间。如果获取锁的客户端在指定的时间内未能释放锁,Redisson 将自动释放该锁。此外,Redisson 支持自动续期锁的超时时间,以防止锁的持有者因为业务逻辑导致操作时间过长而导致锁过期。
  3. 监视锁的释放:Redisson 使用 Redis 的发布/订阅功能来监视锁的释放。当锁被释放时,Redisson 会向订阅了锁的客户端发送消息,以便其他等待获取锁的客户端能够尝试获取锁。
  4. 可重入性:Redisson 支持可重入锁,即同一个线程可以多次获取同一把锁而不会出现死锁。
  5. Fairness(公平性):Redisson 提供了公平锁和非公平锁两种模式。在公平锁模式下,锁将按照获取锁的请求顺序进行分配;而在非公平锁模式下,锁将被立即分配给等待队列中的任何一个线程。

总结

总的来说,加深了对一些辅助工具例如Redisson的使用,对于锁的使用也加深了理解。这类问题也可以使用Spring Cache解决,但是细化的要求还是使用Redisson手动加锁更合适。


表格单元格合并、动态列与Excel导出


需求描述

最近我又制作了一个新的报表:人员追溯表(PersonnelTraceability),这个报表需要记录某个项目下面的某个线体下面的某个岗位的员工工作情况。举例来讲,如下图所示,项目A下面可能会有两个线体:Assembly和Production, 每个线体还对应了一个jobName。另外注意,一个项目下面的一个线体的一个岗位可能有多个人,对于这种情况,需要把所有人都显示出来,这些人按照逗号分隔。

报表还支持项目、线体的下拉搜索,开始结束日期的选取以及员工ID和姓名的模糊搜索。

除此之外,表格会根据选择的日期进行纵向展开,这也就意味着动态列名的生成:即图中 2023/1/1- 2023/1/5 这样的列生成,但是project/ line/job这样的列式固定的。

最后,该报表还需要支持搜索、重置和Excel导出功能。

表格单元格合并与动态列

问题

这个需求有很多功能点,其中的核心难点是什么呢?

  • 如何处理动态列的展示?

  • 单元格如何合并?

  • Excel导出怎么实现?

思路与实现

后端与SQL

在进行这些问题的处理之前,先要规定好后端的传值逻辑。

在这里,我们将会使用如下的findDetail后端方法用于返回后端数据,其Controller类如下所示:

(exportExcel 方法会在后面讲excel导出的时候使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequestMapping("/personnelTraceability")
@RestController
public class PersonnelTraceabilityController {
final PersonnelTraceabilityService personnelTraceabilityService;

public PersonnelTraceabilityController(PersonnelTraceabilityService personnelTraceabilityService){
this.personnelTraceabilityService = personnelTraceabilityService;
}

@PostMapping("/findDetail")
public WebResult<PageResult<Map<String,Object>>> findDetail(@RequestBody PersonnelTraceabilityDTO queryParam){
PageResult<Map<String,Object>> pageResult = personnelTraceabilityService.findDeatil(queryParam);
return WebResult.ok(pageResult);
}

@PostMapping("/exportExcel")
public void exportExcel(@Validated @RequestBody PersonnelTraceabilityDTO queryParams, HttpServletResponse response) throws IOException{
personnelTraceabilityService.exportExcel(queryParams, response);
}
}

可以看到,findDetail方法的返回值是一个PageResult<Map<String,Object>>,对于PageResult类就不赘述了,大致用途就是提供pagelimit属性用于分页,重点在于返回的Map<String,Object>

那么问题来了,这里的Map<String,Object> 究竟接收到了什么数据?

要回答这个问题,需要分析怎么做数据库交互的,即SQL查询语句。我们的数据来源于一个表 New_Base_Call_Names, 该表主要列如下所示:

id project_Name Line_Name job_Name EmployeeId EmployeeName D_date
1 A Assembly PM 1259539 Jack 2023-08-09
2 A Production QCI 1659467 Julia 2023-08-10
3 B Production QCI 1659467 Julia 2023-08-12

这是一个标准的表数据,基本上存储了所有我们需要的信息,问题在于我们需要把日期横向展示作为列名,而不是像现在这样只存储在行数据里面。于是,很自然我们能想到使用 pivot 即转置函数。

但是,Oracle sql确实支持 pivot 转置,但是要求提前知道要将哪些列转换为行,因此它不直接支持动态列名。PIVOT函数的语法要求你在查询中指定要转换为行的列,并在查询中明确列出这些列的名称。

所以,我们只能在Java代码里面完成SQL 语句的拼接来完成这个任务。

首先我们创建一个DTO类存放前端传来的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @ClassName PersonnelTraceabilityDTO
* @Description TODO
* @Author lhj
* @DATE 2023/10/22 1:56
* @Version 1.0
*/
@Data
public class PersonnelTraceabilityDTO extends PageParam{

private String projectCode;
private String lineCode;
private String employeeId;
private String name;
private String beginDate; // use to generate dynamic column
private String endDate;// use to generate dynamic column

}

要在Java中完成该任务,首先明确动态列将是什么?在这里,动态生成的列应该是日期,也就是说,用户在前端选择的日期会作为参数传递到SQL语句中,用户选择的日期当然只有开始日期和结束日期,因此这里我们需要把开始日期和结束日期中间每一天的日期都取出来存放在一个List里面:

1
2
3
4
5
6
7
8
9
private List<String> getDateList(LocalDate startDate, LocalDate endDate) {
ArrayList<String> dateList = new ArrayList<>();
LocalDate currentDate = startDate;
while (!currentDate.isAfter(endDate)){
dateList.add(currentDate.format(DateTimeFormatter.ISO_DATE));
currentDate = currentDate.plusDays(1);
}
return dateList;
}

这里的dateList将会在下面的代码中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private String getSql(PersonnelTraceabilityDTO queryParam, List<String> dateList) {
String sql = SqlUtils.formatSql("select * from (select distinct " +
"project_Name as projectName" +
"Line_Name as lineName," +
"job_Name as jobName," +
"EmployeeId || '/' || EmployeeName as employeeInfo," +
"D_date," +
"from NEW_BASE_CALL_NAMES" +
"where employeeId is not null" +
(StringUtils.isNotEmpty(queryParam.getProjectCode()) ? " and project_code = '" + queryParam.getProjectCode() + "'" : "") +
(StringUtils.isNotEmpty(queryParam.getLineCode()) ? " and line_code = '" + queryParam.getLineCode() + "'" : "") +
(StringUtils.isNotEmpty(queryParam.getEmployeeId()) ? " and employeeId like '%" + queryParam.getProjectCode() + "%'" : "") +
(StringUtils.isNotEmpty(queryParam.getName()) ? " and name like '%" + queryParam.getName() + "%'" : "") +
"and d_date >= '" + queryParam.getBeginDate() + "' and d_date <= '" + queryParam.getEndDate() +
"' ) pivot (listagg(employeeInfo, ',') with group (order by employeeInfo) for d_date in " +
Tools.listToStr(dateList) + ")");
return sql;
}

上述拼接语句中,使用了三元运算符,例如 StringUtils.isNotEmpty(queryParam.getProjectCode() 即对部分参数的非空判断,如果不为空才在where语句中进行判断,如果为空则在sql字符串中拼接 “”。另外,对于动态列名的生成,值得注意的是,在Oracle sql中,pivot的使用需要给每一个转置后的列名加上单引号,举个例子,pivot的语法如下:

1
2
3
SELECT *
FROM (SELECT Region, Month, Sales FROM sales)
PIVOT (SUM(Sales) FOR Month IN ('Jan' , 'Feb' , 'Mar' ));

因此,我们使用了Tools.listToStr的方法处理动态数据,将传入的List 的每个元素两边都加上单引号,再用逗号分隔拼接在一起。具体的方法这里就不详细展开了。

除此之外,由于需求还需要把多个人的姓名和工号组合在一起并通过逗号分隔,所以这里还使用了 EmployeeId || '/' || EmployeeName as employeeInfo 拼接字符,再通过 pivot (listagg(employeeInfo, ',') with group (order by employeeInfo) for d_date in 分隔每个员工。

总而言之,通过下面的代码执行SQL并分页之后,传给前端的格式就确定了:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public PageResult<Map<String, Object>> findDeatil(PersonnelTraceabilityDTO queryParam) {
LocalDate startDate = LocalDate.parse(queryParam.getBeginDate(), DateTimeFormatter.ISO_DATE);
LocalDate endDate = LocalDate.parse(queryParam.getEndDate(), DateTimeFormatter.ISO_DATE);

List<String> dateList = getDateList(startDate,endDate);

String sql = getSql(queryParam,dateList);
PageRequest request = DefaultPageRequest.of(queryParam.getPage(),queryParam.getLimit);
PageResult<Map<String, Object>> result = getSqlManager().execute(new SQLReady(sql), Map.class, request);
return result;
}

这里的Map格式如下:

key value
projectName A
lineName
jobName
‘2023-08-09’
‘2023-08-10’

以上就是Map 是后端即将传给前端的数据了。

动态列展示

现在,前端接收参数,并把分页后的数据存放进 dataList ,把数据总数存放进 total:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
handleList(){
this.dataTable.loading = true;
personTraceApi.findDetail({
projectCode: this.queryParam.query.projectCode,
lineCode: this.queryParam.query.lineCode,
beginDate: this.queryParam.query.beginDate,
endDate: this.queryParam.query.endDate,
name: this.queryParam.query.name,
employeeId: this.queryParam.query.employeeId,
page: this.queryParam.query.page,
limit: this.queryParam.query.limit,
}).then((res) => {
this.dataTable = Object.assign(this.dataTable,{
dataList: res.data.list,
total: res.data.totalRow
});
});
}

现在,我们可以想象到,一个 List<Map<String,Object>>已经被存放在了 this.dataTable.dataList 里面了,现在我们要思考,怎么把这样的结果显示在前端。

其实很简单,element-ui支持这样的操作:

1
2
3
4
5
6
7
<el-table-column
v-for="columnn in dynamicColumns"
:key="columnn.index"
:prop="column"
:label="column"
width="100px"
></el-table-column>

通过 v-for 遍历一个数组,作为每一个列来展示。

因此,我们可以想到,只需要把每次后端出来的数据里面的map的key都存放在dynamicColumns中就可以每次都动态展示出来了。但是,其实有一个更简单的方法,虽然我们的列数量确实是动态的,但是实际上有哪些列需要被展示是可以被基本确定的——用户需要首先选择日期,因此,实际上把选择的日期范围作为动态列的范围即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
generateDateRange(startdate,enddate){
const startdate = new Date(startdate);
const enddate = new Date(endDate);
const dateArray = [];
const currentDate = new Date(startdate);
// Loop through each day and convert it to a string.
while(currentDate <= enddate){
const formattedDate = this.formatDate(currentDate);
dateArray.push(formattedDate);
currentDate.setDate(currentDate.getDate() + 1);
}
return dateArray;
},

通过上述方程,假设用户选择 2023-08-08 至 2023-08-10,则 dateArray = [2023-08-08,2023-08-09,2023-08-10]。把该值放进 dynamicColumns 即可,handleList() 方法可以拓展为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
handleList(){
this.dataTable.loading = true;
personTraceApi.findDetail({
projectCode: this.queryParam.query.projectCode,
lineCode: this.queryParam.query.lineCode,
beginDate: this.queryParam.query.beginDate,
endDate: this.queryParam.query.endDate,
name: this.queryParam.query.name,
employeeId: this.queryParam.query.employeeId,
page: this.queryParam.query.page,
limit: this.queryParam.query.limit,
}).then((res) => {
this.dataTable = Object.assign(this.dataTable,{
dataList: res.data.list,
total: res.data.totalRow
});
this.dynamicColumns = this.generateDateRange(
this.queryParam.query.beginDate,
this.queryParam.query.endDate,
);
});
}

单元格合并

动态列的问题我们解决了,现在思考如何实现表格行数据的单元格合并。

首先, element-ui 是支持单元格合并的,不过需要我们自己配置合并函数在 :span-method 里面:

1
2
3
4
5
6
7
8
9
<el-table
:data="this .dataTable.dataList"
style="width: 100%"
:span-method="arraySpanMethod"
border
:header-cell-style="{'text-align':'center'}"
:v-loading="this.dataTable.loading"
>
</el-table>

合并函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// cell merged
arraySpanMethod({row,column,rowIndex,columnIndex}){
// determine whether current column is first column (project)
if (columnIndex == 0) {
const _row = this.spanArr[rowIndex]
const _col = _row > 0? 1: 0;
return{
rowspan: _row,
colspan: _col
};
}
// determine whether current column is second column (line)
if (columnIndex == 1) {
const _row2 = this.spanArr2[rowIndex]
const _col2 = _row2 > 0? 1: 0;
return{
rowspan: _row2,
colspan: _col2
};
}
},

函数接受一个包含信息的参数对象,包括 row(当前行名)、column(当前列名)、rowIndex(当前行索引)和 columnIndex(当前列索引)。

在代码中,首先检查 columnIndex 是否为0。如果是,它执行接下来的逻辑进行行合并。

如果 columnIndex 是0,它会检查一个名为 spanArr 的数组(或类似结构)来获取该行中当前列需要合并的行数和列数。然后,它将这些合并信息作为一个对象返回,包括 rowspan(行合并数)和 colspan(列合并数)。

接着,代码类似地检查 columnIndex 是否为1,以确定第二列(line)是否需要进行行合并和列合并。这部分的逻辑与第一部分类似。

而spanArr 数组通过以下的方式获得:(其实就是对数据进行判断,如果相等为1不相等为0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Obtain the array of cells that need to be merged.
getSpanArr(data){
for(var i = 0; i < data.length; i++){
if (i == 0){
this.spanArr = []
this.spanArr2 = []
this.spanArr.push(1)
this.spanArr2.push(1)
this.pos = 0
this.pos2 = 0
} else{
// Determine if the current element is equal to the previous element
if (data[i].projectName === data[i - 1].projectName){
this.spanArr[this.pos] += 1;
this.spanArr.push(0);
}else{
this.spanArr.push(1)
this.pos = i
}
if (data[i].lineName === data[i - 1].lineName){
this.spanArr2[this.pos2] += 1;
this.spanArr2.push(0);
}else{
this.spanArr2.push(1)
this.pos2 = i
}
}
}
},

使用一个循环来遍历数据数组 data

在第一个迭代(i 等于0)中,初始化 spanArrspanArr2 为一个包含一个元素的数组,该元素的值为1。然后初始化 pospos2 为0。

对于后续迭代(i 大于0),它检查当前元素(data[i])与前一个元素(data[i - 1])是否满足特定条件。这些条件涉及到数据的某些属性,比如 projectNamelineName

如果当前元素的 projectName 与前一个元素相同,表示需要进行行合并,因此在 spanArr 数组中对应的位置的值加1,并在 spanArr 中添加一个0,以表示需要列合并。

否则,如果 projectName 不同,表示需要在下一个单元格开始一个新的行,因此在 spanArr 中添加一个1,并更新 pos 为当前迭代的索引 i。类似的逻辑使用于 spanArr2,即lineName列

至此,我们完成了行数据的合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
handleList(){
this.dataTable.loading = true;
personTraceApi.findDetail({
projectCode: this.queryParam.query.projectCode,
lineCode: this.queryParam.query.lineCode,
beginDate: this.queryParam.query.beginDate,
endDate: this.queryParam.query.endDate,
name: this.queryParam.query.name,
employeeId: this.queryParam.query.employeeId,
page: this.queryParam.query.page,
limit: this.queryParam.query.limit,
}).then((res) => {
this.dataTable = Object.assign(this.dataTable,{
dataList: res.data.list,
total: res.data.totalRow
});
const modifiedData = [];
this.dataTable.dataList.forEach((item) => {
const modifieditem = {};
Object.keys(item).forEach((key) => {
// Eliminate single quotes on both sides of a string 'yyyy-mm-dd'
if (/^\d{4}-\d{2}-\d{2}'$/.test(key)){
modifieditem[key.slice(1,-1)] = item[key];
}else{
modifieditem[key] = item[key];
}
});
modifiedData.push(modifieditem);
});
this.dataTable.dataList = modifiedData;
this.dataTable.loading = false;
this.getSpanArr(this.dataTable.dataList);
this.dynamicColumns = this.generateDateRange(
this.queryParam.query.beginDate,
this.queryParam.query.endDate,
);
});
},

另外,我还在代码中添加了一个逻辑,还记得吗,从后端获取到的数据日期格式是” ‘yyyy-mm-dd’ “格式的,这里的单引号会在前端显示出来,所以我用正则表达式进行匹配并去除了单引号。

Excel导出

Excel导出和搜索有点类似,不过区别在于收集到的数据不需要分页,需要进行全量展示。这里的前端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
handleExport(){
personTraceApi.exportExcel({
projectCode: this.queryParam.query.projectCode,
lineCode: this.queryParam.query.lineCode,
beginDate: this.queryParam.query.beginDate,
endDate: this.queryParam.query.endDate,
name: this.queryParam.query.name,
employeeId: this.queryParam.query.employeeId,
}).then((res) => {
const blob = new Blob([res],{
type: "application/vnd.ms-excel;charset=utf-8"
});
const downloadElemnt = document.createElement("a");
const href = window.URL.createObjectURL(blob);
downloadElemnt.herf = href
downloadElemnt.download = "personnelTraceability.xls"
document.body.appendChild(downloadElemnt);
downloadElemnt.click();
downloadElemnt.body.removeChild(downloadElemnt);
window.URL.revokeObjectURL(href);
})
}
  1. 一旦请求成功并获取到导出的数据(在 then 部分),数据以二进制流的形式存储在 res 变量中。
  2. 接下来,代码使用 Blob 对象将数据包装成二进制数据,同时设置数据的类型为 Excel 文件,这是通过 type 设置的。
  3. 之后,代码创建一个 <a> 元素(超链接元素),用于触发下载操作。它设置了 href 属性,将之前创建的 Blob 对象的 URL 赋给 href
  4. 然后,给这个 <a> 元素设置了 download 属性,指定下载时的文件名为 “personnelTraceability.xls”。
  5. 接着,将这个 <a> 元素添加到 document.body,这样它会在页面上占据一个位置。
  6. 通过触发 click 事件,模拟用户点击这个链接,从而触发文件下载操作。
  7. 最后,代码将这个 <a> 元素从 document.body 中移除,以避免在页面上留下不必要的元素。
  8. 最后,调用 window.URL.revokeObjectURL 来释放之前创建的 Blob URL,以释放浏览器资源。

那么,后端应该做些什么呢?

1
2
3
4
5
@Override
public void exportExcel(PersonnelTraceabilityDTO queryParams, HttpServletResponse response) throws IOException {
List<Map> list = findList(queryParams);

}

首先,获取数据是方法的第一步:

1
2
3
4
5
6
7
8
9
10
private List<Map> findList(PersonnelTraceabilityDTO queryParams) {
LocalDate startDate = LocalDate.parse(queryParams.getBeginDate(), DateTimeFormatter.ISO_DATE);
LocalDate endDate = LocalDate.parse(queryParams.getEndDate(), DateTimeFormatter.ISO_DATE);

List<String> dateList = getDateList(startDate,endDate);

String sql = getSql(queryParams,dateList);
List<Map> result = getSqlManager().execute(new SQLReady(sql),Map.class);
return result;
}

这个方法和前面的后端方法几乎一模一样,区别仅仅只是没有使用PageResult接收来进行分页。

在该项目里,还需要对里面的表头和数据进行翻译(因为初始数据是中文的),这里就不详细讲解翻译的内容了,总而言之,数据和表头都需要单独作为列表展示出来,同时转化为List<List<Object>>的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  private List<List<Object>> getTranslatedData(List<Map> list, Map<String, String> translatedMap, List<String> headers) {
// translated and replace single quotes
...
// change data to required List<List<Object>>
List<List<Object>> lists = new ArrayList<>();
for (Map<String,Object> map : list) {
ArrayList<Object> objects = new ArrayList<>();
for (int i = 0; i < headers.size(); i++) {
objects.add(map.get(headers.get(i)));
}
lists.add(objects);
}
return lists;
}

private List<List<String>> createdHead(List<String> headerMap) {
List<List<String>> headList = new ArrayList<>();
// change headers to required List<List<Object>>
for (String head : headerMap) {
ArrayList<String> list = new ArrayList<>();
list.add(head);
headList.add(list);
}
return headList;
}

最后,以下代码会用于导出EXCEL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  @Override
public void exportExcel(PersonnelTraceabilityDTO queryParams, HttpServletResponse response) throws IOException {
List<Map> list = findList(queryParams);

...getTranslatedData...

File file = new File( "");
String filePath = null;
filePath = file.getCanonicalPath();
filePath = filePath +"/downloadExportTemplate";
if (!FileUtil.isDirectory(filePath)){
FileUtil.mkdir(filePath);
}
String fileName = filePath.concat( "/personnelTraceability.xls");

ExcelWriterBuilder writerBuilder = EasyExcel.write(fileName)
// set column width
.registerWriteHandler(new SimpleColumnWidthStyleStrategy(25))
.head(createdHead(headers));
writerBuilder.sheet("personnelTraceability").doWrite(datalist);

InputStream in = new FileInputStream(fileName);
String encode = URLEncoder.encode(fileName, "UTF-8");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/vnd.ms-excel;charset=utf-8");
response.setHeader("Content-Disposition","attachment; filename=" + encode);
HSSFWorkbook workbook = new HSSFWorkbook(in);
workbook.write(response.getOutputStream());
in.close();
}
  1. File file = new File("");:这一行创建了一个空的 File 对象,实际上并没有指定文件路径。
  2. String filePath = null;:初始化一个字符串变量 filePath
  3. filePath = file.getCanonicalPath();:这一行获取当前项目或应用程序的工作目录,并将其存储在 filePath 中。
  4. filePath = filePath +"/downloadExportTemplate";:将字符串 "/downloadExportTemplate" 添加到 filePath 中,用于指定下载文件的目录。
  5. if (!FileUtil.isDirectory(filePath)):检查指定的目录是否存在,如果不存在,执行下面的操作。
  6. FileUtil.mkdir(filePath);:如果目录不存在,创建该目录。这是通过项目中的 FileUtil 类或者第三方库来执行的,这里就不详细解释了。
  7. String fileName = filePath.concat( "/personnelTraceability.xls");:设置要生成的 Excel 文件的文件名和路径。
  8. ExcelWriterBuilder writerBuilder = EasyExcel.write(fileName):创建一个 EasyExcel 的写入器,用于将数据写入 Excel 文件。指定了文件名和路径。
  9. .registerWriteHandler(new SimpleColumnWidthStyleStrategy(25)):设置列宽样式,这里指定了列宽为 25。
  10. .head(createdHead(headers)):设置 Excel 表格的表头信息,createdHead(headers) 方法用于创建表头的内容。
  11. writerBuilder.sheet("personnelTraceability").doWrite(datalist);:创建一个工作表(sheet)命名为 “personnelTraceability”,并将数据列表 datalist 写入到 Excel 文件中。
  12. InputStream in = new FileInputStream(fileName);:打开生成的 Excel 文件并创建一个输入流。
  13. String encode = URLEncoder.encode(fileName, "UTF-8");:将文件名进行 URL 编码。
  14. response.setCharacterEncoding("UTF-8");:设置 HTTP 响应字符编码为 UTF-8。
  15. response.setContentType("application/vnd.ms-excel;charset=utf-8");:设置响应的内容类型为 Excel 文件。
  16. response.setHeader("Content-Disposition","attachment; filename=" + encode);:设置响应头,告诉浏览器这是一个附件,并提供文件名。
  17. HSSFWorkbook workbook = new HSSFWorkbook(in);:创建一个 HSSFWorkbook 对象,用于处理 Excel 文件。
  18. workbook.write(response.getOutputStream());:将 Excel 数据写入 HTTP 响应的输出流,以便浏览器下载。
  19. in.close();:关闭输入流。

至此,整个需求已基本完成了。

总结

全部代码可以在Github上看到:

Github

学习到了新的三类功能的实现思路。


Oracle表触发器


需求描述

今天有个新的项目需求,这个需求是有关数据库表的,相对简单。

现在有一个表A,它只记录当天的数据,并且它的数据每五分钟都会进行变化,它会删掉之前的所有数据,再把更新后的数据插入进来。并且尽管表A会删除掉之前的所有数据,但是更新后的数据对于五分钟之前的数据并不会做修改,只是做了添加操作。

TIPS: 至于说这里为什么不能直接进行添加操作,另一边的大数据部门反馈这里做不到,具体的原因不得而知了。

总而言之,需要根据目前的需求,做一个触发器,当表A插入数据的时候,把相同的数据插入进表B,但是表B的数据不会删除,并且也不仅仅只保留当天的。另外,对于增量表B,为了避免性能问题,当表A插入的时候需要判断一下,如果新插入的数据表B已经存在了,则不要再插入了。

思路与实现

这里就不得不提一下merger into 这个触发器函数了。

在Oracle SQL中,MERGE INTO语句用于指定要合并数据的目标表;USING 指定要从中获取数据的源表;ON 指定用于匹配源表和目标表行的条件。如果条件为真,则执行匹配时的UPDATE操作,否则执行不匹配时的INSERT操作;WHEN NOT MATCHED THEN 在源表和目标表之间不存在匹配的情况下执行的操作块。

很显然,在这里,使用merge into 是能满足要求的:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TRIGGER insert_trigger
AFTER INSERT ON A
REFERENCING OLD AS "OLD"
NEW AS "NEW"
FOR EACH ROW

BEGIN
MERGE INTO B
USING (SELECT 1 FROM DUAL) ON (B.employeeId = :NEW.employeeId and B.date = NEW:date)
WHEN NOT MATCHED THEN
INSERT (ID,employeeId,date,...)
VALUES (:NEW.ID,:NEW.employeeId,:NEW.date,...);
END;

在上面的代码里面,我们创建了一个触发器 insert_trigger, 它是一个AFTER INSERT触发器,这意味着它会在数据插入到表A之后执行。

  1. 当在表A中插入新数据时,触发器将被激活。
  2. 触发器定义了一个命名引用(REFERENCING),使用OLD代表之前的行(在插入之前的行)和NEW代表新插入的行。
  3. 触发器定义了FOR EACH ROW,这表示它会针对每一行插入操作都执行一次。
  4. 在BEGIN和END之间是触发器的主体逻辑。在这里,触发器使用MERGE语句来处理表B中的数据。
  5. MERGE INTO语句允许将数据从一个数据源(在这里是SELECT 1 FROM DUAL)合并到目标表B中。在这个例子中,它使用表B作为目标。
  6. USING子句指定了数据源,这里使用了一个简单的SELECT 1 FROM DUAL。这个子句通常用于指定一个虚拟表或查询,用来提供要插入的数据。
  7. ON子句定义了合并操作的条件。它使用了一个条件,即B表的employeeId列与插入到A表的新行的employeeId列以及date列与NEW:date相匹配。
  8. WHEN NOT MATCHED THEN定义了在合并操作中当条件不匹配时要执行的操作。在这里,它执行了插入操作,将新行的数据插入到表B中。(仅当B表的employeeId列与插入到A表的新行的employeeId列以及date列与NEW:date不匹配的时候
  9. INSERT子句指定了要插入的列和相应的值。这些值来自于新插入到表A中的行(:NEW.ID,:NEW.employeeId,:NEW.date等等)。

总之,这个触发器的作用是在表A中插入新数据后,检查是否存在符合条件的表B中的数据行,如果条件不匹配,则将新数据插入到表B中。

至此,我们的触发器实现了。

总结

这个需求相对容易很多,主要是学习到了一个新的触发器函数 merge into



具有下拉框/直方图的报表


这是一个新的特别完整的页面报表需求,包含有下拉框设计以及对应的数据收集和限制,以及数据直方图在Vue的显示等。

需求描述

需要构建类似如下的画面,包含的基本功能需求有:

项目记录-企业需求3

  • 下拉框功能实现
  • 强制先选择项目才能获取line和process
  • 直方图的实现
  • 日期选择的实现
  • 姓名,员工id的input框
  • 数据表格的实现

问题

正如在需求描述中所写的一样,需要实现的功能与我的问题高度相关:

  • 下拉框功能应该怎么实现?

  • 部门树应该怎么获取?

  • line和process需要先获取项目了再显示,应该怎么做?

  • 动态的直方图功能应该怎么实现?

  • 日期、姓名、员工id等参数怎么在后端做查询?

  • 表格数据应该怎么呈现?

思路与实现

这个项目涉及了很多功能的实现,让我们一步一步的思考,这次不会再区分前后端代码,而是围绕需求或者说问题去分析,因为一个功能往往是既涉及前端代码也涉及到后端代码的。如果围绕需求或者说问题去分析,可能更容易理解一点。

Dept部门树级联选择器

这一块我不会详谈,因为既涉及到了公司隐私,而且也是个已经完成的接口,后端直接调用该接口,传递给前端即可。

这里的前端代码是利用element-ui的cascader实现的,Vue代码如下:

(注意尽管封装好了一个cust-cascader组件,但是功能和el-cascader差不多)

1
2
3
4
5
6
7
8
9
10
11
<cust-cascader
v-model="queryParam.deptCode"
class= "filter-item"
:placeholder="Dept"
:props = "{value:'deptId',label:'name'}"
:show-all-levels="false"
:options="orgTree.treeList"
ref="cascader"
size="mini"
style="float:left;margin-right:15px;"
/>
  • v-model绑定的数据是未来会通过前端传递给后端的;
  • class,style,size等属性用于美化;
  • show-all-levels属性属于el-cascader的一部分,用于确定输入框中是否显示选中值的完整路径(即部门树的完整路径)
  • placeholder显示文字在框内

那么这个下拉框的原始数据是从哪里来的呢?——通过options和props组合获取,:options获取的orgTree的treeList,这个treeList是从data属性获取的,data的属性又通过放在created里面的方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
data(){
return{
orgTree:{
treeList:[],
selectId:null,
defaultProps:{
children:"children",
label:"label"
}
}
}
},

import api from ...
methods:{
handleOrgTreeList(){
return api.getOrgList({orderBy:"orderNum"}).then((res) => {
const data = res.data;
this.orgTree.treeList = data;
})
}
},

created(){
this.handleOrgTreeList().then(() => {
if (this.$isNotEmpty(this.orgTree.treeList)){
const firstNode = this.orgTree.treeList[0];
}
})
}

可见,当组件创建的时候,调用了handleOrgTreeList方法,即调用了后端接口,为orgTree.treeList赋值,然后在DOM渲染完毕后自然放进了级联选择器中显示出来了。

Project与Type 下拉框

我们知道,按照正常的设计逻辑,下拉框的内容肯定应该是在用户点击的时候就显示出来,而不是等待用户输入几个字符之后才开始搜索数据并显示,因此这里很自然能想到应该把方法的实现写在created或者mounted里面。

事实上这两个下拉框的实现逻辑是一样的,我在这里以Type下拉框作为例子展示,Project下拉框和Type下拉框的差距仅仅在SQL查询上。

这次首先先写后端的内容,后端首先在Controller类里面完成一个很简单的代码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Api("...")
@RequestMapping("/bi")
@RestController
public class BaseController{

private final BaseService baseService;

public BaseController(BaseService baseService){
// Constructor injection. If the code is bloated later, can add @AllArgsConstructora to remove this part
this.baseService = baseService;
}

@GetMapping("/type")
@ApiOperation
public WebResult<List<BaseEmployeeEntity>> findType(){
<List<BaseEmployeeEntity>> list = baseService.findType();
return WebResult.ok(list);
}
}

一个GetMapping的方法:

WebResult如之前所说,是一个封装好的类,用于定制返回值到WSDL的映射。

BaseEmployeeEntity,则是一个关联数据库的实体类,里面有很多字段,但是我们需要的只是关联type的那个字段,它将用于获取员工类型:A,B,C,D。

QueryParams类,一个封装的接收前端参数的类。

接下来,Service层和ServiceImpl层的代码如下所示:(比较基础)

BaseService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author lu
*/

public interface BaseService extends SysBaseService<BaseMapper,BaseEmployeeEntity>{

/**
* @author lu
* @date 2023/...
* @version 1.0
* @return a list of type field, now (A,B,C,D)
*/
List<BaseEmployeeEntity> findType();
}


BaseServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author lu
*/

@Service
public class BaseServiceImpl extends SysBaseServiceImpl<BaseMapper,BaseEmployeeEntity> implements BaseService{

@Override
public List<BaseEmployeeEntity> findType(){
LambdaQuery<BaseEmployeeEntity> query = sqlManager.lambdaQuery(BaseEmployeeEntity.class);
// deduplication and check not empty
List<BaseEmployeeEntity> select = query.andIsNotNull(BaseEmployeeEntity::getType)
.groupBy(BaseEmployeeEntity::getType)
.select(BaseEmployeeEntity::getType);
return select;
}

Service的实现层,首先通过beetl sql建立了一个sql查询,然后通过andIsNotNull去除了数据表中type列为空的数据,再通过groupBy去重,最后通过select返回,这段代码大致上执行了如下的SQL语句:

注意这里对表的操作实际上是对BaseEmployeeEntity类关联的表做的操作

1
2
3
select type from ...
where type is not null
group by type

通过这段SQL,后端返回给前端A,B,C,D这四种员工类型。

Project下拉框的逻辑与Type基本一致,这里就不赘述了。

另外值得一提的是前端这部分的显示,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<el-select
:placeholder="project"
class="filter-item"
size="mini"
filterable
clearable
:loading="selectLoading"
v-model="queryParam.projectCode"
style="float:left;margin-right:15px;"
@change="prochange"
>
<el-option
v-for="item in projectArr"
:key="item.projectCode"
:label="item.projecName"
:value="item.projectCode"
>
</el-option>
</el-select>

<el-select
:placeholder="type"
class="filter-item"
size="mini"
filterable
clearable
:loading="selectLoading"
v-model="queryParam.type"
style="float:left;margin-right:15px;"
>
<el-option
v-for="(item,index) in typeArr"
:key="index"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>

element-ui有适配的el-select组件帮助我们进行下拉框展示。这里需要注意的是loading属性,是在数据如果加载时间很长的时候启动,会有转圈等待的特效。

v-model双向绑定的值是未来需要传递给后端用来搜索的值。

那下拉框的值从哪里来呢?这个值需要从el-option组件里面获取,el-option前端显示的是:label标签的值,其对应的索引是:value的值。以获取Type和project为例,el-option显然是把typeArr和projectArr的值显示到了下拉框里面,它们的值又是通过后端方法获取的,那么,它们在前端是怎么和后端互动的呢?

这里使用的方法是mapGetters,mapActions,当然也可以选择直接在前端import一个api:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
computed:{
...mapGetters({
projectArr:"getProjectArr",
typeArr:"getType",
}),
},
methods: {
...mapActions(['updateProjectArr','updateTypeArr']),
init(){
this.updateProjectArr();
this.updateTypeArr();
}
},
mounted() {
this.init();
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import projectApi from ...
import lineProcessApi from ...

export default{
state:{
projectArr:[],
projectArrVisited:false,
typeArr:[],
typeArrVisited:false
},
getters:{
getProjectArr: state => state.projectArr,
getTypeArr: state => state.typeArr
},
mutations:{
setProjectArr(state,project){
state.projectArr = project
state.projectArrVisited = true
},
setTypeArr(state,type){
state.typeArr = type
state.typeArrVisited = true
}
},
actions:{
updateProjectArr({
commit,
state
},ignorant){
if(!state.projectArrVisited || ignorant){
return new Promise((resolve,reject) => {
let queryParam = {
query:{
projectCode:null,
projectName:null
},
pageSize:9999,
pageNum:1,
pageQuery:true,
fuzzyQuery:true,// default fuzzy query
orderby:null
}
projectApi.list(queryParam).then((res) => {
if(res.code === 200){
commit('setProjectArr',res.data)
resolve(res.data)
}else{
reject()
}
}).catch(() => {
reject()
});
})
}
},
updateTypeArr({
commit,
state
},ignorant){
if(!state.typeArrVisited || ignorant){
return new Promise((resolve,reject) => {
lineProcessApi.type().then((res) => {
if(res.code === 200){
commit('setTypeArr',res.data)
resolve(res.data)
}else{
reject()
}
}).catch(() => {
reject()
});
})
}
},
}
}

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

其中state和getters用来保存状态;mutations和actions用来改变状态;监听状态用的是Vue组件中的computed属性;module是用来组织整个应用的状态管理代码,使状态划分模块,更易于管理;辅助函数用来在监听状态时候简化代码,createStore则用来创建状态管理对象。

通过Vuex的mapActions可以直接获取到对应的数据,例如ProjectArr。

至此,下拉框的功能就实现完毕了。

有要求的下拉框

回顾一下我们的需求——除了dept,project和type下拉框,我们还有Line和process下拉框,这两个下拉框的数据有些不同,它必须要先选择了项目,才会出现下拉选项。而且,它还有一些映射关系:

  • 一个项目可能只有line,这个时候process下拉框没有数据
  • 一个项目可能只有process,这个时候line下拉框没有数据
  • 一个项目可能既有line又有process,这个时候两个下拉框都有数据

同时,这里数据的收集也很有意思,A表中的数据既有line和process,B表只有line的数据,所以假设项目a只有line,则必须在两个表中查询,不过A表可能没有需要的数据,只有B表才有。

这里在后端的实现与前面并没有什么区别,不过需要使用PostMapping而不是GetMapping了,因为需要接收一个project参数,然后再返回给前端Line和process的数据:

1
2
3
4
5
6
@PostMapping("/lineOrProcess")
@ApiOperation("lineOrProcess information")
public WebResult<List<BaseEmployeeEntity>> findLineOrProcess(@RequestBody BaseEmployeeEntity queryParam){
<List<BaseEmployeeEntity>> list = baseService.findLineOrProcess(queryParam);
return WebResult.ok(list);
}

值得思考的是,我们需要返回什么给数据?

一个思路是:一个标识符标识这是line还是process,这样我们返回的数据类似这样:

type code name
2 D1 process2
2 D2 process3
3 S1 line1

type为3代表是Line,type为2代表是process,code列用于标识不同的line或者process,而Name列的值用于显示在下拉框。

有了这个思路,就可以把这个想法在SQL中实现,后端的代码比较简单就不赘述了,serviceImpl调用Mapper方法,mapper方法再调用SQL即可。

SQL代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
findLineOrProcess
====
SELECT line_type,line_code,line_name
from A
where project_code = #{projectCode}
group by line_type,line_code,line_name
union
select '3',line_code,line_name
from B
where project_code = #{projectCode}
group by line_code,line_name

#{projectCode} 可见,该SQL脚本需要projectCode作为查询参数。而这里之所以要这么设计,使用一个Union,是为了满足前面的条件,因为A表可能又有line又有process数据,B表只有line数据,那么,当我们传入项目编码,就可以同时在表中搜寻:

它有以下几种可能发生:

  • 该项目只有line,line数据只在A表。这种情况我们从A表获取到对应的数据,B表没有获取到数据,union后也不会影响到从A表获取到的数据;
  • 该项目只有line,line数据只在B表。这种情况我们从B表获取到对应的数据,A表没有获取到数据,union后也不会影响到从B表获取到的数据;
  • 该项目的Line数据在A,B表都有,因此我们会从A,B表分别获取数据,union后合并。并且从B表获取的数据我们直接给type列命名为了’3’,这是因为B表只有表示为3的line数据。同时,由于sql的特性,union在前面的那个表字段名会覆盖后面的那个表字段,这样达成了我们的要求。
  • 该项目只有process,process数据我们知道只在A表有,因此B表不会获取到数据,union后也不会影响到从A表获取到的数据;
  • 该项目既有process又有line,同该项目的Line数据在A,B表都有的情况。

到此,我们在后端对数据的采集完成了,前端怎么收集呢?

很显然,我们需要对收集到的数据的type做一个判断,然后把它们分别放到对应的el-option的循环里面展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
toSectionSearch(val){
// project can not be null
if(
this.queryParam.projectCode == "" || this.queryParam.projectCode == undefined
){
this.$message.error("project can not be null");
return;
}
// Some projects only have processes or lines, and some have both processes and lines
if(val != ""){
this.selectLoading = true;
this.lineList=[];
this.processList=[];
lineProcessApi.lineOrProcess({
projectCode:this.queryParam.projectCode
}).then((res) => {
let sectionData = res.data;
// lineType is 3 means line
this.lineList = sectionData.map((item) =>{
if(item.lineType == "3"){
return{
value:item.lineCode,
label:item.lineName
};
}else{
return{
value:null,
label:null
};
}
});
// lineType is 3 means process
this.processList = sectionData.map((item) => {
if(item.lineType == "2"){
return{
value:item.lineCode,
label:item.lineName
};
}else{
return{
value:null,
label:null
};
}
});
// remove all null
this.lineList = this.lineList.filter((item) => {
return item.value != null;
});
this.processList = this.processList.filter((item) => {
return item.value != null;
});
});
}else{
this.lineList=[];
this.processList=[];
}

this.$nextTick(() => {
this.selectLoading=false;
});
},
1
2
3
4
5
6
7
8
9
10
11
12
13
import BaseAPI from ...

// use to run controller class as default
const api = new BaseAPI('/bi')

api.lineOrProcess = function(query){
return api.httpRequest({
// baseUrl is the default url "/" when the url is empty
url:api.baseUrl + '/lineOrProcess',
method:'post',
data:query || {}
})
};

这里的Vue代码首先进行了project不能为空的判断,然后调用了lineProcessApilineOrProcess方法(如代码片段二)所示。

通过调用lineOrProcess方法,获取到的数据如我们之前所展示的表格那样。

然后我们进行了一个判断,使用Map函数对里面的每个值进行判断,如果type为2就把它放到processList里面,type为3就把它放到lineList里面,如果不是就赋值为null。值得注意的是因为,需要使用filter筛除掉这些null值,因为null值也会显示在下拉框里面,表示为一片空白。

至此功能就实现了,这里的Vue代码展示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<el-select
:placeholder="process"
class="filter-item"
size="mini"
filterable
clearable
:loading="selectLoading"
v-model="queryParam.processCode"
style="float:left;margin-right:15px;"
>
<el-option
v-for="(item,index) in processList"
:key="index"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>

<el-select
:placeholder="line"
class="filter-item"
size="mini"
filterable
clearable
:loading="selectLoading"
v-model="queryParam.lineCode"
style="float:left;margin-right:15px;"
>
<el-option
v-for="(item,index) in lineList"
:key="index"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>

表格数据展示

这一部分相对简单,element-ui有对应的el-table可做数据展示,这里我直接用了已经封装好的table组件,叫app-table:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div style="margin-top: 5px;">
<el-tabs v-model="tabCurrent">
<div style="margin-top: 6px;">
<app-table
ref="table"
class="type-table"
v-loading="dataTable.loading"
:boarder="true"
:data="dataTable.dataList"
:columns="tableFields"
overflow="auto"
></app-table>
</div>
</el-tabs>
<pagination
:total="dataTable.total"
:page.sync="queryParam.page"
:limit.sync="queryParam.limit"
@pagination="handleList"
></pagination>
</div>


:data 存放后端传来的数据,pagination组件用于分页,需要page limit等参数进行页数显示和每页数据个数限制。

同时,获取数据的前端方法也是通过@pagination调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async handleList(){
this.dataTable.loading=true;
await lineProcessApi.findDetail({
deptCode:this.queryParam.deptCode,
projectCode:this.queryParam.projectCode,
processCode:this.queryParam.processCode,
lineCode:this.queryParam.lineCode,
type:this.queryParam.type,
beginDate:this.queryParam.beginDate,
endDate:this.queryParam.endDate,
name:this.queryParam.name,
employeeId:this.queryParam.employeeId,
page:this.queryParam.page,
limit:this.queryParam.limit,
}).then((res) => {
this.dataTable = Object.assign(this.dataTable,{
dataList:res.data.list,
total:res.data.totalRow
});
this.dataTable.loading=false;
});
},

这是一个异步方法,调用lineProcessApifindDetail方法,需要传递的参数已经显示出来了,即deptCode,projectCode等值,最后把获取的数据通过Object.assign分配给dataTable即可。

这一部分后端是如何实现的呢?

其实报表设计的思路很简单:

了解前端需要什么数据,后端需要前端传入什么数据即可

以这个思路出发,我们知道,想做查询,前端需要传给后端的参数就是页面上下拉框或者输入框的内容:

即deptCode, projectCode, processCode, lineCode, name, employeeId……等值,这些值存储在后端的一个DTO类中,通过mapper进行SQL查询,查询的结果放入一个VO类返回给前端。前端需要接收的数据,就是需要展示在表格的数据,即员工信息:name, address, type等等。

所以我们可以新建一个如下的DTO类,VO类也类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
    /** 
* @author lu
* @date 2023/...
* @version 1.0
* ...
*/

@Data
@ApiModel("BaseDTO")
public class BaseDTO extends PageParam implements Serializable{

private static final long serialVersionUID = ...;

@Query
@ApiModelProperty("deptCode")
private String deptCode;

@Query
@ApiModelProperty("process")
private String processCode;
@Query
@ApiModelProperty("line")
private String lineCode;

@Query
@ApiModelProperty("type")
private String type;

@Query
@ApiModelProperty("beginDate")
private String beginDate;

@Query
@ApiModelProperty("endDate")
private String endDate;

@Query
@ApiModelProperty("name")
private String name;

@Query
@ApiModelProperty("employeeId")
private String employeeId;

}

这个DTO类存放前端传来的数据,就可以按照正常的流程在Controller和Service层里面完成代码了,需要注意的是ServiceImpl类的代码:

1
2
3
4
5
6
7
8
9
10
@Override
publci PageResult<BaseVO> findDetail(BaseDTO queryParam){
// if deptCode is null, then give a default dept code by userId
if (StringUtils.isEmpty(queryParam.getDeptCode())){
queryParam.setDeptCode(anotherService.setHighDeptCode());
}
Map<String,Object> pageParam = query.getPageParam();
PageResult<BaseVO> vos = mapper.findDetail(queryParam.getPageRequest(),pageParam);
return vos;
}

这里需要做一些空值判断,因为用户刚刚看到页面的时候还没有传递任何参数,但是这个时候报表需要有一些值显示出来,因此在这里需要设置一些默认值。

pageParam是beetlsql的分页类,存储了limit(每页显示数据个数)和page(当前页数)信息。

这些数据将在mapper层通过@Root注解组合查询:

1
2
3
4
5
6
7
   /**
* @author lu
* @date 2023/...
* @version 1.0
* ...
*/
PageResult<BaseVO> findDetail(PageRequest<BaseDTO> query, @Root Map pageParam);

SQL代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
findDetail
====

SELECT -- @pageTag(){
name,employeeId,sex...
-- @}
FROM(
nbe.name as name,
nbe.employeeId as employeeId,
...
FROM
employee nbe INNER JOIN(
SELECT nbe.deptId,nbd.deptName
FROM department nbd START WITH nbd.deptId = #{deptCode}
CONNECT BY nbd.up_deptId = PRIOR nbd.deptId
) nbd ON nbd.deptId = nbe.deptId
LEFT JOIN ...
WHERE 1=1
-- @if(!isEmpty(projectCode) || !isBlank(projectCode)){
and nbe.projectCode = #{projectCode}
-- @}
-- @if(...){
...
-- @}
...
GROUP BY
nbe.name,
nbe.employeeId,
...)

暂时忽略 -- @ 的内容,这里的sql逻辑比较容易理解,关联几个相关的表,然后where对应的限定条件,然后通过Group by 去重。值得一提的是这里beetlsql的内容,首先之所以在select的最外层还嵌套了一层select是beetlsql的要求,如果要是用Group by 进行分页,需要在最外层进行嵌套。

那么,为什么去重不使用distinct呢?这里就要涉及beetlsql分页的一个缺陷了。

beetlsql在分页的时候需要计算出一个TotalRow属性,这个属性用于计算数据总数,然后分页的时候展示出来对应的页数。但是这个总数的计算方式与select内写的内容无关,beetlsql直接调用了select count(*) 函数新建了一个SQL查询作为TotalRow的结果,这也就导致了select distinct的结果与它发生了冲突(因为select count(*)不会去重),所以这里需要使用Group by

但是另一个问题是,beetlsql在使用Group by的时候,不能直接对Group by的结果分页,需要在外面嵌套一层select才能分页,因此这里的SQL写成了内外嵌套的格式。

-- @pageTag()用在select,可以实现分页计算;

-- @if(!isEmpty(projectCode) || !isBlank(projectCode))用于条件判断,仅在if语句内的结果有效的时候,后面的SQL语句才会生效。

至此,数据表的整体逻辑实现完毕。

直方图展示

该需要要求动态展示未来十个月的每个月员工合同到期数量,并且如果当月员工到期数为0,依然要显示出来。

直方图的绘制不能直接从后端开始,因为我们不知道需要什么参数现在。

需要的参数取决于页面如何绘制,这里需要使用到Echarts组件。因此,这里我创建一个新的Vue组件:histogram.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<template>
<div
ref="echarts"
class="echarts"
:style="{width,height}"
></div>
</template>

<script>
import * as echarts from 'echarts';
import ...

export default {
components:{},
props:{
width:{
type:String,
default:"2400px"
},
height:{
type:String,
default:"200px"
},
options:Object
},
watch:{
options:{
handler:function(newVal,oldVal){
this.redraw()
},
deep:true
}
},
computed:{
myOptions(){
return this.options || {}
}
},
data(){
return{
myChart:null
};
},
mounted() {
this.init()
},
destroyed() {
window.onresize=null
this.myChart = null
bus.$off('toggleSideBar')
},
methods: {
init(){
this.myChart = echarts.init(this.$refs.echarts)
this.myChart.setOption(this.myOptions)
window.onresize = () => this.myChart.resize()
bus.$on('toggleSideBar',data=>{
this.redraw()
window.onresize = () => this.myChart.resize()
})
},
showLoading(){
this.myChart.showLoading()
},
hideLoading(){
this.myChart.hideLoading()
},
redraw(){
if(!this.myOptions) return
this.myChart.clear()
this.myChart.setOption(this.myOptions)
this.myChart.resize()
}
},
}
</script>

<style lang="scss" scoped>
.echarts{
left:0;
right:0;
top:0;
bottom:0;
margin: 0;
padding: 0;
}

</style>

这个绘图组件需要接受传递给他的一些属性,它的使用如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<template>
<div class="app-container">
<div class="filter-container header-search" style="display:flex;flex-wrap:wrap;">

<el-row style="margin-top: 20px;">
<el-col :span="10">
<div class="echart-box">
<histogram
:options="myOptions"
ref="typeByMonth"
></histogram>
</div>
</el-col>
</el-row>

</div>
</div>
</template>

<script>
import Histogram from "...";

export default {
name:"...",
components:{
AppTable,
AppFilterForm,
Histogram,
CustCascader
},
data() {
return {
myOptions:{
title:{
text:"...",
show:true
},
tooltip:{
trigger:"axis",
axisPointer:{
type:"shadow"
},
},
grid:{
left:"...",
right:"..."
},
xAxis:{
type:"category",
data:[],
axisPointer:{
type:"shadow",
},
},
yAxis:{
type:"value"
},
series:[
{
name:"...",
type:"bar",
itemStyle:{
normal:{
color: "#5470c6",
},
},
data:[]
},
],
},
};
},
methods: {
// plot chart
typeByMonth(){
lineProcessApi.plotChart({
deptCode:this.queryParam.deptCode,
projectCode:this.queryParam.projectCode,
processCode:this.queryParam.processCode,
lineCode:this.queryParam.lineCode,
type:this.queryParam.type,
beginDate:this.queryParam.beginDate,
endDate:this.queryParam.endDate,
name:this.queryParam.name,
employeeId:this.queryParam.employeeId,
page:this.queryParam.page,
limit:this.queryParam.limit,
}).then((res) => {
if(res.code == 200){
this.chartList = res.data;
this.myOptions.xAxis.data = this.chartList.map((item) => item.month);
this.myOptions.series[0].data = this.chartList.map((item) => item.count);
this.$refs.typeByMonth.redraw();
}
});
},
},


};
</script>

<style lang="scss" scoped>
.echart-box{
padding: 15px;
border: 1px solid #dcdfe6;
box-shadow: 0 2px 4px 0 rgba(0, 0,0,0.12), 0 0 6px 0 rgba(0, 0,0,0.04);
}

</style>

这里,可见直方图的数据是通过:options即组件中的watch属性进行传递的,传递数据的方法则是通过调用ref里面的typeByMonth方法执行的。

这个方法与之前进行的数据查询方法很类似,但是区别在于接受的数据不再是需要显示在每个列中,而是如下所示:

month count
2023-1 300
2023-2 350
2023-3 400

month列的数据将会被用作x轴展示,即分别是哪几个月:

this.myOptions.xAxis.data = this.chartList.map((item) => item.month);

count列的数据会被放在y轴展示,即每个月的员工数量:

this.myOptions.series[0].data = this.chartList.map((item) => item.count);

所以前端接收的参数少了很多,这一部分逻辑只需要基于前文后端做查询的逻辑稍作修改即可。其实就是在SQL代码中做一点修改,不再返回select的详细信息,而是group by 月份后返回count函数的结果。

不过值得注意的是这里需要动态展示月份数据,因此需要在后端完成一部分逻辑;同时需求中还提示了,如果当月员工到期数为0,依然要显示出来。这与SQL的表达逻辑有点冲突,因为group by的时候可能会忽略掉0值(当然通过SQL也可以做到不忽略),但是这部分代码写在后端或许更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Override
public List<chartVO> plotChart(chartDTO queryParam){
// if deptCode is null, then give a default dept code by userId
if (StringUtils.isEmpty(queryParam.getDeptCode())){
queryParam.setDeptCode(anotherService.setHighDeptCode());
}
List<chartVO> chartData = mapper.plotChart(queryParam);
List<chartVO> chartVO = nextNmonthData(10);
// Map, time complexity O(n)
HashMap<String,String> map = new HashMap<>(chartData.size());
for(int i = 0;i < chartData.size();i++){
map.put(chartData.get(i).getMonth(),chartData.get(i).getCount());
}
for(chartVO vo : chartVO){
if(map.containsKey(vo.getMonth())){
vo.setCount(map.get(vo.getMonth()));
}
}
return chartVO;
}

/** return next N month data
*
* @author lu
* @date 2023/...
* @version 1.0
* ...
*/
private List<chartVO> nextNmonthData(int n){
AyyayList<chartVO> arr = new ArrayList<>(n);
LocalDate today = LocalDate.now();
// plus n month
for(int i = 0;i < n;i++){
LocalDate localDate = today.plusMonth(i);
String ym = LocalDate.format(DateTimeFormatter.ofPattern("yyyy-mm"));
chartVO vo = new chartVO();
vo.setMonth(ym);
vo.setCount("0");
arr.add(vo);
}
return arr;
}

这里我新写了一个方法,用于创建一个存放未来十个月且都是0的List——chartVO。chartVO类只有两个属性,一个是month,一个是count。

然后这里使用了HashMap遍历SQL的查询结果,当SQL结果对应的月份有数据的时候,就把chartVO中对应的月份的数据从0更改为对应的数据,之所以使用HashMap是为了优化时间复杂度,限制在O(N)内。

至此,直方图的显示就完成了。

总结

完全的代码可以在这里看到:

Github

总的来说,这一次需求相比之前难了很多,但是整体逻辑并不难,报表需求的完成只需要想清楚:

前端需要什么数据,后端需要前端传入什么数据

另外,还学到了一些功能设计的思路:

  1. 级联选择器设计
  2. 下拉框设计与后端交互
  3. 有前提要求的下拉框与后端交互
  4. 表格数据展示与后端交互
  5. 直方图设计


定时调度邮件任务优化


需求描述

上一任设计定时调度任务的员工为图省事,在接收到调度任务的数据后,直接以toString的方式返回了数据,导致每次邮件内容都长这样:

项目记录——企业需求

很显然,这不是利于人类阅读的模式,因此这个需求要求我更改这个需求的样式,把这些数据按照表格的形式展示出来,同时附件带有EXCEL文件。合理的样式应该如下所示:

项目记录——企业需求(2)

问题

看到需求,接下来需要解决的问题是:

  • 定时任务怎么调试?

  • 获取的数据长什么样子?

  • 应该怎么实现EXCEL作为附件写入邮件?

  • 怎么让邮件按照需要的样子进行展示?

思路与实现

这个项目更多的是拓展了我的见识,了解了如何使用定时调度任务,以及了解了EasyPoi的导出excel等功能。

另外,这是一个纯后端项目:

后端

首先,这个代码已经完成的阶段大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Scheduled(cron = "0 0 23 * * ?")
@Transactional(rollbackFor = Exception.class)
public void removeEmployee() throw Exception{
... get data by SQL ...

List<sysUserEntity> userList = new ArrayList<>();
List<businessUserEntity> arrList = new ArrayList<>();

... put data into these two arraylist ...

MailUtil.send("...@...",userList.toString,...);
MailUtil.send("...@...",arrList.toString,...);
}

现在,根据我们之前提到的解决项目需求的方法思考:

通过思考问题解决需求

针对第一个问题,定时任务怎么调试?

调试定时任务的方法可以参考之前我发的另一个帖子

第二个问题,获取的数据长什么样子?

之前的代码中,发送任务执行了两次,并且发送的是userList和arrList两个数组,这两个数组包装了sysUserEntity和businessUserEntity 两个实体类,所以我们可以想到发送的就是这两个实体类的数据。

而要同时发送两次,它们可能有大同小异的操作,所以我想到可能这里需要写一个泛型方法去接收这两个实体类。

第三个问题,应该怎么实现EXCEL作为附件写入邮件?

这里可能就应该用到一些API了,例如 Hutool MailUtil.send方法发送邮件。

这个方法接收邮箱名等参数,更重要的是它还接收一个html模板参数,所以在这里我们可以写一个btl模板配置文件用于优化原本的邮件格式。

到此,基本的思路就已经有了。

第四个问题,怎么让邮件按照需要的样子进行展示?

这个问题需要一步一步分析,并且它可以拓展为很多小问题。

首先,我们打算使用Hutool MailUtil.send方法发送邮件,这个方法接收什么参数呢?

MailUtil.send

因为这个方法被重载过,所以我直接介绍我们要用的方法的参数:

Hutool的这个方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 发送邮件给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML格式
* @param files 附件列表
*/
public static void send( Collection<String> tos, String subject, String content, boolean isHtml, File... files) {
Mail.setTos(tos.toArray(new String[tos.size()]))//
.setTitle(subject)//
.setContent(content)//
.setHtml(isHtml)//
.setFiles(files)//
.send();
}

总结一下,它需要这些参数:

  • tos 收件人,正常可以设置多个,这里只有一个收件人
  • subject 邮件标题
  • content 邮件正文,可以是文本,也可以是HTML内容
  • isHtml 是否为HTML,如果是,那参数3识别为HTML内容
  • File 可选:附件,可以为多个或没有,将File对象加在最后一个可变参数中即可

所以,可以看出来,如果我们希望修改邮件的格式,需要增加一个html页面在参数中。而且,这个html页面还需要把数据传进去,在邮件中显示出来。所以,这里我使用了BeelUtiltemplate,它需要一个btl文件。

这个文件按照html格式在邮件开头增加了

“Dear Manager: Employee who resigned … “ 这段话,同时用 td 等html标签生成了一个表格,数据采用 jsp 动态生成,这里就不详细展示了。

这里想要使用 BeelUtil 导入模板的代码如下:

1
2
3
4
5
GroupTemplate gt = BeelUtil.getGt();
Template template = gt.getTemplate(templateKeyPath);
Map<String,Object> map = new HashMap<>();
map.put("lists",listsCopy);
template.binding(map)

templateKeyPath 是 btl 文件的路径,listsCopy是复制的arraylist的数据。之所以要复制一份,是因为如果不复制一份用原数据的话,template 在binding之后这些数据就消失了,最后对导致 template 找不到需要的数据,发出一个空邮件。

Excel 导出

搞定模板了,现在由于我们还需要在邮件中添加一个excel附件,所以需要先把这些数据导出到一个excel里面,然后最后把这个excel文件放在 MailUtil.send 的参数里面。

Excel文件的导出,这里选用的是 EasyPoiExcelExportUtil.exportExcel 方法

这个方法也有很多次重载,选择我们要使用的,源码展示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 根据Entity创建对应的Excel
*
* @param entity
* 表格标题属性
* @param pojoClass
* Excel对象Class
* @param dataSet
* Excel对象数据List
*/
public static Workbook exportExcel(ExportParams entity, Class<?> pojoClass, Collection<?> dataSet) {
Workbook workbook;
if (ExcelType.HSSF.equals(entity.getType())) {
workbook = new HSSFWorkbook();
} else if (dataSet.size() < 1000) {
workbook = new XSSFWorkbook();
} else {
workbook = new SXSSFWorkbook();
}
new ExcelExportServer().createSheet(workbook, entity, pojoClass, dataSet,null);
return workbook;
}

可见,exportExcel 方法需要的参数是:

  • entity exportParams类,这个类里面包含了excel文件的各种属性,例如标题,标题行内容等等
  • pojoClass 需要转变的entity类,在这个例子里面就是sysUserEntity.classbusinessUserEntity.class
  • dataSet 传入的数据

这里出于隐私角度,不详细介绍我要在这里设置的exportParams了,而是封装在getExportParams方法里面了。最后把导出的excel读入作为 MailUtil.send 的参数使用。具体如下所示:

1
2
3
4
5
6
7
8
9
10
11
// set title in excel    
ExportParams export = this.getExportParams(title);

// export Excel
Workbook workbook = ExcelExportUtil.exportExcel(export,entityClass,lists);
...
com.spire.xls.Workbook _workbook = poiUtils.convertToSpireWorkbook(workbook);
// encapsulation file outputStream method
File file = this.getFileAttach(_workbook);
// send email
MailUtil.send("....@...",subjectTitle,template.render(),true,file);
  • title 是要手动传入的标题字符串,通过getExportParams设置为excel 内部的标题。

  • subjectTitle 是要手动传入的邮件标题字符串。

  • template.render() 是对前文获取到的template模板进行渲染

但是这里有个问题,这个 entityClass 是需要的类,而对这个例子,我们知道,指的是sysUserEntity.classbusinessUserEntity.class,可是这是个泛型方法!因为类型擦除的原因,你不能直接获取 T.class

那么,问题来了——怎么获取泛型类型?

反射获取泛型Class对象

在idea 里面尝试写 T.class 连编译都无法通过,那么应该怎么办呢?

虽然泛型会在字节码编译过程中被擦除,但是Class对象会通过java.lang.reflect.Type记录其实现的接口和继承的父类信息。我们以ArrayList<E>为例:

1
2
3
4
ArrayList<String> strings = new ArrayList<>();
Type genericSuperclass = strings.getClass().getGenericSuperclass();
// genericInterfaces = java.util.AbstractList<E>
System.out.println("genericSuperclass = " + genericSuperclass);

结果是 占位符 E。

但是显然,我们想要的是String 而不是那个E。

genericSuperclass 是 Type 类型,而Type有四种类型:

  • GenericArrayType 用来描述一个参数泛型化的数组。
  • WildcardType 用来描述通配符?相关的泛型,包含的?、下界通配符? super E 、上界通配符? extend E
  • Class<T> 用来描述类的Class对象。
  • ParameterizedType 用来描述参数化类型。

看看instanceof 它们分别会输出什么呢?

1
2
3
4
5
6
7
8
ArrayList<String> strings = new ArrayList<>();

Type genericSuperclass = strings.getClass().getGenericSuperclass();

System.out.println( genericSuperclass instanceof ParameterizedType); // true
System.out.println( genericSuperclass instanceof Class); // false
System.out.println( genericSuperclass instanceof WildcardType); // false
System.out.println( genericSuperclass instanceof GenericArrayType); // false

所以,选择参数化类型方法能获得什么?

1
2
3
ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass;
Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
System.out.println("actualTypeArguments = " + Arrays.toString(actualTypeArguments));

返回的Type[] 数组里面只有一个 [E],看来结果还是E,似乎失败了。

但是为什么呢?

原因其实在ArrayList上,看看ArrayList的源码:

1
2
3
4
5
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
...
}

ArrayList实例化时只指定了自己的泛型类型而没有指定父类AbstractList的具体泛型,所以获取到的还是占位符E

那在这里,实际操作的时候,我要是就是想要 arrayList 里面的 String 类型应该怎么办呢?

实际上有个简单的方法:(构建匿名子类实现)

ArrayList strings = new ArrayList(){}; // 看这里最后加了个大括号

我们通过大括号{}就可以重写实现父类的方法并指定父类的泛型具体类型。为什么呢?因为加一个大括号这种写法相当于是定义匿名内部类,编译时可以确定类型为String。

那项目这里我们就可以这么写了:

1
2
Type genericSuperclass = lists.getClass().getGenericSuperclass();
Class<?> entityClass = (Class<?>)((ParameterizedType) genericSuperclass).getActualTypeArguments()[0];

lists 就是我们要传入的 ArrayList 了。

在配置文件设置收件人

到这一步,基本上代码已经完成了,但是目前收件人的邮箱是固定的,固定在 MailUtil.send 的第一个参数里面。

如果我还希望在配置文件里面更改这个收件人邮箱怎么办呢?

很简单,在 application.properties 里面设置键值对:

auto.email=”aaaaa@bbbbb.com

在Java Bean注入:

1
2
@Value("${auto.email}")
private String email;

然后在 MailUtil.send 的参数里面写 this.email 即可。

总结

完全的代码大致如此:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Value("${auto.email}")
private String email;

@Override
public <T> void sendDeletedEmployeeAuto(List<T> lists, String templateKeyPath,String subjectTitle,
String title) throws IOException{
if (CollectionUtils.isNotEmpty(lists) && lists.size() > 0){
// Get the generic type
Type genericSuperclass = lists.getClass().getGenericSuperclass();
Class<?> entityClass = (Class<?>)((ParameterizedType) genericSuperclass).getActualTypeArguments()[0];

// Copy arraylist to help template read
List<T> listsCopy = new ArrrayList<>();
listsCopy = ListUtil.toCopyOnWriteArrayList(lists);

// template
GroupTemplate gt = BeelUtil.getGt();
Template template = gt.getTemplate(templateKeyPath);
Map<String,Object> map = new HashMap<>();
map.put("lists",listsCopy);
template.binding(map)

// set title in excel
ExportParams export = this.getExportParams(title);

// export Excel
Workbook workbook = ExcelExportUtil.exportExcel(export,entityClass,lists);
...
com.spire.xls.Workbook _workbook = poiUtils.convertToSpireWorkbook(workbook);
// encapsulation file outputStream method
File file = this.getFileAttach(_workbook);
// send email
MailUtil.send(this.email,subjectTitle,template.render(),true,file);

}

}

发送邮件后就可以按需求的样子展示了,具体的样式看自己写的html文件了。

这个需求是我之前没接触过的全新项目,还尝试了自己写一个泛型方法,可以说很有意思了。主要学习到了:

  1. HutoolMailUtil.send 方法的使用
  2. EasyPoiExcelExportUtil.exportExcel 方法使用
  3. 如何通过反射获取泛型类型
  4. 如何把配置文件属性注入类中使用


点击下钻跳转新页面


独立项目开始了

需求描述

对Vue 表格中的表格数据新增一个点击方法,点击数据可以跳转一个新页面,新页面有点击数据的详细信息。

假设我现在有一个已经实现的饼状图,显示出ABCD四个岗位的百分比,同时饼状图下方是一个表格,显示了岗位名,人数和百分比,大致如下所示:

【饼状图】

岗位类型 人数 百分比
A 100 10%
B 200 20%
C 300 30%
D 400 40%

现在需要当我们点击人数的时候(即点击上面表格中的100),会跳转到一个新页面,新页面使用表格形式显示这100个人的信息,同时实现分页。如下所示:

岗位类型 姓名 工号 部门 职位 性别
A pyrrhic 025618 IT Staff M
A Mike 159736 Business CEO F
A Done 789156 IT Junior F

问题

看到需求,我思考了一下,觉得需要解决的问题是:

  • 怎么添加前端的点击事件?

  • 前端怎么实现跳转新页面?

  • 新页面需要实现哪些功能?

  • 原数据已经有了,我应该怎么获取?在哪里获取?

  • 前后端分别需要什么参数?

思路与实现

在这里,与上一个项目不同,这里不仅需要前端代码反推后端,新的解决企业项目的思路是:

通过思考问题解决需求

前端

  • 第一个问题:怎么添加前端的点击事件

这里原本功能的实现者使用的element-ui的el-table实现表格功能,但是我们知道Vue表格的数据不是写死的,而是通过:data动态绑定的,el-table组件也没有列绑定事件,那应该怎么才能给对应的列添加点击事件呢?

这里困扰了我很久,网上相关的模棱两可的说法有太多了,也没有找到很合适的。最后我决定查看已经实现的其他页面,寻找可能的解决方案。这诞生了一个新的解决企业项目的思路:

举一反三

通过我不懈的思考和寻找,我发现在另外一个页面同样实现了类似的需求。它虽然没有给数据里面添加点击事件,但是它对表格内的某一列的数据做了判断,如果为空即返回“空”字符串。

这给了我一个启示,只要这么写就好:

1
2
3
4
<template #value = "scope">
<span @click="employeeDetail(scope.row)">{{scope.row.value}}</span>
</template>

通过#号绑定data里面带有slot属性的列,再调用click方法,Vue就能找到对应的方法了。这里的方法需要能够跳转新页面。

  • 那么,第二个问题:前端怎么实现跳转新页面?

这里需要使用push方法,即:

1
2
3
4
5
6
7
8
9
10
methods:{
employeeDetail(row){
this.$route.push({
name: ...,
params:{
...
}
})
}
}

很显然,找到公司代码山里面的router.vue,添加你要跳转的页面名字和链接,同时指向一个新的你要创建的Vue组件——你即将创建的Vue组件就是新的页面,现在只需要create一个new file即可!

  • 现在第三个问题:新页面需要实现哪些功能?

很显然,我需要一个 el-table 表格存放后端传来的数据,我还需要一个pagination标签对表格进行分页。

至此,前端的基本框架和内容已经基本架构完毕了,现在看看后端

后端

  • 第四个问题:原数据已经有了,我应该怎么获取?在哪里获取?
  • 第五个问题:前后端分别需要什么参数?

这两个问题都可以通过分析后端解决。

通过分析后端方法,能了解到后端这里获取员工数量是通过这样的逻辑:

Controller -> Service -> Mapper -> SQL

最后通过SQL的count函数获取的人数,大致如下所示:

1
2
3
4
5
select count(employeeId) as A
from ... inner join ...
where deptId = #{deptId}
and jobType = #{jobType}
and ...

这里吐槽一下公司用的BeetlSQL + Oracle SQL这种梦幻组合,有很多不同于之前了解的Mysql语法,而且还能添加 if函数,可以说非常奇特,让我大开眼界。

那这个逻辑稍微修改一下就可以获得员工的详细信息了:

1
2
3
4
5
select jobType,Name,employeeId,dept,position,sex
from ... inner join ...
where deptId = #{deptId}
and jobType = #{jobType}
and ...

参数什么的都不需要改,可以说很方便了。而且也可以猜到,需要的参数应该就是部门ID(deptId)和工作类型(jobType)了。另外分页可能也需要参数 page 和limit。

只需要新建一个VO类,存放返回的 jobType,Name,employeeId,dept,position,sex 信息即可,略过Controller类和Service类代码,直接看Service层的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public PageResult<BaseVO> findEmployeeDetail(BaseDTO query){
// If deptId is null, set a default id by its login session
if (StringUtils.isEmpty(query.getDeptId())){
query.setDeptId(getDefaultDeptId());
}
// Extract parameters except pagination parameters
Map<String,Object> pageParam = query.getPageParam();

PageResult<BaseVO> BaseVOs = mapper.findEmployeeDetail(query,pageParam);
return BaseVOs;

}

这里防止了一个空指针,然后还完成了分页逻辑,只需要向前端索要上一个页面传递的参数即可。

至此,后端需要做的事情也基本清楚了,需求的整体已经很清晰了。

总结

最后,在新页面,前端可以通过api的方法传递参数(接收上一个页面push过来的this.$router.params.deptId等等),再多传入几个分页参数,例如page:1limit:10 。最后调用Controller方法,返回的数据,前端通过.then(res => ...) 来接收即可。

这个需求相比上一个已经有了一点难度,更重要的是又有了两个解决项目需求的思路:

  1. 通过思考问题解决需求

  2. 举一反三


连接迁移


最近接到了自己的第一个需求,为一个已经做好的页面做迁移使得可以做到外部访问并展示的效果。什么意思呢?后文会对这个需求做解释。

总之,作为一个开发新人,不得不说面对企业级的庞大代码,后端前端加起来上百个类的时候是一脸懵的,更别说还需要直接面对一个自己之前没接触过的需求了。当然,产品经理也明白一来让我做很难的需求也不现实,这个需求的实际实现并不算难,很多关键部位的代码已经被其他人完成了,但是对我来说也还是一个巨大的挑战。

需求描述

简单的说,这个项目需求要求我把原来放在管理系统中的页面可以通过外部链接进行访问。

前端的页面和功能很多是通过类似Vue-element-admin的方式展示的。我需要把其中一个已经完成的Vue页面——一张BI报表,绕过登录系统做到可以直接通过链接访问的形式(当然并不是完全不需要登录验证了,只是换了一种验证形式),至于完成后会拿来怎么用,现在我不是太清楚,可能会有一些别的APP的超链接指向这里。

问题

需求是这样的,但是依旧有很多不理解的问题:

  • 链接?是什么样的链接去访问的?它应该是什么样子的?
  • 前端应该怎么实现?
  • 后端需要做什么?
  • 其他同事已经做了哪些事情?

思路与实现

前端

作为一个新人,需要不断学习,但是当然不能是闷头学习。

通过请教前辈,了解到这个连接迁移功能的实现是在一个Vue页面实现的,也是通过这一节课,我学到了了解项目,破解需求的第一个思路:

前端代码反推后端

这里展示一下大概的前端页面构造,出于隐私考虑隐瞒了具体信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
<div>
<component :is="XXcomponents[index]"></component>
</div>
</template>

<script>
import ...

export default{
data(){
return {
XXcomponents:[
'a',
'b',
'c'...
],
index:0
}
},
created(){
window.myData = this
this.routeData = this.$route.query
this.method()
},
methods:{
method(){
this.$store.dispatch('/.../loginByUsername',this.routeData.data)
.then(() => {
let index = this.XXcomponents.findIndex(i => i == this.routeData.name )
...
}).catch(() => {
...
})
}
},
components:{
a,
b,
c...
}
}

<script>
<style>...</style>

这里的template标签呈现前端内容,可以看到只有一个:is绑定的动态组件,联想一下我们要做的是连接迁移,所以有理由可以猜想这里的components可能是根据index确定传入的页面是什么,然后在前端展示。

于是我自然地去看data属性里面的数据,可以看到XXcomponents对应的正好也是components属性里面的组件,这些组件是通过import导入的其他已经写好的Vue页面,这证实了我的猜想。

现在,我们已经知道了前端是怎么通过一个Vue页面实现连接迁移的功能了:

通过切换index切换列表里面要展示的Vue页面

那么,后端呢?

我看到这里Vue的生命周期create里面有写this.$route.query,这说明这个Vue页面创建的时候需要接收参数,这里的参数是直接写在网址里面的,this.$route.query的结果被写入了this.routeData,那后面的代码出现了this.routeData.name和this.routeData.data,那有理由相信传入链接里面的参数应该是类似这个样子的,例如:

localhost:8888/index/data=123&name=123

那传入的数据就是:

1
{data:123, name:123}

后端

接收参数的事情往往和后端就有关系了,那它接收什么参数呢?

暂时不知道了,但是后面有个method方法,这个方法内部使用了异步的dispatch方法,向一个叫做loginByUsername的方法发送了参数,发送的值是我们前文提到的链接中的name属性对应的值。

现在,前端访问了后端一个叫做loginByUsername的方法了,并且把其中的name属性对应的值以Json格式传递了过去。

打开后端idea,输入:ctrl + shift + f,寻找匹配的loginByUsername方法。

很快我就找到了匹配的方法,一个写在@Controller里面的@PostMapping方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping
public WebResult loginByUsername (@Requestbody UserNameDTO userNameDTO){
String userName = (userNameDTO.getUserName())
// get token
String token = ...(userName);
......
return WebResult.ok(token);
}

@Data
class UserNameDTO{

public String userName;
}

这个方法接收一个@Requestbody 的UserNameDTO实体类,然后用它获取username,把username通过Service层封装好的方法经过Base64加密后作为认证信息返回给前端。(WebResult是一个封装好的类,用于定制返回值到WSDL的映射)

至此,后端的功能也确认了:

后端需要前端传入的userName信息,然后通过userName产生一个用于身份验证、网页跳转即连接迁移的token返回给前端。

总结

最后,链接的data属性将会被后面的findIndex方法用作确定index,然后根据index匹配对应的vue页面进行展示。

到此,我们可以通过访问如下链接:

localhost:8888/index/data=study&name=James

匹配到一个属于James的study.vue前端界面,并展示对应的信息。

总的来说,这个需求并不难,其中核心的功能实现,例如dispatch对应的网址,根据Index匹配对应的vue页面,后端的token生成等功能的实现已经被封装了。不过作为第一个需求,对我来说也有一定难度,更重要的是学会了

  1. 如何在企业项目中理清思路——通过前端代码反推后端代码
  2. 重要的ctrl + shift + f