element plus地点选择器

参考链接:https://blog.csdn.net/m0_63209237/article/details/134030737

value为string数组

第一步,安装中国全省市区的数据

1
sudo npm install element-china-area-data -S

第二步,在要使用地址选择器的页面导入数据

1
2
3
4
5
6
7
import {
provinceAndCityData,
pcTextArr,
regionData,
pcaTextArr,
codeToText,
} from "element-china-area-data";

导入数据说明:

1
2
3
4
5
provinceAndCityData:省市二级联动数据,汉字+code
regionData:省市区三级联动数据
pcTextArr:省市联动数据,纯汉字
pcaTextArr:省市区联动数据,纯汉字
codeToText:是个大对象,属性是区域码,属性值是汉字 用法例如:codeToText['110000']输出北京市

html 部分

1
2
3
4
5
6
7
8
9
10
<template>
<div id="app">
<el-cascader
size="large"
:options="pcaTextArr"
v-model="area"
placeholder="请点击选择区域">
</el-cascader>
</div>
</template>

js 部分

1
2
import { pcaTextArr } from 'element-china-area-data'
const area = ref('')

MP 分页实现

访问:http://localhost:8080/activity?page=1&size=1

1
2
3
4
5
6
7
8
9
10
11
12
// 通过构建一个分页查询接口,实现获取activity表中所有数据的接口
@GetMapping
public R<Page<Activity>> getAll(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
// 创建分页对象
Page<Activity> activityPage = new Page<>(page, size);

// 使用 MyBatis Plus 进行分页查询
Page<Activity> result = activityService.page(activityPage);

return R.ok(result);
}

注意需要加配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

日历实现

功能描述

在日历中展示未来的活动,使用变深颜色标记有活动的日期。当点击该日期时,显示活动详情,包括活动名称、时间和地点。

实现步骤

​ 1. 活动日期的标记:

​ • 在每个日期单元格中,判断该日期是否有活动。

​ • 如果该日期有活动,将日期单元格的背景颜色变深(如#BFDFFF),设置成圆形,并将文字变为白色,以示区分。

​ • 当日期处于当前月,透明度为1;否则,透明度减半(0.5)。

​ 2. 日期点击触发活动详情展示:

​ • 点击某一日期后,检查是否有活动数据。

​ • 如果该日期有活动数据,切换 Popover 的显示状态,以展示活动信息;如果无活动数据,则隐藏 Popover。

​ 3. Popover 组件用于显示活动详细信息:

​ • 弹出活动详情,包含:

​ • 活动名称:以粗体显示。

​ • 活动时间:以本地时间格式显示(格式:年-月-日 时:分:秒)。

​ • 活动地点:显示活动举办地点。

​ • Popover 弹出位置为下方(placement=”bottom”),宽度设置为200px,并且不使用teleport,以避免重定位。

​ 4. 数据加载和格式化:

​ • 在组件挂载时调用getAllActivity接口,加载所有活动数据。

​ • 将活动按日期(startTime字段的日期部分)分组存储到activityByDate中,便于按日期查询活动。

​ 5. 关键变量

​ • activityByDate:按日期组织的活动数据对象,结构为 { [key: string]: Activity[] }。

​ • popoverVisible:记录每个日期是否显示活动详情 Popover 的布尔状态。

代码说明

​ • <el-calendar> 组件展示日历,并通过 #date-cell 插槽自定义日期单元格。

​ • handleDateClick 函数切换 Popover 的显示状态。

​ • getAllActivity 异步获取活动数据,并处理数据格式。

​ • Activity 接口定义了活动对象的基本信息,包括 id、name、startTime、place 等字段。

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
<template>
<div class="pt-10 px-10">
<el-calendar>
<!-- 自定义日期单元格 -->
<template #date-cell="{ data }">
<div :style="{
backgroundColor: activityByDate[data.day] ? '#BFDFFF' : '',
color: activityByDate[data.day] ? 'white' : '',
opacity: data.type === 'current-month' ? 1 : 0.5,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative'
}" @click="handleDateClick(data.day)">
{{ data.day.split('-')[2] }} <!-- 显示日期的日部分 -->

<!-- 动态 Popover 展示活动信息 -->
<el-popover
v-if="popoverVisible[data.day]"
v-model:visible="popoverVisible[data.day]"
width="100%"
:teleported="false"
>
<div v-for="activity in activityByDate[data.day]" :key="activity.id">
<p><strong>{{ activity.name }}</strong></p>
<p>时间: {{ new Date(activity.startTime).toLocaleString() }}</p>
<p>地点: {{ activity.place }}</p>
</div>
</el-popover>
</div>
</template>
</el-calendar>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';

import { Activity } from '../interfaces/Activity';
import { getAllActivity } from '../api/activity';

const loading = ref(false);
const allData = ref<Activity[]>([]);
const activityByDate = ref<{ [key: string]: Activity[] }>({});
const popoverVisible = ref<{ [key: string]: boolean }>({});

onMounted(async () => {
loading.value = true;
try {
const res = await getAllActivity();
allData.value = res.data.records;

// 按日期字符串组织活动
allData.value.forEach((activity) => {
const date = activity.startTime.split('T')[0];
if (!activityByDate.value[date]) {
activityByDate.value[date] = [];
}
activityByDate.value[date].push(activity);
});
} catch (error) {
ElMessage.error('加载活动数据失败');
} finally {
loading.value = false;
}
});

// 点击日期显示活动详情的 Popover
const handleDateClick = (day: string) => {
if (activityByDate.value[day]) {
// 切换 Popover 可见状态
popoverVisible.value[day] = !popoverVisible.value[day];
} else {
popoverVisible.value[day] = false;
}
};
</script>

日期不显示

  1. 一开始将date作为Date类型使用,准备用解析日期的方式获取yyyy-MM-dd格式的数据,但是实际上EP已经准备了对应的插槽数据,date底下就有一个.day属性,格式好了所有的日期
  2. date-cell插槽中数据的是固定的,就你说使用data,而不是date,否则会获取不到数据(类型:{ data: { type: ‘prev-month’ | ‘current-month’ | ‘next-month’, isSelected: boolean, day: string, date: Date } })

el-popover位置错位问题

需要将需要定位的元素作为relative,并将el-popover移动至元素下面(其实一般是使用插槽去实现,但是会与日历组件冲突)、开启teleported,最大的踩坑点在于需要是:teleported,冒号是不能忘记的

上传获取图片

首先代码

将文件目录作为静态资源路径,以便可以通过 URL 访问到上传的图片。可以在 Spring Boot 的配置文件中指定静态资源路径。

在 Spring Boot 的配置文件中定义上传目录的路径,方便管理和更改。

1
2
3
4
5
6
7
spring:
web:
resources:
static-locations: file:/Users/tec/Desktop/

file:
upload-dir: /Users/tec/Desktop
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
package com.tec.campuscareerbackend.controller;


import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.tec.campuscareerbackend.common.R;
import com.tec.campuscareerbackend.entity.Activity;
import com.tec.campuscareerbackend.service.IActivityService;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.List;

/**
* <p>
* 活动信息表 前端控制器
* </p>
*
* @author TECNB
* @since 2024-10-31
*/
@RestController
@RequestMapping("/activity")
public class ActivityController {

// 定义文件存储的目录路径(可以通过 application.yml 或 application.properties 配置)
@Value("${file.upload-dir}")
private String uploadDir;

@Resource
private IActivityService activityService;

// 上传活动照片到服务器
@PostMapping("/file")
public R<String> uploadFile(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return R.error("文件不能为空");
}

try {
// 创建上传目录(如果不存在)
File dir = new File(uploadDir);
if (!dir.exists()) {
dir.mkdirs();
}

// 保存文件到指定目录
File uploadFile = new File(dir, file.getOriginalFilename());
file.transferTo(uploadFile);

return R.ok("文件上传成功:" + uploadFile.getName());
} catch (IOException e) {
e.printStackTrace();
return R.error("文件上传失败:" + e.getMessage());
}
}
}

接着安全性考虑

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 匹配所有 URL 路径
.allowedOrigins("http://localhost:5173") // 允许的前端地址
.allowedMethods("GET", "POST", "PUT", "DELETE") // 允许的请求方法
.allowedHeaders("*") // 允许的请求头
.allowCredentials(true) // 允许携带 Cookie
.maxAge(3600); // 设置预检请求的缓存时间(单位:秒)
}
}

关联表

踩坑点在于必须使用@TableField(exist = false)禁止将该属性映射到数据库表中

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
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("activity")
public class Activity implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 活动ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Integer id;

/**
* 活动图片链接
*/
private String activityImage;

/**
* 存储活动的所有图片路径,不映射到数据库表
*/
@TableField(exist = false)
private List<String> imagePaths;
}

下载并打包为压缩包

文件名乱码问题

不使用默认java提供的ZipArchiveOutputStream

