From 0dafb47e7dd152a7cb871580ebb0e72762568f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=BA=86=E6=B3=B0?= <12369755+htcloud1@user.noreply.gitee.com> Date: Sat, 22 Aug 2026 16:35:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(attendee):=20Excel=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=20(=E5=90=8E=E7=AB=AF=20/importData=20+=20/i?= =?UTF-8?q?mportTemplate=20+=205=E9=82=80=E8=AF=B7=E5=B7=AE=E9=9B=86?= =?UTF-8?q?=E6=8E=A8=E9=80=81,=20=E5=89=8D=E7=AB=AF=20dialog-upload=20?= =?UTF-8?q?=E5=A5=97=E7=94=A8=20dialog-import=20SKILL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BizMeetingAttendeeController.java | 58 +++++++++ .../domain/vo/BizMeetingAttendeeImportVo.java | 113 ++++++++++++++++++ .../service/IBizMeetingAttendeeService.java | 15 +++ .../impl/BizMeetingAttendeeServiceImpl.java | 71 ++++++++++- ry-vue3/src/views/manager/MeetingDetail.vue | 96 +++++++++++++++ 5 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java index 1e0392c..33c84c3 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingAttendeeController.java @@ -1,19 +1,27 @@ package com.ruoyi.business.controller; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeetingAttendee; +import com.ruoyi.business.domain.dto.ImportResult; +import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingService; +import javax.servlet.http.HttpServletResponse; + /** * 会议参会人 Controller (劳务协议 / 手写签名 + 管理端 CRUD) * @@ -119,6 +127,56 @@ public class BizMeetingAttendeeController extends BaseController { return toAjax(attendeeService.updateHandsign(entity)); } + /** + * 批量导入参会人 — Excel 模板下载 (Manager/Admin 在 MeetingDetail "批量导入" 按钮触发). + * + *

复用 {@link BizMeetingAttendeeImportVo} 的 @Excel 注解自动生成表头. + */ + @GetMapping("/importTemplate") + public void importTemplate(HttpServletResponse response) { + ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); + util.importTemplateExcel(response, "参会人导入"); + } + + /** + * 批量导入参会人 — 上传 Excel + 解析入库 + #5 会议邀请差集推送. + * + *

三步: + *

    + *
  1. 查"导入前"该会议已存在的参会人 userIds (Set)
  2. + *
  3. 调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList
  4. + *
  5. 查"导入后"该会议 userIds, 与"前"做差集 → 仅给"新加入"的 userId 推 #5 会议邀请
  6. + *
+ * + *

返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染. + * + * @param meetingId 会议 id (query 传, 决定导入到哪个会议) + */ + @Log(title = "参会人批量导入", businessType = BusinessType.IMPORT) + @PostMapping("/importData") + public AjaxResult importData(@RequestParam("file") MultipartFile file, + @RequestParam("meetingId") Long meetingId) throws Exception { + // 1. 导入前快照 + Set preUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); + + // 2. 解析 + 入库 + ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername()); + + // 3. 差集 → 仅对"新加入" userId 推 #5 邀请 + Set postUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); + postUserIds.removeAll(preUserIds); + if (!postUserIds.isEmpty()) { + BizMeeting m = bizMeetingService.getById(meetingId); + String meetingName = m != null ? m.getMeetingName() : null; + java.util.Date startTime = m != null ? m.getStartTime() : null; + for (Long uid : postUserIds) { + bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime); + } + } + + return success(result); + } + /** * 更新劳务协议 URL (OSS 上传后调本接口). * diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java new file mode 100644 index 0000000..97594e0 --- /dev/null +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/vo/BizMeetingAttendeeImportVo.java @@ -0,0 +1,113 @@ +package com.ruoyi.business.domain.vo; + +import java.math.BigDecimal; +import com.ruoyi.common.annotation.Excel; + +/** + * 会议参会人 Excel 导入 VO + * + *

仅用于 Excel 批量导入 + 模板下载. 字段顺序与 Excel 列一一对应, 调 sort 时同步改模板下载. + * + *

关键约束: + *

+ * + * @author guoju + */ +public class BizMeetingAttendeeImportVo { + + /** 手机号 (必填, 按此查/建 sys_user) */ + @Excel(name = "手机号", sort = 1) + private String phone; + + /** 姓名 */ + @Excel(name = "姓名", sort = 2) + private String name; + + /** 工作单位 */ + @Excel(name = "工作单位", sort = 3) + private String workUnit; + + /** 科室 */ + @Excel(name = "科室", sort = 4) + private String department; + + /** 职称 */ + @Excel(name = "职称", sort = 5) + private String title; + + /** 身份证号 */ + @Excel(name = "身份证号", sort = 6) + private String idCard; + + /** 银行 */ + @Excel(name = "银行", sort = 7) + private String bankName; + + /** 银行卡号码 */ + @Excel(name = "银行卡号码", sort = 8) + private String bankCard; + + /** 开户行 */ + @Excel(name = "开户行", sort = 9) + private String bankBranch; + + /** 角色 (原"劳务形式": 授课/主持/评审...) */ + @Excel(name = "角色", sort = 10) + private String laborForm; + + /** 应发金额 (decimal, 元) */ + @Excel(name = "应发金额", sort = 11) + private BigDecimal feePreTax; + + /** 个税税金 (decimal, 元) */ + @Excel(name = "个税税金", sort = 12) + private BigDecimal tax; + + /** 增值税及附加成本 (decimal, 元) */ + @Excel(name = "增值税及附加成本", sort = 13) + private BigDecimal vatAndSurcharge; + + /** 实发金额 (decimal, 元) */ + @Excel(name = "实发金额", sort = 14) + private BigDecimal fee; + + /** 摘要 */ + @Excel(name = "摘要", sort = 15) + private String summary; + + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getWorkUnit() { return workUnit; } + public void setWorkUnit(String workUnit) { this.workUnit = workUnit; } + public String getDepartment() { return department; } + public void setDepartment(String department) { this.department = department; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getIdCard() { return idCard; } + public void setIdCard(String idCard) { this.idCard = idCard; } + public String getBankName() { return bankName; } + public void setBankName(String bankName) { this.bankName = bankName; } + public String getBankCard() { return bankCard; } + public void setBankCard(String bankCard) { this.bankCard = bankCard; } + public String getBankBranch() { return bankBranch; } + public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; } + public String getLaborForm() { return laborForm; } + public void setLaborForm(String laborForm) { this.laborForm = laborForm; } + public BigDecimal getFeePreTax() { return feePreTax; } + public void setFeePreTax(BigDecimal feePreTax) { this.feePreTax = feePreTax; } + public BigDecimal getTax() { return tax; } + public void setTax(BigDecimal tax) { this.tax = tax; } + public BigDecimal getVatAndSurcharge() { return vatAndSurcharge; } + public void setVatAndSurcharge(BigDecimal vatAndSurcharge) { this.vatAndSurcharge = vatAndSurcharge; } + public BigDecimal getFee() { return fee; } + public void setFee(BigDecimal fee) { this.fee = fee; } + public String getSummary() { return summary; } + public void setSummary(String summary) { this.summary = summary; } +} \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java index a902438..6af9ad8 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizMeetingAttendeeService.java @@ -1,7 +1,9 @@ package com.ruoyi.business.service; import java.util.List; +import org.springframework.web.multipart.MultipartFile; import com.ruoyi.business.domain.BizMeetingAttendee; +import com.ruoyi.business.domain.dto.ImportResult; public interface IBizMeetingAttendeeService { int insert(BizMeetingAttendee entity); @@ -45,4 +47,17 @@ public interface IBizMeetingAttendeeService { * 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断. */ List selectUserIdsByMeetingId(Long meetingId); + + /** + * Excel 批量导入参会人 (MeetingDetail "批量导入" 按钮). + * + *

每行处理: 解析 → 校验手机号 → 复用 {@link #insertByPhoneWithProfile} (查/建 sys_user + 写 attendee 行) → + * 单行失败不入 ngList, 其它行继续. 返回的 ImportResult 含 okNum/ngNum/ngList 给前端 ImportResultDialog. + * + *

注意: 本方法**只插数据**, 不触发 #5 会议邀请通知. 调用方 (controller) 拿到成功行后统一调 + * {@code bizNotifyService.meetingInvitation} 推通知, 避免在 service 层耦合 notify 依赖. + * + * @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] } + */ + ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index 3af3b42..ee5763f 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -6,12 +6,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import com.ruoyi.business.domain.BizMeetingAttendee; +import com.ruoyi.business.domain.dto.ImportResult; +import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.service.ISysUserService; @@ -160,4 +164,69 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService public List selectUserIdsByMeetingId(Long meetingId) { return mapper.selectUserIdsByMeetingId(meetingId); } -} \ No newline at end of file + + /** + * 批量导入参会人 (Excel → biz_meeting_attendee). + * + *

复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee" + * 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行. + * + *

注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff, + * 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请. + */ + @Override + public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception { + if (meetingId == null) throw new ServiceException("meetingId 不能为空"); + + ExcelUtil util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); + List rows = util.importExcel(file.getInputStream()); + if (rows == null || rows.isEmpty()) { + throw new ServiceException("导入数据不能为空"); + } + + ImportResult result = new ImportResult(); + for (int i = 0; i < rows.size(); i++) { + BizMeetingAttendeeImportVo vo = rows.get(i); + int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据 + try { + // 行级校验: 手机号必填 + 格式 + if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) { + result.fail(rowNo, "手机号不能为空"); + continue; + } + String phone = vo.getPhone().trim(); + if (!phone.matches("^1[3-9]\\d{9}$")) { + result.fail(rowNo, "手机号格式不正确: " + phone); + continue; + } + + // 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee) + BizMeetingAttendee body = new BizMeetingAttendee(); + body.setMeetingId(meetingId); + body.setPhone(phone); + body.setName(vo.getName()); + body.setWorkUnit(vo.getWorkUnit()); + body.setDepartment(vo.getDepartment()); + body.setTitle(vo.getTitle()); + body.setIdCard(vo.getIdCard()); + body.setBankName(vo.getBankName()); + body.setBankCard(vo.getBankCard()); + body.setBankBranch(vo.getBankBranch()); + body.setLaborForm(vo.getLaborForm()); + body.setFeePreTax(vo.getFeePreTax()); + body.setTax(vo.getTax()); + body.setVatAndSurcharge(vo.getVatAndSurcharge()); + body.setFee(vo.getFee()); + body.setSummary(vo.getSummary()); + + insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch + result.ok(); + } catch (Exception e) { + String msg = e.getMessage(); + result.fail(rowNo, msg == null ? "导入失败" : msg); + } + } + log.info("[attendee] 批量导入完成 meetingId={} ok={} ng={}", + meetingId, result.getOkNum(), result.getNgNum()); + return result; + } \ No newline at end of file diff --git a/ry-vue3/src/views/manager/MeetingDetail.vue b/ry-vue3/src/views/manager/MeetingDetail.vue index a527722..67f22de 100644 --- a/ry-vue3/src/views/manager/MeetingDetail.vue +++ b/ry-vue3/src/views/manager/MeetingDetail.vue @@ -56,6 +56,7 @@

+ 新增参会人 + 批量导入 按手机号定位; 手机号未注册将自动建 sys_user (用户名=密码=手机号)
+ + + +
将文件拖到此处,或点击上传
+
+
+ 仅允许导入 xls、xlsx 格式文件 + 下载模板 +
+ +
+ +
返回
@@ -405,7 +435,9 @@ import { listSupporters, listExecutor } from '@/api/system' import { useUserStore } from '@/store/user' import { ElMessage } from 'element-plus' import OssFileUploader from '@/components/OssFileUploader.vue' +import ImportResultDialog from '@/components/ImportResultDialog.vue' import { ElMessageBox } from 'element-plus' +import { Upload } from '@element-plus/icons-vue' const route = useRoute() const router = useRouter() @@ -709,6 +741,70 @@ async function confirmDeleteAttendee(row) { ElMessage.error(e?.msg || e?.message || '删除失败') } } + +// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) ===================== +const importOpen = ref(false) +const importing = ref(false) +const importResultOpen = ref(false) +const importResult = ref(null) +const importUploadRef = ref(null) +// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization +const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') })) +// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串 +const importAction = computed(() => `/dev-api/business/meetingAttendee/importData?meetingId=${meetingId.value}`) + +function openAttendeeImport() { + importResult.value = null + importOpen.value = true +} + +function downloadAttendeeImportTpl() { + fetch('/dev-api/business/meetingAttendee/importTemplate', { headers: importHeaders.value }) + .then(r => r.blob()) + .then(blob => { + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = '参会人批量导入模板.xlsx' + a.click() + }) + .catch(() => ElMessage.error('模板下载失败')) +} + +function submitImportFile() { + importUploadRef.value?.submit() +} + +function onImportProgress() { importing.value = true } + +function onImportSuccess(res) { + importing.value = false + const result = (res && res.data) ? res.data : res + if (!result || typeof result.okNum !== 'number') { + ElMessage.error(res?.msg || '导入失败, 返回数据异常') + importResult.value = null + return + } + importResult.value = result + importOpen.value = false + importResultOpen.value = true + // 刷新列表 (新参会人会出现) + loadAttendees() +} + +function onImportError(err) { + importing.value = false + let msg = '上传失败, 请重试' + if (err && err.response != null) { + try { + const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response + msg = body.msg || msg + } catch { /* JSON 解析失败用兜底 */ } + } else if (err && err.message) { + msg = err.message + } + ElMessage.error(msg) + importResult.value = null +} async function loadStaff() { try { const [sp, ex] = await Promise.all([