1
2
3
4
5
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.21</version>
</dependency>
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
@PostMapping("/download")
public ResponseEntity<ByteArrayResource> downloadAttachmentsZip(@RequestBody EmploymentDatabase employmentDatabase) {
List<EmploymentDatabaseAttachment> urls = employmentDatabase.getAttachment();

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(byteArrayOutputStream)) {
// 设置 ZipOutputStream 的编码为 UTF-8
zipOutputStream.setEncoding("UTF-8");

for (EmploymentDatabaseAttachment url : urls) {
URL fileUrl = new URL(url.getFilePath());
try (InputStream inputStream = fileUrl.openStream()) {
// 使用 UTF-8 编码的文件名
ZipArchiveEntry entry = new ZipArchiveEntry(url.getFileName());
zipOutputStream.putArchiveEntry(entry);

byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, len);
}
zipOutputStream.closeArchiveEntry();
}
}
} catch (Exception e) {
e.printStackTrace();
}

ByteArrayResource byteArrayResource = new ByteArrayResource(byteArrayOutputStream.toByteArray());
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=attachments.zip");
return ResponseEntity.ok()
.headers(headers)
.contentLength(byteArrayResource.contentLength())
.contentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM)
.body(byteArrayResource);
}

el-tree-select

注意使用multiple可以多选,并且可以通过其他命令调整大标题是否可选

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 第六行 -->
<div class="flex flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">网申链接:</p>
<el-input v-model="applicationLink" placeholder="请输入链接" />
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">发送人群:</p>
<el-tree-select
v-model="targetAudience"
:data="treeData"
placeholder="请点击选择发送人群"
size="large"
clearable
:props="defaultProps"
multiple
show-checkbox
collapse-tags
class="dynamic-height-select"
/>
</div>
</div>

配置树状选择栏部分

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
// 定义目标受众的响应式变量
export const targetAudience = ref<any>([]);

// 定义树形选择的数据结构
export const treeData = ref([
{
label: '21级本科',
children: [
{ label: '电子信息2101', value: '电子信息2101' },
{ label: '电子信息2102', value: '电子信息2102' },
{ label: '电子信息2103', value: '电子信息2103' },
{ label: '电子信息2104', value: '电子信息2104' },
{ label: '软件工程2101', value: '软件工程2101' },
{ label: '软件工程2102', value: '软件工程2102' }
]
},
{
label: '22级本科',
children: [
{ label: '电子信息2201', value: '电子信息2201' },
{ label: '电子信息2202', value: '电子信息2202' },
{ label: '电子信息2203', value: '电子信息2203' },
{ label: '电子信息2204', value: '电子信息2204' },
{ label: '软件工程2201', value: '软件工程2201' },
{ label: '软件工程2202', value: '软件工程2202' }
]
},
{
label: '23级本科',
children: [
{ label: '电子信息2301', value: '电子信息2301' },
{ label: '电子信息2302', value: '电子信息2302' },
{ label: '电子信息2303', value: '电子信息2303' },
{ label: '电子信息2304', value: '电子信息2304' },
{ label: '软件工程2301', value: '软件工程2301' },
{ label: '软件工程2302', value: '软件工程2302' }
]
},
{
label: '24级本科',
children: [
{ label: '电子信息2401', value: '电子信息2401' },
{ label: '电子信息2402', value: '电子信息2402' },
{ label: '电子信息2403', value: '电子信息2403' },
{ label: '电子信息2404', value: '电子信息2404' },
{ label: '软件工程2401', value: '软件工程2401' },
{ label: '软件工程2402', value: '软件工程2402' }
]
}
]);

// 定义 el-tree-select 的属性配置
export const defaultProps = {
children: 'children',
label: 'label',
value: 'value'
};

通过key的方式对子组件强制渲染

参考:https://www.cnblogs.com/zhangycun/p/13268577.html

Vue 3 提供的响应式 API 可以通过事件触发和 ref 变量的方式更简洁地实现刷新操作,无需直接操作子组件的内部方法。你可以在父组件中使用状态(如refreshKey)来驱动子组件刷新。以下是这种更符合 Vue 3 哲学的实现方法:

  1. 在父组件中,定义一个 refreshKey 的 ref,并在上传成功后更改 refreshKey 的值,子组件会自动响应这个变化:

    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
    import { ref } from 'vue';

    const fileInput = ref<HTMLInputElement | null>(null);
    const refreshKey = ref(0); // 用于触发子组件重新渲染

    const handleFileUpload = () => {
    fileInput.value?.click();
    };

    const onFileChange = async (event: Event) => {
    const target = event.target as HTMLInputElement;
    const file = target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append('file', file);

    try {
    const response = await fetch('http://localhost:5173/api/user-detail/importExcel', {
    method: 'POST',
    body: formData,
    });

    if (response.ok) {
    ElMessage.success('文件上传成功!');
    refreshKey.value += 1; // 上传成功后更改 refreshKey 值,触发子组件刷新
    } else {
    ElMessage.error('文件上传失败,请重试!');
    }
    } catch (error) {
    ElMessage.error('上传过程中出现错误!');
    }
    };
  2. 在模板中,将 refreshKey 传递给 <UserDetailTable> 组件:

    1
    2
    <UserDetailTable :key="refreshKey" :dateOrder="dateOrder" :typeOrder="typeOrder" />
    <input type="file" ref="fileInput" @change="onFileChange" accept=".xls, .xlsx" style="display: none" />
  3. <UserDetailTable> 子组件中,每次接收到新的 key 值时会自动重新加载,因此你只需要在 onMounted 钩子中调用 fetchTableData:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const fetchTableData = async () => {
    loading.value = true;
    try {
    const response = await getAllUserDetails();
    tableData.value = response.data;
    } finally {
    loading.value = false;
    }
    };

    onMounted(fetchTableData); // 页面加载时获取数据

通过更改 key 值,Vue 会自动重新渲染 <UserDetailTable> 组件,这样可以避免直接调用子组件方法,保持代码更加简洁。

手机适配

参考链接:https://juejin.cn/post/7265129339195424827

taliwincss 常用样式

踩坑点主要有需要加上!增加其样式的优先级,以及 elementui 部分的内含样式太多,不好轻易改,所以通过媒体查询+v-if的方式去进行手机的适配

手机存在,大屏幕不存在

1
class="!hidden md:!block"

字体调整

1
class="md:text-4xl font-extrabold text-3xl"

IndexView.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
<template>
<div class="IndexView flex p-0 md:p-5">
<!-- 仅在大屏幕上显示 sidebar (大于md) -->
<div class="min-w-52 hidden md:block">
<Sidebar device="pc"/>
</div>
<div class="md:w-[86%] w-full h-full">
<div class="">
<Header />
</div>
<div class="h-[93%]">
<router-view></router-view>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import Sidebar from '../components/Sidebar.vue'
// 引入Header
import Header from '../components/Header.vue'

</script>

<style lang="scss" scoped>
.IndexView {
height: 100vh;
background: #F2F8FC;
}
</style>

slider转为点击图标出现菜单

1
2
3
4
5
6
7
8
9
10
<template>
<div class="Header rounded-none md:rounded-2xl md:mx-6 md:mb-6 m-0 px-5">
<el-icon size="20" class="!block md:!hidden mr-2" @click="showSlider">
<Menu/>
</el-icon>

<Sidebar device="phone" v-if="ifShowSidebar"/>
<MaskLayer :ifShow="ifShowSidebar" @click="showSlider"/>
</div>
</template>

媒体查询

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
<template>
<el-scrollbar height="100%">
<el-table :data="tableData" class="tableBox" table-layout="fixed" @selection-change="handleSelectionChange"
v-loading="loading" :row-style="{ height: '80px' }">
<!-- v-if实现隐藏和显示的逻辑 -->
<el-table-column
v-if="isMediumScreen"
type="selection"
width="40"
:class="{ 'hidden-md': !isMediumScreen }"
/>
</el-table>
</el-scrollbar>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";

// 定义是否处于中等屏幕以上的状态
const isMediumScreen = ref(false);

// 更新屏幕宽度的响应式逻辑
const updateScreenSize = () => {
isMediumScreen.value = window.innerWidth >= 768;
};

onMounted(() => {
updateScreenSize(); // 初始化时检查屏幕大小
window.addEventListener("resize", updateScreenSize); // 监听窗口变化
});

onBeforeUnmount(() => {
window.removeEventListener("resize", updateScreenSize); // 组件卸载时移除监听器
});

const handleSelectionChange = (selection: any) => {
console.log("Selection changed:", selection);
};
</script>

Vue 3 router addroute no match found

参考链接:https://www.jianshu.com/p/95569fd0b20a

原因:当 name 名相同时,后面的路由会覆盖前端的路由。

easyexcel 报错ExceptionInInitializerError

参考链接:https://github.com/alibaba/easyexcel/issues/2040、https://github.com/alibaba/easyexcel/issues/2240

官网:https://easyexcel.opensource.alibaba.com/docs/current/quickstart/read

原因:easyexcel 版本设定低了,应该按照下面版本,修复了在JDK17中的适配问题

1
2
3
4
5
6
<!--表格导入-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.1.0</version>
</dependency>

java自带ZipOutputStream压缩文件名为中文的乱码问题

参考链接:https://blog.csdn.net/cqstart116/article/details/44728821

原因:java自带的工具就没有设定编码的功能,所以改用apache的工具

1
2
3
4
5
6
<!-- 文件上传 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.21</version>
</dependency>

代码

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
@RestController
@RequestMapping("/employment-database")
public class EmploymentDatabaseController {
@Resource
private IEmploymentDatabaseService employmentDatabaseService;
@Resource
private IEmploymentDatabaseAttachmentService employmentDatabaseAttachmentService; // 用于保存附件路径
@PostMapping("/download")
public ResponseEntity<ByteArrayResource> downloadAttachmentsZip(@RequestBody EmploymentDatabase employmentDatabase) {
List<EmploymentDatabaseAttachment> urls = employmentDatabase.getAttachment();

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(byteArrayOutputStream)) {
// 设置 ZipOutputStream 的编码为 UTF-8
zipOutputStream.setEncoding("UTF-8");

for (EmploymentDatabaseAttachment url : urls) {
URL fileUrl = new URL(url.getFilePath());
try (InputStream inputStream = fileUrl.openStream()) {
// 使用 UTF-8 编码的文件名
ZipArchiveEntry entry = new ZipArchiveEntry(url.getFileName());
zipOutputStream.putArchiveEntry(entry);

byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, len);
}
zipOutputStream.closeArchiveEntry();
}
}
} catch (Exception e) {
e.printStackTrace();
}

ByteArrayResource byteArrayResource = new ByteArrayResource(byteArrayOutputStream.toByteArray());
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=attachments.zip");
return ResponseEntity.ok()
.headers(headers)
.contentLength(byteArrayResource.contentLength())
.contentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM)
.body(byteArrayResource);
}
}

ElementPlus upload fileList为空

参考链接:https://www.sunzhongwei.com/vue-element-ui-upload-file-upload-component-after-file-list-is-empty-array

  1. 不使用 action 可以避免用户上传了图片,但是没保存表单,导致图片冗余的问题,但是实际上不使用 action fileList又为空,导致极难获取到上传到图片,所以这个还没有实现

  2. 使用 action 上传

带属性搜索

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
@RestController
@RequestMapping("/employment-database")
public class EmploymentDatabaseController {
@Resource
private IEmploymentDatabaseService employmentDatabaseService;
@Resource
private IEmploymentDatabaseAttachmentService employmentDatabaseAttachmentService; // 用于保存附件路径
// 搜索就业信息
@GetMapping("/search")
public R<Page<EmploymentDatabase>> searchEmploymentDatabase(
@RequestParam(required = false) String filterField,
@RequestParam(required = false) String filterValue,
@RequestParam int page,
@RequestParam int size) {

Page<EmploymentDatabase> employmentDatabasePage = new Page<>(page, size);
QueryWrapper<EmploymentDatabase> queryWrapper = new QueryWrapper<>();

// 根据字段名动态添加查询条件
if (filterField != null && filterValue != null) {
switch (filterField) {
case "category":
queryWrapper.like("category", filterValue);
break;
case "title":
queryWrapper.like("title", filterValue);
break;
case "attachment":
queryWrapper.like("attachment", filterValue);
break;
case "details":
queryWrapper.like("details", filterValue);
break;
case "createdAt":
queryWrapper.like("created_at", filterValue);
break;
default:
return R.error("无效的筛选字段");
}
}

Page<EmploymentDatabase> result = employmentDatabaseService.page(employmentDatabasePage, queryWrapper);
return R.ok(result);
}
}

智能匹配岗位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/job-search")
public class JobSearchController {
@Resource
private IJobSearchService jobSearchService;
// 智能匹配岗位接口,通过 studentId 获取 className 后再匹配岗位
@GetMapping("/match")
public R<Page<JobSearch>> matchJobsByStudentId(@RequestParam String studentId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
Page<JobSearch> result = jobSearchService.matchJobsByStudentId(studentId, page, size);
return R.ok(result);
}

}

导入excel

关键实现步骤

​ 1. 上传文件

​ • 使用 @RequestParam(“file”) MultipartFile file 接收用户上传的文件。

​ • 通过 file.getInputStream() 获取文件的输入流。

​ 2. 读取 Excel 数据

​ • 使用 EasyExcel 的 read 方法读取文件内容:

1
2
3
4
List<UserDetailExcelDto> userList = EasyExcel.read(file.getInputStream())
.head(UserDetailExcelDto.class)
.sheet()
.doReadSync();

​ • head(UserDetailExcelDto.class):指定 Excel 数据对应的实体类 UserDetailExcelDto,表示每一行数据将被映射到该对象。

​ • sheet():默认读取第一个工作表。

​ • doReadSync():同步读取数据,结果存储为一个 List

​ 3. 数据校验和保存

​ • 遍历 userList,对每条数据进行处理:

1
2
3
4
5
for (UserDetailExcelDto dto : userList) {
if (dto.getName() == null || dto.getName().isEmpty()) {
return R.ok("导入成功");
}
}

​ • 检查关键字段(如 dto.getName())是否为空,跳过空白行。

​ 4. 保存到数据库

​ • 保存到 user_detail

1
2
3
4
5
6
7
8
9
UserDetail userDetail = new UserDetail();
userDetail.setName(dto.getName());
userDetail.setGender(dto.getGender());
userDetail.setClassName(dto.getClassName());
userDetail.setStudentId(dto.getStudentId());
userDetail.setContactNumber(dto.getContactNumber());
userDetail.setClassTeacher(dto.getClassTeacher());
userDetail.setGraduationTutor(dto.getGraduationTutor());
userDetailService.save(userDetail);

将 Excel 数据映射到 UserDetail 实体类,并调用 userDetailService.save 保存到数据库。

​ • 保存到 users

1
2
3
4
5
6
7
8
9
10
11
Users user = new Users();
user.setStudentId(dto.getStudentId());
user.setUsername(dto.getName());
String initialPassword = dto.getStudentId().substring(dto.getStudentId().length() - 6);
String salt = generateSalt();
String passwordHash = encryptHv(initialPassword, salt);
user.setPasswordHash(passwordHash);
user.setSalt(salt);
user.setUserType("student");
user.setPhone(dto.getContactNumber());
usersService.save(user);

初始化用户账号信息:

​ • 生成默认密码:学号后 6 位。

​ • 调用加密方法 encryptHv 进行密码加密。

​ • 调用 usersService.save 保存到数据库。

完整代码:

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
@RestController
@RequestMapping("/user-detail")
public class UserDetailController {
@Resource
private IUserDetailService userDetailService;
@Resource
private IUsersService usersService;
@PostMapping("/importExcel")
public R<String> importExcel(@RequestParam("file") MultipartFile file) {
try {
// 读取Excel数据并过滤只获取需要的字段
List<UserDetailExcelDto> userList = EasyExcel.read(file.getInputStream())
.head(UserDetailExcelDto.class)
.sheet()
.doReadSync();

for (UserDetailExcelDto dto : userList) {
// 检查关键字段是否为空,判断是否为空白行
if (dto.getName() == null || dto.getName().isEmpty()) {
// 遇到空白行,跳出循环并返回成功
return R.ok("导入成功");
}
// 保存到 user_detail 表
UserDetail userDetail = new UserDetail();
userDetail.setName(dto.getName());
userDetail.setGender(dto.getGender());
userDetail.setClassName(dto.getClassName());
userDetail.setStudentId(dto.getStudentId());
userDetail.setContactNumber(dto.getContactNumber());
userDetail.setClassTeacher(dto.getClassTeacher());
userDetail.setGraduationTutor(dto.getGraduationTutor());
userDetailService.save(userDetail);

// 初始化保存到 users 表
Users user = new Users();
user.setStudentId(dto.getStudentId());
user.setUsername(dto.getName());

// 生成初始密码为学号后6位
String initialPassword = dto.getStudentId().substring(dto.getStudentId().length() - 6);
String salt = generateSalt();
String passwordHash = encryptHv(initialPassword, salt);

user.setPasswordHash(passwordHash);
user.setSalt(salt);
user.setUserType("student");
user.setPhone(dto.getContactNumber());
usersService.save(user);
}
return R.ok("导入成功");
} catch (Exception e) {
e.printStackTrace();
return R.error("导入失败: " + e.getMessage());
}
}
}

GPT新建表惯例

  1. 请根据下面的表单,修改其表单字段名称,多余的属性删去,缺少的属性补上:

  2. 根据上面的字段为我创建一个名为conversation_records的数据表,请你把sql语句给我

  3. 请给我对应的rowjson,方便我测试添加接口,注意驼峰

  4. 请你参考下面的Dto,根据上面的字段编写一份ConversationRecordsExcelDto,多余的属性删去,缺少的属性补上:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    public class UserDetailExcelDto {
    @ExcelProperty("姓名")
    private String name;
    @ExcelProperty("性别")
    private String gender;
    @ExcelProperty("所在班级")
    private String className;
    @ExcelProperty("学号")
    private String studentId;
    @ExcelProperty("手机号码")
    private String contactNumber;
    @ExcelProperty("班主任")
    private String classTeacher;
    @ExcelProperty("导师")
    private String graduationTutor;
    }
  5. 请你参考下面的importExcel接口,根据上面的字段编写一份新的接口:

    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
    @PostMapping("/importExcel")
    public R<String> importActivityTargetAudienceExcel(@RequestParam("file") MultipartFile file) {
    try {
    // 使用 EasyExcel 读取 Excel 数据
    List<ActivityTargetAudienceExcelDto> audienceList = EasyExcel.read(file.getInputStream())
    .head(ActivityTargetAudienceExcelDto.class)
    .sheet()
    .doReadSync();

    // 过滤掉空白行
    List<ActivityTargetAudienceExcelDto> validAudienceList = audienceList.stream()
    .filter(dto -> dto.getAudienceLabel() != null && !dto.getAudienceLabel().isEmpty())
    .collect(Collectors.toList());

    // 将 DTO 转换为实体列表
    List<ActivityTargetAudience> audienceEntities = validAudienceList.stream().map(dto -> {
    ActivityTargetAudience entity = new ActivityTargetAudience();
    entity.setId(dto.getId());
    entity.setMajor(dto.getMajor());
    entity.setAudienceLabel(dto.getAudienceLabel());
    entity.setAudienceValue(dto.getAudienceValue());
    return entity;
    }).collect(Collectors.toList());

    // 批量保存或更新
    if (!audienceEntities.isEmpty()) {
    activityTargetAudienceService.saveOrUpdateBatch(audienceEntities);
    }

    return R.ok("导入成功");
    } catch (Exception e) {
    e.printStackTrace();
    return R.error("导入失败: " + e.getMessage());
    }
    }

GPT新建表格导入规范惯例

  1. 我有一个excel导入数据库的功能,现在需要我为每个excel的每个标题定一个存入标准,防止存入时报错,请你根据字段以及对应sql语句,给我相关的文字标准描述:

    序号 展位号 招聘企业 招聘岗位 HR名称 联系电话 所需专业 招聘人数 薪资待遇 地区 网申链接 其他要求 企业简介

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    DROP TABLE IF EXISTS job_search;
    CREATE TABLE job_search (
    id int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    company_name varchar(255) NOT NULL COMMENT '企业名称',
    position_name varchar(255) NOT NULL COMMENT '岗位名称',
    hr_name varchar(100) DEFAULT NULL COMMENT 'HR名称',
    hr_phone varchar(20) DEFAULT NULL COMMENT '联系电话',
    major_requirement varchar(255) DEFAULT NULL COMMENT '专业要求',
    participant_count int DEFAULT '0' COMMENT '招聘人数',
    money varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '薪资待遇',
    area varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '工作地点',
    application_link text COMMENT '网申链接',
    additional_requirements text COMMENT '其他要求',
    company_description text COMMENT '企业简介',
    created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id)
    ) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='岗位发布详情表';
  2. 参考JobSearchExcellmportListener,为我编写一个ActivityTargetAudienceImportListener,错误消息要求逻辑为第2列班级必填、第3列专业名称必填且不允许出现“专升本”这三个字、第4列年级必填,且只能为数字,由于我的audienceValue存入时是string,具体的逻辑,可以是先尝试转化为Integer,如果失败则抛出然后存入错误:

    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
    public class JobSearchExcelImportListener extends AnalysisEventListener<JobSearchExcelDto> {

    private final List<JobSearchExcelDto> jobList;
    private final List<Map<Integer, String>> errorDataList;

    public JobSearchExcelImportListener(List<JobSearchExcelDto> jobList, List<Map<Integer, String>> errorDataList) {
    this.jobList = jobList;
    this.errorDataList = errorDataList;
    }

    @Override
    public void invoke(JobSearchExcelDto dto, AnalysisContext context) {
    Map<Integer, String> errors = new HashMap<>();

    // 校验第 3 列: 招聘企业
    if (dto.getCompanyName() == null || dto.getCompanyName().isEmpty()) {
    errors.put(2, "招聘企业不能为空");
    }

    // 校验第 4 列: 招聘岗位
    if (dto.getPositionName() == null || dto.getPositionName().isEmpty()) {
    errors.put(3, "招聘岗位不能为空");
    }

    // 校验第 7 列: 所需专业
    if (dto.getMajorRequirement() == null || dto.getMajorRequirement().isEmpty()) {
    errors.put(6, "所需专业不能为空");
    } else if (!Pattern.matches("^[\\w/\\u4e00-\\u9fa5]+$", dto.getMajorRequirement())) {
    errors.put(6, "所需专业必须使用“/”分割");
    }

    // 校验第 8 列: 招聘人数
    if (dto.getParticipantCount() == null) {
    errors.put(7, "招聘人数不能为空");
    }else if (dto.getParticipantCount() == 0) {
    errors.put(7, "招聘人数必须为数字整数");
    }

    // 校验第 9 列: 薪资待遇
    if (dto.getMoney() == null || dto.getMoney().isEmpty()) {
    errors.put(8, "薪资待遇不能为空");
    } else {
    List<String> validSalaries = Arrays.asList("2000-5000", "5000-8000", "8000-15000", "15000以上","面议");
    if (!validSalaries.contains(dto.getMoney())) {
    errors.put(8, "薪资待遇格式错误,仅支持以下格式: 2000-5000, 5000-8000, 8000-15000, 15000以上, 面议");
    }
    }

    // 校验第 10 列: 地区
    if (dto.getArea() == null || dto.getArea().isEmpty()) {
    errors.put(9, "地区不能为空");
    } else if (!Pattern.matches("^[\\w,\\u4e00-\\u9fa5]+$", dto.getArea())) {
    errors.put(9, "地区必须使用英文逗号分割");
    }

    // 保存错误信息到 dto
    dto.setErrorMessages(errors);

    // 将 dto 和错误信息添加到列表
    jobList.add(dto);
    errorDataList.add(errors.isEmpty() ? new HashMap<>() : errors); // 确保每行都有一个 Map
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
    // 全部解析完成后的处理
    }
    }

    ActivityTargetAudienceExcelDto:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    public class ActivityTargetAudienceExcelDto {
    @ExcelProperty("序号")
    private Integer id;

    @ExcelProperty("年级")
    private String audienceLabel;

    @ExcelProperty("班级")
    private String audienceValue;

    @ExcelProperty("专业名称")
    private String major;

    @ExcelIgnore
    private Map<Integer, String> errorMessages = new HashMap<>();
    }
  3. 参考JobSearchController的importExcel修改ActivityTargetAudienceController的importExcel

    JobSearchController的importExcel:

    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
    @PostMapping("/importExcel")
    public void importJobSearchExcel(@RequestParam("file") MultipartFile file, HttpServletResponse response) {
    try {
    // 存储读取的数据和错误信息
    List<JobSearchExcelDto> jobList = new ArrayList<>();
    List<Map<Integer, String>> errorDataList = new ArrayList<>();

    // 创建 ExcelImportListener
    JobSearchExcelImportListener listener = new JobSearchExcelImportListener(jobList, errorDataList);

    // 使用 EasyExcel 读取 Excel 数据,使用自定义监听器
    EasyExcel.read(file.getInputStream(), JobSearchExcelDto.class, listener)
    .sheet()
    .doReadSync(); // 使用同步读取方式,确保读取所有行

    // 如果没有数据,则返回提示
    if (jobList.isEmpty()) {
    response.setContentType("application/json");
    response.setCharacterEncoding("utf-8");
    response.getWriter().write("{\"message\":\"导入数据为空\"}");
    return;
    }

    // 检查是否存在错误
    boolean hasErrors = jobList.stream()
    .anyMatch(dto -> dto.getErrorMessages() != null && !dto.getErrorMessages().isEmpty());

    if (hasErrors) {
    // 如果有错误,生成错误文件并返回
    response.setContentType("application/vnd.ms-excel");
    response.setCharacterEncoding("utf-8");
    response.setHeader("Content-Disposition", "attachment;filename=error_data.xlsx");

    // 调用 EasyExcel 写入错误数据
    EasyExcel.write(response.getOutputStream(), JobSearchExcelDto.class)
    .registerWriteHandler(new ErrorCellStyleHandler(errorDataList))
    .sheet("错误数据")
    .doWrite(jobList);
    return;
    }

    // 使用 mapToJobSearch 将 DTO 转换为实体列表
    List<JobSearch> jobEntities = jobList.stream()
    .map(this::mapToJobSearch) // 调用 mapToJobSearch 方法
    .collect(Collectors.toList());

    // 批量保存或更新
    if (!jobEntities.isEmpty()) {
    jobSearchService.saveOrUpdateBatch(jobEntities);
    }

    // 返回成功信息
    response.setContentType("application/json");
    response.setCharacterEncoding("utf-8");
    response.getWriter().write("{\"message\":\"导入成功\"}");
    } catch (Exception e) {
    e.printStackTrace();
    try {
    response.setContentType("application/json");
    response.setCharacterEncoding("utf-8");
    response.getWriter().write("{\"message\":\"导入失败: " + e.getMessage() + "\"}");
    } catch (IOException ioException) {
    ioException.printStackTrace();
    }
    }
    }

利用 CASE 语句,将 money 转换为数值后按从高到低排序

在代码中,CASE 语句用于将文字形式的薪资范围(如 “2000-5000”)转换为对应的数值,以便进行条件过滤和排序操作。以下是具体解析:

CASE 语句基本结构

CASE 是 SQL 中的一种条件表达式,类似于编程语言中的 if-else 或 switch-case,用于根据条件返回不同的值。

语法如下:

CASE

WHEN condition1 THEN result1

WHEN condition2 THEN result2

ELSE resultN

END

​ • WHEN condition1 THEN result1:如果 condition1 为真,返回 result1。

​ • ELSE resultN:当没有任何条件为真时,返回 resultN(可以省略)。

在我们的代码中,CASE 被用在 QueryWrapper.apply 和 orderByDesc 中。

用法 1:映射薪资范围到数值

queryWrapper.lambda()

.apply(“CASE “ +

​ “WHEN money = ‘2000-5000’ THEN 2000 “ +

​ “WHEN money = ‘5000-8000’ THEN 5000 “ +

​ “WHEN money = ‘8000-15000’ THEN 8000 “ +

​ “WHEN money = ‘15000以上’ THEN 15000 “ +

​ “ELSE 0 END >= {0}”, lowSalaryValue);

解析:

​ 1. money = ‘2000-5000’:

​ • 如果 JobSearch.money 为 “2000-5000”,返回 2000。

​ 2. money = ‘5000-8000’:

​ • 如果 JobSearch.money 为 “5000-8000”,返回 5000。

​ 3. ELSE 0:

​ • 如果 JobSearch.money 的值不在已知范围内,返回 0。

最终,这段 CASE 的结果是一个整数值,可以用来和用户的最低薪资需求(lowSalaryValue)进行数值比较。

例如:如果用户的 lowSalaryValue 是 8000,那么查询条件会变成:

CASE

WHEN money = ‘2000-5000’ THEN 2000

WHEN money = ‘5000-8000’ THEN 5000

WHEN money = ‘8000-15000’ THEN 8000

WHEN money = ‘15000以上’ THEN 15000

ELSE 0

END >= 8000

这将筛选出薪资范围为 “8000-15000” 和 “15000以上” 的数据。

用法 2:排序

queryWrapper.orderByDesc(“CASE “ +

​ “WHEN money = ‘2000-5000’ THEN 2000 “ +

​ “WHEN money = ‘5000-8000’ THEN 5000 “ +

​ “WHEN money = ‘8000-15000’ THEN 8000 “ +

​ “WHEN money = ‘15000以上’ THEN 15000 “ +

​ “ELSE 0 END”);

解析:

​ 1. CASE 的逻辑同上,将文字形式的 money 映射为可比较的数值。

​ 2. orderByDesc 按照这些数值从大到小排序。

例如:

​ • “15000以上” -> 15000

​ • “8000-15000” -> 8000

​ • “5000-8000” -> 5000

​ • “2000-5000” -> 2000

最终,结果将按这些映射值从高到低排序。

SQL 执行逻辑示例

假设表 JobSearch 中有如下数据:

money

2000-5000

5000-8000

8000-15000

15000以上

SQL 查询语句:

SELECT *

FROM JobSearch

WHERE

CASE

​ WHEN money = ‘2000-5000’ THEN 2000

​ WHEN money = ‘5000-8000’ THEN 5000

​ WHEN money = ‘8000-15000’ THEN 8000

​ WHEN money = ‘15000以上’ THEN 15000

​ ELSE 0

END >= 8000

ORDER BY

CASE

​ WHEN money = ‘2000-5000’ THEN 2000

​ WHEN money = ‘5000-8000’ THEN 5000

​ WHEN money = ‘8000-15000’ THEN 8000

​ WHEN money = ‘15000以上’ THEN 15000

​ ELSE 0

END DESC;

执行结果:

​ • WHERE 过滤出 money >= 8000 的数据,即:”8000-15000” 和 “15000以上”。

​ • ORDER BY 按映射值排序,最终结果:

​ 1. “15000以上”

​ 2. “8000-15000”

总结

​ • CASE 用于将无法直接比较的文字形式数据(薪资范围)转换为数值。

​ • 筛选通过 CASE 结合 WHERE 实现。

​ • 排序通过 CASE 映射值结合 ORDER BY 实现。

​ • 这种方法兼容文字范围的动态映射,同时保留了高效的 SQL 查询能力。

如何让item.title部分的p标签保持只有一行,多处一行的部分用…代替

Tailwind 的 truncate 类作用

truncate 类的实际效果等同于以下 CSS:

1
2
3
overflow: hidden; /* 超出部分隐藏 */
text-overflow: ellipsis; /* 显示省略号 */
white-space: nowrap; /* 文本不换行 */

效果

​ 1. 文本长度超过容器宽度时,会自动加上 …。

​ 2. 文本强制保持单行,不会因为内容过多而换行。

自定义宽度容器

如果需要限制 title 的最大显示宽度以便触发省略号,可以添加 max-w-* 类。例如:

1
2
3
<p class="text-lg text-blue-400 cursor-pointer hover:text-black truncate max-w-[200px]">
{{ item.title }}
</p>

其中:

​ • max-w-[200px]:限制最大宽度为 200px(可以根据设计需求调整)。

​ • 这将确保在宽度超过 200px 时文本溢出,触发省略号效果。

esayexcel

esayexcel导入失败

参考链接:https://segmentfault.com/q/1010000018526018

1
导入失败: Text '2024-11-23' could not be parsed: Unable to obtain LocalDateTime from TemporalAccessor: {},ISO resolved to 2024-11-23 of type java.time.format.Parsed

导入失败原因:LocalDateTime 不能转换 yyyy-MM-dd 这种格式的字符串,LocalDateTime 是 LocalDate + LocalTime 两部分都得有,也就是说上面的格式字符串缺少了小时分钟以及秒,将类似的类型改为LocalDate就行

esayexcel导出

后端接口:

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
@GetMapping("/exportJobSearchExcel")
public void exportJobSearchExcel(HttpServletResponse response) {
try {
// 查询数据库中的 JobSearch 数据
List<JobSearch> jobSearchList = jobSearchService.list();

if (jobSearchList.isEmpty()) {
throw new RuntimeException("无数据可导出");
}

// 将实体对象转换为 DTO 对象
List<JobSearchExcelDto> jobSearchDtoList = jobSearchList.stream()
.map(this::mapToJobSearchExcelDto)
.collect(Collectors.toList());

// 设置响应头,确保文件正确下载
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("求职信息", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName + ".xlsx");

// 使用 EasyExcel 写入数据到响应流
EasyExcel.write(response.getOutputStream(), JobSearchExcelDto.class)
.sheet("求职信息")
.doWrite(jobSearchDtoList);
} catch (Exception e) {
e.printStackTrace();
try {
// 导出失败时返回 JSON 错误信息
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write("{\"message\":\"导出失败: " + e.getMessage() + "\"}");
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}

/**
* 将 JobSearch 实体对象转换为 JobSearchExcelDto
*/
private JobSearchExcelDto mapToJobSearchExcelDto(JobSearch jobSearch) {
JobSearchExcelDto dto = new JobSearchExcelDto();
dto.setId(jobSearch.getId());
dto.setCompanyName(jobSearch.getCompanyName());
dto.setPositionName(jobSearch.getPositionName());
dto.setHrName(jobSearch.getHrName());
dto.setHrPhone(jobSearch.getHrPhone());
dto.setMajorRequirement(jobSearch.getMajorRequirement());
dto.setParticipantCount(jobSearch.getParticipantCount());
dto.setMoney(jobSearch.getMoney());
dto.setArea(jobSearch.getArea());
dto.setApplicationLink(jobSearch.getApplicationLink());
dto.setAdditionalRequirements(jobSearch.getAdditionalRequirements());
dto.setCompanyDescription(jobSearch.getCompanyDescription());
return dto;
}

前端请求:

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
const handleExport = async () => {
try {
// 发送导出请求
const response = await fetch("http://localhost:5173/api/user-info/exportExcel", {
method: "GET",
});

if (response.ok) {
// 将响应转换为 Blob 对象
const blob = await response.blob();

// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;

// 设置文件名,确保与后端导出一致
link.setAttribute("download", "用户信息.xlsx");
document.body.appendChild(link);

// 自动触发下载
link.click();

// 清理临时链接
document.body.removeChild(link);
window.URL.revokeObjectURL(url);

ElMessage.success("文件导出成功!");
} else {
ElMessage.error("文件导出失败,请重试!");
}
} catch (error) {
console.error("导出过程中出现错误:", error);
ElMessage.error("导出过程中出现错误!");
}
};

esayexcel标红错误部分数据的单元格

注意别忘了ExcelDto里加上errorMessages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class ActivityTargetAudienceExcelDto {
@ExcelProperty("序号")
private Integer id;

@ExcelProperty("年级")
private String audienceLabel;

@ExcelProperty("班级")
private String audienceValue;

@ExcelProperty("专业名称")
private String major;

@ExcelIgnore
private Map<Integer, String> errorMessages = new HashMap<>();
}

整体步骤

  1. 数据读取与校验
    • 通过 EasyExcel 读取用户上传的 Excel 文件。
    • 使用自定义监听器(JobSearchExcelImportListener)对每一行数据进行校验,记录错误信息。
  2. 标注错误单元格
    • 如果检测到错误,通过自定义的 ErrorCellStyleHandler 在输出的 Excel 中将错误单元格标红,并在最后一列记录对应的错误信息。
  3. 导出错误文件
    • 如果存在错误数据,将处理后的 Excel 数据(包含标注)作为错误文件导出。
    • 如果没有错误,则将数据保存到数据库。

ErrorCellStyleHandler

作用

  • 对包含错误数据的单元格进行样式修改(标红)。
  • 在最后一列追加错误描述。

关键逻辑

  • afterCellDispose
    • 检测当前单元格是否包含错误信息。
    • 如果有错误,则设置背景色为红色。
  • afterRowDispose
    • 如果是标题行,在最后一列添加“错误信息”标题。
    • 如果是数据行,在最后一列写入当前行的错误描述。

注意事项

  • 样式设置时,确保未覆盖已有样式。
  • 行索引与列索引从 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
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
public class ErrorCellStyleHandler implements CellWriteHandler, RowWriteHandler {

private final List<Map<Integer, String>> errorDataList;

public ErrorCellStyleHandler(List<Map<Integer, String>> errorDataList) {
this.errorDataList = errorDataList;
System.out.println("ErrorCellStyleHandler initialized"+errorDataList.size());
}

@Override
public void afterCellDispose(CellWriteHandlerContext context) {
// 检查是否是数据行(非表头)
if (Boolean.TRUE.equals(context.getHead())) {
return;
}

// 获取当前行索引和列索引
int rowIndex = context.getRowIndex();
int columnIndex = context.getColumnIndex();

// 检查当前单元格是否存在错误
if (rowIndex > 0 && rowIndex - 1 < errorDataList.size()) { // rowIndex 是从 1 开始的
Map<Integer, String> errorMap = errorDataList.get(rowIndex - 1);

if (errorMap.containsKey(columnIndex)) {
// 获取或创建样式
WriteCellData<?> cellData = context.getFirstCellData();
WriteCellStyle writeCellStyle = cellData.getOrCreateStyle();
writeCellStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND);
writeCellStyle.setFillForegroundColor(IndexedColors.RED.getIndex());

// 输出日志,便于调试
System.out.println("标记错误: 行 " + (rowIndex + 1) + ", 列 " + (columnIndex + 1) + ", 错误信息: " + errorMap.get(columnIndex));
}
}
}

@Override
public void afterRowDispose(RowWriteHandlerContext context) {
// 如果是头部行,添加“错误信息”标题
if (BooleanUtils.isTrue(context.getHead())) {
int lastColumnIndex = context.getRow().getLastCellNum();
Cell cell = context.getRow().createCell(lastColumnIndex, CellType.STRING);
cell.setCellValue("错误信息");

Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle headerStyle = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
headerStyle.setFont(font);
cell.setCellStyle(headerStyle);
return;
}

// 如果不是头部,处理错误信息列
if (context.getRelativeRowIndex() != null) {
int rowIndex = context.getRelativeRowIndex();

if (rowIndex < errorDataList.size()) {
Map<Integer, String> errorMap = errorDataList.get(rowIndex);

if (errorMap != null && !errorMap.isEmpty()) {
int lastColumnIndex = context.getRow().getLastCellNum();
String errorMessage = String.join("; ", errorMap.values());
Cell cell = context.getRow().createCell(lastColumnIndex, CellType.STRING);
cell.setCellValue(errorMessage);

// 设置样式(如红色字体)
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
CellStyle errorStyle = workbook.createCellStyle();
Font font = workbook.createFont();
font.setColor(IndexedColors.RED.getIndex());
errorStyle.setFont(font);
cell.setCellStyle(errorStyle);
}
}
}
}
}

JobSearchExcellmportListener

作用

  • 负责监听 Excel 的数据解析过程。
  • 对每一行的关键字段(如招聘企业、岗位、薪资等)进行校验,记录错误信息。

关键逻辑

  • 利用 Map<Integer, String> 存储每一行错误信息,其中键为列索引,值为错误描述。
  • errorDataList 保存所有行的错误信息,用于后续处理。

注意事项

  • 检查逻辑需要与业务需求保持一致,例如字段是否为必填、格式是否正确等。
  • 注意之前确保每行都有一个 Map部分的代码有误,现在是修正版本,否则只是储存了有错误的数据,实际上每行都要存入,即使该行没错误
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
public class JobSearchExcelImportListener extends AnalysisEventListener<JobSearchExcelDto> {

private final List<JobSearchExcelDto> jobList;
private final List<Map<Integer, String>> errorDataList;

public JobSearchExcelImportListener(List<JobSearchExcelDto> jobList, List<Map<Integer, String>> errorDataList) {
this.jobList = jobList;
this.errorDataList = errorDataList;
}

@Override
public void invoke(JobSearchExcelDto dto, AnalysisContext context) {
Map<Integer, String> errors = new HashMap<>();

// 校验第 3 列: 招聘企业
if (dto.getCompanyName() == null || dto.getCompanyName().isEmpty()) {
errors.put(2, "招聘企业不能为空");
}

// 校验第 4 列: 招聘岗位
if (dto.getPositionName() == null || dto.getPositionName().isEmpty()) {
errors.put(3, "招聘岗位不能为空");
}

// 校验第 7 列: 所需专业
if (dto.getMajorRequirement() == null || dto.getMajorRequirement().isEmpty()) {
errors.put(6, "所需专业不能为空");
} else if (!Pattern.matches("^[\\w/\\u4e00-\\u9fa5]+$", dto.getMajorRequirement())) {
errors.put(6, "所需专业必须使用“/”分割");
}

// 校验第 8 列: 招聘人数
if (dto.getParticipantCount() == null) {
errors.put(7, "招聘人数不能为空");
}

// 校验第 9 列: 薪资待遇
if (dto.getMoney() == null || dto.getMoney().isEmpty()) {
errors.put(8, "薪资待遇不能为空");
} else {
List<String> validSalaries = Arrays.asList("2000-5000", "5000-8000", "8000-15000", "15000以上","面议");
if (!validSalaries.contains(dto.getMoney())) {
errors.put(8, "薪资待遇格式错误,仅支持以下格式: 2000-5000, 5000-8000, 8000-15000, 15000以上, 面议");
}
}

// 校验第 10 列: 地区
if (dto.getArea() == null || dto.getArea().isEmpty()) {
errors.put(9, "地区不能为空");
} else if (!Pattern.matches("^[\\w,\\u4e00-\\u9fa5]+$", dto.getArea())) {
errors.put(9, "地区必须使用英文逗号分割");
}

// 保存错误信息到 dto
dto.setErrorMessages(errors);

// 将 dto 和错误信息添加到列表
jobList.add(dto);
errorDataList.add(errors.isEmpty() ? new HashMap<>() : errors); // 确保每行都有一个 Map
}

@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 全部解析完成后的处理
}
}

JobSearchController

作用

  • 读取用户上传的文件,并根据错误情况决定后续操作:
    1. 如果没有错误,直接保存数据。
    2. 如果有错误,生成包含错误标注的文件并返回给用户。

关键逻辑

  • 判断是否存在错误:jobList.stream().anyMatch(...)
  • 使用 EasyExcel 写入时,注册 ErrorCellStyleHandler 以实现错误标注。

注意事项

  • 返回 Excel 文件时需要正确设置 Content-TypeContent-Disposition
  • 处理异常时需要保证响应的输出流关闭。
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
@PostMapping("/importExcel")
public void importJobSearchExcel(@RequestParam("file") MultipartFile file, HttpServletResponse response) {
try {
// 存储读取的数据和错误信息
List<JobSearchExcelDto> jobList = new ArrayList<>();
List<Map<Integer, String>> errorDataList = new ArrayList<>();

// 创建 ExcelImportListener
JobSearchExcelImportListener listener = new JobSearchExcelImportListener(jobList, errorDataList);

// 使用 EasyExcel 读取 Excel 数据,使用自定义监听器
EasyExcel.read(file.getInputStream(), JobSearchExcelDto.class, listener)
.sheet()
.doReadSync(); // 使用同步读取方式,确保读取所有行

// 如果没有数据,则返回提示
if (jobList.isEmpty()) {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write("{\"message\":\"导入数据为空\"}");
return;
}

// 检查是否存在错误
boolean hasErrors = jobList.stream()
.anyMatch(dto -> dto.getErrorMessages() != null && !dto.getErrorMessages().isEmpty());

if (hasErrors) {
// 如果有错误,生成错误文件并返回
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-Disposition", "attachment;filename=error_data.xlsx");

// 调用 EasyExcel 写入错误数据
EasyExcel.write(response.getOutputStream(), JobSearchExcelDto.class)
.registerWriteHandler(new ErrorCellStyleHandler(errorDataList))
.sheet("错误数据")
.doWrite(jobList);
return;
}

// 使用 mapToJobSearch 将 DTO 转换为实体列表
List<JobSearch> jobEntities = jobList.stream()
.map(this::mapToJobSearch) // 调用 mapToJobSearch 方法
.collect(Collectors.toList());

// 批量保存或更新
if (!jobEntities.isEmpty()) {
jobSearchService.saveOrUpdateBatch(jobEntities);
}

// 返回成功信息
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write("{\"message\":\"导入成功\"}");
} catch (Exception e) {
e.printStackTrace();
try {
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write("{\"message\":\"导入失败: " + e.getMessage() + "\"}");
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}

CustomlntegerConverter

自定义类型转换:CustomIntegerConverter

  • 作用:
    • 解决 Excel 数据中整数字段可能为空、格式错误的问题。
    • 在读取或写入过程中,将错误数据转换为默认值(如 0),但保留错误信息。
  • 关键逻辑:
    • **convertToJavaData**:解析单元格数据,确保其为合法的整数格式,否则返回默认值。
    • **convertToExcelData**:在写入 Excel 时,优先输出原始错误数据。
  • 注意事项:
    • 注意字符串转换为数字时可能抛出的 NumberFormatException
    • 确保 errorData 在多线程情况下不会被共享导致数据错误。
    • 原始错误信息保留这块通过解析单元格数据时存入,在写入 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
31
32
33
34
35
36
37
38
public class CustomIntegerConverter implements Converter<Integer> {

String errorData = "";

@Override
public Integer convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
String cellValue = cellData.getStringValue();

// 如果是 null 或者空字符串,尝试读取为数字
if (cellValue == null || cellValue.trim().isEmpty()) {
// 尝试使用数字格式获取值
if (cellData.getNumberValue() != null) {
return cellData.getNumberValue().intValue();
}
return 0; // 错误格式时返回 0
}

try {
// 尝试将表格中的字符串转换为 Integer
return Integer.parseInt(cellValue);
} catch (NumberFormatException e) {
// 如果字符串不能转换为整数,返回 0
errorData = cellValue;
return 0; // 错误格式时返回 0
}
}

@Override
public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
System.out.println("convertToExcelData-errorData: " + errorData);
// 如果值为 null 或 0,则写入空字符串,避免写入错误数据
if (value == null || value == 0) {
return new WriteCellData<>(errorData); // 返回空字符串
}
// 正常转换 Integer 为字符串
return new WriteCellData<>(String.valueOf(value));
}
}

学生信息表备份:

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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
<template>
<div class="h-full">

<div class="flex justify-center items-center px-8">
<div class="h-full">
<p class="md:text-4xl font-extrabold text-2xl" v-if="userInfo.user?.userType == 'student'">个人信息登记详情</p>
<p class="md:text-4xl font-extrabold text-2xl text-center" v-if="userInfo.user?.userType == 'teacher'">{{ userDetail.name }}学生信息表</p>
</div>
</div>
<el-scrollbar height="90%">
<div class="main flex flex-col justify-center gap-10 p-10">
<!-- 第一行 -->
<div class="flex flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">学生照片:</p>
<img :src="userDetail.imageUrl" class="avatar" />
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.name }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">性别:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.gender }}</p>
</div>
</div>

<!-- 第二行 -->
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.phone }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">学号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.studentId }}</p>
</div>
<div class="flex flex-1 justify-start items-center mt-4 md:mt-0">
<p class="text-xl font-bold whitespace-nowrap">身份证号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.idCard }}</p>
</div>
</div>

<!-- 第三行 -->
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">年级:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.grade }}</p>
</div>
<div class="flex flex-1 justify-start items-center mt-4 md:mt-0">
<p class="text-xl font-bold whitespace-nowrap">专业:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.major }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">班级:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.className }}</p>
</div>
</div>

<!-- 出生日期相关 -->
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">出生日期:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.birthDate }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">入学日期:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.admissionDate }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">预计毕业时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.expectedGraduation }}</p>
</div>
</div>

<!-- 其他信息 -->
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">籍贯:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.nativePlace }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">生源地:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.sourcePlace }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">民族:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.ethnicity }}</p>
</div>
</div>


<!-- 第四行 -->
<div class="flex flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">班级职务:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.classRole }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">专业方向:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.specialization }}</p>
</div>
</div>

<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">户口所在地:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.residence }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家庭住址:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.homeAddress }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">辅导员:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.counselor }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">辅导员手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.counselorPhone }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">班主任:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.classTeacher }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">班主任手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.classTeacherPhone }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">毕设导师:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.graduationTutor }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">毕设导师手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.graduationTutorPhone }}</p>
</div>
</div>

<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">寝室号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.dormitoryNumber }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">红旗网络:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.networkStatus }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">寝室成员名单:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.dormitoryMembers }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">政治面貌:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.politicalStatus }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">入党进度:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.partyProgress }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">入党培训进度:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.partyTrainingProgress }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">所在支部:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.branchName }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">入党申请时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.applicationDate }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">入党积极分子时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.activistDate }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">发展对象时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.developmentDate }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">预备党员时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.probationaryDate }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">发展转正时间:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.fullMemberDate }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">党建工时:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.partyHours }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">党支部书记姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.branchSecretary }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">党支部副书记姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.branchDeputySecretary }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">电子邮箱:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.email }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">QQ号码:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.qqNumber }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">微信号码:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.wechatId }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">抖音账号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.douyinId }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<!-- 家长1信息 -->
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长1姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent1Name }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长1手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent1Phone }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长1工作单位:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent1Company }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长1职业:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent1Job }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<!-- 家长2信息 -->
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长2姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent2Name }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长2手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent2Phone }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长2工作单位:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent2Company }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">家长2职业:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.parent2Job }}</p>
</div>
</div>
<div class="md:flex md:flex-1 justify-between items-center gap-10">
<!-- 紧急联系人信息 -->
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">紧急联系人姓名:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.emergencyContactName }}</p>
</div>
<div class="flex flex-1 justify-start items-center">
<p class="text-xl font-bold whitespace-nowrap">紧急联系人手机号:</p>
<p class="text-xl font-bold whitespace-nowrap">{{ userDetail.emergencyContactPhone }}</p>
</div>
</div>
</div>
</el-scrollbar>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Delete, Download, Plus, ZoomIn } from '@element-plus/icons-vue';
import router from '../router/index';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { pcaTextArr } from "element-china-area-data";
import type { UploadProps } from 'element-plus'

// 引入接口方法
import { addUserInfo, getUserInfoById, editUserInfo } from '../api/userInfo';
import type { UploadFile } from 'element-plus';
import { userInfoStore } from "../stores/UserInfoStore";
const userInfo = userInfoStore();

// 是否为修改模式
const route = useRoute();
const isEdit = ref(false);
const loading = ref(false);

// 定义表单字段
const id = ref('');
const name = ref('');
const gender = ref<'男' | '女'>('男');
const className = ref('');
const studentId = ref('');
const contactNumber = ref('');
const classTeacher = ref('');
const graduationTutor = ref('');
const futurePlan = ref('');
const salary = ref('');
const companyNature = ref('');
const workLocation = ref([]);
const employmentStatus = ref('实习');
const companyName = ref('');

const userDetail = ref<any>({
id: '', // 用户ID
name: '', // 姓名
gender: '男', // 性别
studentId: '', // 学号
idCard: '', // 身份证号
grade: '', // 年级
major: '', // 专业
className: '', // 班级
classRole: '', // 班级职务
specialization: '', // 专业方向
birthDate: '', // 出生日期
admissionDate: '', // 入学日期
expectedGraduation: '', // 预计毕业时间
nativePlace: '', // 籍贯
sourcePlace: '', // 生源地
ethnicity: '', // 民族
residence: '', // 户口所在地
homeAddress: '', // 家庭住址
counselor: '', // 辅导员姓名
counselorPhone: '', // 辅导员手机号
classTeacher: '', // 班主任姓名
classTeacherPhone: '', // 班主任手机号
graduationTutor: '', // 毕设导师姓名
graduationTutorPhone: '', // 毕设导师手机号
dormitoryNumber: '', // 寝室号
networkStatus: '', // 红旗网络
dormitoryMembers: '', // 寝室成员名单
politicalStatus: '', // 政治面貌
partyProgress: '', // 入党进度
partyTrainingProgress: '', // 入党培训进度
branchName: '', // 所在支部
applicationDate: '', // 入党申请时间
activistDate: '', // 入党积极分子时间
developmentDate: '', // 发展对象时间
probationaryDate: '', // 预备党员时间
fullMemberDate: '', // 发展转正时间
partyHours: '', // 党建工时
branchSecretary: '', // 党支部书记姓名
branchDeputySecretary: '', // 党支部副书记姓名
email: '',
qqNumber: '',
wechatId: '',
douyinId: '',
parent1Name: '',
parent1Phone: '',
parent1Company: '',
parent1Job: '',
parent2Name: '',
parent2Phone: '',
parent2Company: '',
parent2Job: '',
emergencyContactName: '',
emergencyContactPhone: '',
});

// 定义上传文件列表
const fileList = ref<UploadFile[]>([]);

// 初始化
onMounted(async () => {
await getUserInfoById(route.params.id as string).then((res) => {
const data = res.data;
console.log("userDetail.value", userDetail.value);
// 如果不为空则填充表单字段,如果为空则重置表单字段
if (data) {
populateFormFields(data);
isEdit.value = true;
loading.value = true;
} else {
isEdit.value = false;
resetFormFields();
}

loading.value = false;
}).catch((err) => {
console.log(err);
});
});

// 重置表单字段
const resetFormFields = () => {
name.value = '';
gender.value = '男';
className.value = '';
studentId.value = '';
contactNumber.value = '';
classTeacher.value = '';
graduationTutor.value = '';
futurePlan.value = '';
salary.value = '';
companyNature.value = '';
workLocation.value = [];
employmentStatus.value = '实习';
companyName.value = '';
fileList.value = [];
};

const populateFormFields = (data: any) => {
Object.assign(userDetail.value, data);
};
</script>

<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

// 下面为el-select部分
@mixin select_radius {
border-radius: 12px;
}


// 控制el-select的长度以及圆角
:deep(.el-select__wrapper) {
height: 50px;
@include select_radius;
}

// 控制el-select中文字的样式
:deep(.el-select__placeholder) {

font-size: 16px;
font-weight: bold;
}

// 控制点击后的边框颜色
:deep(.el-select__wrapper.is-focused) {
box-shadow: 0 0 0 1px var(--accent-100);
}

// 下面为下拉框部分
// 下面用于控制整体的下拉框圆角
:deep(.el-select__popper.el-popper) {
@include select_radius;
}


//下拉框的文本未选中的样式
// .el-select-dropdown__item {

// }
//下拉框的文本颜色选中之后的样式
.el-select-dropdown__item.is-selected {
color: var(--accent-200);
}

.el-input {
height: 50px;

border-radius: 12px;
border: 0.5px solid var(--text-200);
border: 0;
background-color: var(--bg-200);

font-size: 16px;
font-weight: bold;


:deep(.el-input__wrapper) {
border-radius: 12px;
}

:deep(.is-focus) {
box-shadow: 0 0 0 1px var(--accent-100)
}
}

// 下面是日期选择组件的自定义样式
:deep(.el-date-editor.el-input, .el-date-editor.el-input__wrapper) {
width: 100%;
height: 50px;
border-radius: 12px;
}

.el-date-editor-style {
--el-input-border-radius: 12px;
}



// 下面是数字选择组件的自定义样式
.el-input-number {
width: 100%;
height: 50px;
}

:deep(.el-input) {
box-shadow: 0 0 0 1px var(--accent-200, var(--accent-100)) inset;
border-radius: 12px;
}

:deep(.el-input-number__increase:hover~.el-input:not(.is-disabled) .el-input__wrapper) {
box-shadow: 0 0 0 1px var(--accent-200, var(--accent-100)) inset;
}

:deep(.el-input-number__decrease:hover~.el-input:not(.is-disabled) .el-input__wrapper) {
box-shadow: 0 0 0 1px var(--accent-200, var(--accent-100)) inset;
}

:deep(.el-input-number__increase:hover) {
color: var(--accent-200);
}

:deep(.el-input-number__decrease:hover) {
color: var(--accent-200);
}

:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--accent-200, var(--accent-100)) inset !important;
}

// 下面是textarea组件的自定义样式
.el-textarea {
font-size: 16px;
font-weight: bold;

--el-input-focus-border-color: var(--accent-200);
}


// 下面是地区选择组件的自定义样式
:deep(.el-cascader--large) {
width: 100%;
height: 50px;
}

:deep(.el-cascader .el-input) {
height: 50px;
}

:deep(.el-input--large .el-input__wrapper) {
border-radius: 12px;
}

:deep(.el-cascader .el-input .el-input__inner) {
font-size: 16px;
font-weight: bold;
}

.avatar-uploader .avatar {
width: 80px;
height: 112px;
/* 80px * (35/25) */
display: block;
}
</style>

<style>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 80px;
height: 112px;
/* 80px * (35/25) */
text-align: center;
}
</style>

Vue3 打印实现

Vue3 中使用 html2canvas + jspdf + print-js 实现打印的详细方案

这套方案适合复杂页面的打印需求,尤其是在需要高精度 PDF 输出、支持跨域图片加载以及良好页面布局控制的情况下。

核心功能简介

​ 1. html2canvas:

​ • 将指定的 DOM 元素渲染为 canvas 图像。

​ • 支持动态内容(如样式、图片)。

​ • 配合 useCORS 参数解决图片跨域问题。

​ 2. jspdf:

​ • 将 canvas 转换为 PDF 文件。

​ • 支持多页 PDF 输出。

​ 3. print-js:

​ • 支持直接打印 HTML 或 PDF 文件。

​ • 提供弹窗式的打印预览,体验更友好。

实现步骤

1. 安装依赖

运行以下命令安装相关库:

1
npm install html2canvas jspdf print-js

2. 代码实现

(1) 基础模板代码

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
<template>
<div>
<!-- 打印区域 -->
<div id="printArea" class="p-5 bg-white">
<h1 class="text-xl font-bold">学生信息表</h1>
<div class="flex items-center gap-4">
<div>
<p>姓名: {{ userDetail.name }}</p>
<p>学号: {{ userDetail.studentId }}</p>
<p>专业: {{ userDetail.major }}</p>
​ </div>
​ <img :src="userDetail.imageUrl" class="w-32 h-32 border" />
</div>
</div>

<!-- 按钮操作 -->
<div class="mt-5">
<button @click="exportToPDF" class="px-4 py-2 bg-blue-500 text-white rounded">导出 PDF</button>
<button @click="printPDF" class="px-4 py-2 bg-green-500 text-white rounded">打印</button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import printJS from 'print-js';


// 导出为 PDF 并打印
const handleExportToPDF = async () => {
const element = document.getElementById('printArea');
if (!element) {
console.error('打印区域未找到');
return;
}

try {
// 将 DOM 转为图片
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true, // 启用跨域支持
});
const imgData = canvas.toDataURL('image/jpeg', 1.0);

// 创建 PDF
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (canvas.height * pdfWidth) / canvas.width;
pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight);

// 导出 PDF 文件 Blob URL
const pdfBlob = pdf.output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);

// 使用 print-js 打印 PDF
printJS({ printable: pdfUrl, type: 'pdf', showModal: true });
} catch (error) {
console.error('生成 PDF 失败:', error);
}
};
</script>

常见问题与解决方法

1. 图片跨域问题

​ • 原因:html2canvas 对跨域资源有限制。

​ • 解决方案:

​ 1. 使用 useCORS: true 参数。

​ 2. 确保图片服务器启用了 CORS 并设置 Access-Control-Allow-Origin: *。

2. 样式丢失

​ • 原因:部分样式未被内联,或者浏览器限制外部样式加载。

​ • 解决方案:

​ • 不要直接使用printjs打印,而是转成pdf再打印

最终建议

​ • 简单需求:直接使用 print-js 打印。

​ • 复杂需求:结合 html2canvas + jspdf 输出 PDF,再通过 print-js 打印,适配性更强。

​ • 样式控制:确保打印专用样式定义清晰,避免内容超出页面或影响用户体验。