“出国申请审批流程已实现10.29”

This commit is contained in:
weizhong committed 2025-10-29 20:02:33 +08:00
1 parent 32b9822146
commit 4277e2fca8
15 files changed
+1040 -321

No files matched your search

@@ -330,7 +330,7 @@ public class RsOverseasApplicationController extends BaseController {
return toAjax(applicationService.approveApplication(
request.getApplicationId(), approverId, approverName,
request.getApprovalStatus(), request.getApprovalOpinion(),
request.getTaskId()));
request.getApprovalSignature(), request.getTaskId()));
}
/**
@@ -376,6 +376,7 @@ public class RsOverseasApplicationController extends BaseController {
private Long applicationId;
private String approvalStatus;
private String approvalOpinion;
private String approvalSignature; // 审批签字(base64图片)
private String taskId; // 🔴 新增:工作流任务ID,支持标准工作流模式
public Long getApplicationId() {
@@ -402,6 +403,14 @@ public class RsOverseasApplicationController extends BaseController {
this.approvalOpinion = approvalOpinion;
}
public String getApprovalSignature() {
return approvalSignature;
}
public void setApprovalSignature(String approvalSignature) {
this.approvalSignature = approvalSignature;
}
public String getTaskId() {
return taskId;
}
@@ -6,20 +6,14 @@ import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.dromara.rs.domain.vo.RsOverseasApprovalMonitorVo;
import org.dromara.rs.domain.vo.RsOverseasApprovalVo;
import org.dromara.rs.domain.query.RsOverseasApprovalMonitorQuery;
import org.dromara.rs.service.IRsOverseasApprovalMonitorService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 出国申请审批监控Controller
* <p>
@@ -40,53 +34,40 @@ public class RsOverseasApprovalMonitorController extends BaseController {
private final IRsOverseasApprovalMonitorService monitorService;
/**
* 查询审批中的申请列表(实时监控)
* 查询待审批的申请列表(出国申请进度查询)
* <p>
* 显示所有状态为"待审核"的申请,包含:
* - 申请人信息
* - 出国目的地和时间
* - 当前审批环节(部门主管/分管领导/校领导)
* - 当前审批人姓名
* - 审批开始时间
* 🔴 功能说明:查询所有待审批申请(不限制申请人)
* - 拥有权限的用户可以查看所有待审批申请
* - 显示当前审批环节和审批人
* - 可以查看审批历史记录
*
* @param query 查询条件
* @return 审批中的申请列表
* @return 所有待审批申请列表
*/
// 🔴 标准化改造:删除自定义待审批查询接口
// 原因:出国申请工作流应使用标准工作流模式
// 用户应通过工作流API pageByTaskWait() 获取待办任务
// 而不是调用自定义的业务API
//
// 标准工作流模式:
// 1. 用户访问标准待办页面:/workflow/task/taskWaiting.vue
// 2. 调用工作流API:pageByTaskWait() 获取待办任务
// 3. 点击"办理"跳转:/rs/overseas/application/edit?taskId=xxx
// 4. 在业务表单页面进行审批操作
@SaCheckPermission("rs:overseas:list")
@GetMapping("/pending/list")
public R<String> pendingList(RsOverseasApprovalMonitorQuery query) {
// 提示用户使用标准工作流API
return R.fail("请使用标准工作流待办页面查看审批任务。访问:工作流 > 我的任务 > 待办任务");
public TableDataInfo<RsOverseasApprovalMonitorVo> pendingList(RsOverseasApprovalMonitorQuery query) {
log.info("查询所有待审批申请列表");
// 只查询待审核状态(status = 0)
query.setStatus("0");
return monitorService.queryPendingList(query);
}
/**
* 查询已完成审批的申请列表
* <p>
* 显示所有审批流程完成的申请(已通过),包含:
* - 申请人信息
* - 出国目的地和时间
* - 最终审批结果
* - 完成时间
* - 所有审批环节的历史记录
* 🔴 功能说明:查询所有已完成审批申请(不限制申请人)
* - 拥有权限的用户可以查看所有已完成审批申请
* - 显示最终审批结果和完成时间
* - 可以查看所有审批环节的历史记录
*
* @param query 查询条件
* @return 已完成审批的申请列表
* @return 所有已完成审批的申请列表
*/
@SaCheckPermission("rs:overseas:list")
@GetMapping("/completed/list")
public TableDataInfo<RsOverseasApprovalMonitorVo> completedList(RsOverseasApprovalMonitorQuery query) {
log.info("查询已完成审批的申请列表,查询条件:{}", query);
log.info("查询所有已完成审批的申请列表,查询条件:{}", query);
// 只查询已通过状态(status = 1)
query.setStatus("1");
return monitorService.queryCompletedList(query);
@@ -128,4 +109,21 @@ public class RsOverseasApprovalMonitorController extends BaseController {
log.info("查询审批进度统计");
return R.ok(monitorService.getApprovalStatistics());
}
/**
* 下载出国申请审批表PDF
* <p>
* 生成并下载"杭州第一技师学院登记报备人员因私事出国(境)审批表"PDF文件
*
* @param applicationId 申请ID
* @param response HTTP响应对象
*/
@SaCheckPermission("rs:overseas:query")
@GetMapping("/downloadApprovalPdf/{applicationId}")
public void downloadApprovalPdf(
@PathVariable @NotNull(message = "申请ID不能为空") Long applicationId,
HttpServletResponse response) {
log.info("下载出国申请审批表PDF,申请ID:{}", applicationId);
monitorService.downloadApprovalPdf(applicationId, response);
}
}
@@ -54,7 +54,7 @@ public class RsOverseasApproval extends BaseEntity {
private String approverName;
/**
* 审批级别:1-部门主管,2-分管领导,3-校领导
* 审批级别:1-部门负责人,2-人事处,3-纪委,4-学院分管领导,5-学院党委书记
*/
@ExcelProperty(value = "审批级别")
private String approvalLevel;
@@ -71,6 +71,12 @@ public class RsOverseasApproval extends BaseEntity {
@ExcelProperty(value = "审批意见")
private String approvalOpinion;
/**
* 审批签字(base64图片数据)
*/
@ExcelIgnore
private String approvalSignature;
/**
* 审批时间
*/
@@ -118,5 +124,4 @@ public class RsOverseasApproval extends BaseEntity {
@ExcelIgnore
private Date updateTime;
}
}
@@ -72,4 +72,9 @@ public class RsOverseasApprovalBo extends BaseEntity {
* 审批意见
*/
private String approvalOpinion;
}
/**
* 审批签字(base64图片数据)
*/
private String approvalSignature;
}
@@ -67,6 +67,11 @@ public class RsOverseasApprovalVo implements Serializable {
*/
private String approvalOpinion;
/**
* 审批签字(base64图片数据)
*/
private String approvalSignature;
/**
* 审批时间
*/
@@ -74,49 +79,49 @@ public class RsOverseasApprovalVo implements Serializable {
private Date approvalTime;
// ==================== 申请信息相关字段 ====================
/**
* 申请人ID
*/
private Long applicantId;
/**
* 申请人姓名
*/
private String applicantName;
/**
* 所属部门
*/
private String department;
/**
* 出国目的
*/
private String purpose;
/**
* 出国目的文本
*/
private String purposeText;
/**
* 目的地
*/
private String destination;
/**
* 出国开始日期
*/
@JsonFormat(pattern = "yyyy-MM-dd")
private Date startDate;
/**
* 出国结束日期
*/
@JsonFormat(pattern = "yyyy-MM-dd")
private Date endDate;
/**
* 出国天数
*/
@@ -158,4 +163,4 @@ public class RsOverseasApprovalVo implements Serializable {
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
}
@@ -132,21 +132,22 @@ public interface IRsOverseasApplicationService {
* @return 是否成功
*/
Boolean approveApplication(Long applicationId, Long approverId, String approverName,
String approvalStatus, String approvalOpinion);
String approvalStatus, String approvalOpinion, String approvalSignature);
/**
* 审批出国申请(支持标准工作流模式)
*
* @param applicationId 申请ID
* @param approverId 审批人ID
* @param approverName 审批人姓名
* @param approvalStatus 审批结果:1-通过,2-拒绝
* @param approvalOpinion 审批意见
* @param taskId 工作流任务ID(可选,用于标准工作流模式)
* @param applicationId 申请ID
* @param approverId 审批人ID
* @param approverName 审批人姓名
* @param approvalStatus 审批结果:1-通过,2-拒绝
* @param approvalOpinion 审批意见
* @param approvalSignature 审批签字(base64图片)
* @param taskId 工作流任务ID(可选,用于标准工作流模式)
* @return 是否成功
*/
Boolean approveApplication(Long applicationId, Long approverId, String approverName,
String approvalStatus, String approvalOpinion, String taskId);
String approvalStatus, String approvalOpinion, String approvalSignature, String taskId);
/**
* 校验删除数据
@@ -1,5 +1,6 @@
package org.dromara.rs.service;
import jakarta.servlet.http.HttpServletResponse;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.rs.domain.vo.RsOverseasApprovalMonitorVo;
import org.dromara.rs.domain.query.RsOverseasApprovalMonitorQuery;
@@ -44,4 +45,12 @@ public interface IRsOverseasApprovalMonitorService {
* @return 统计数据
*/
Map<String, Object> getApprovalStatistics();
/**
* 下载出国申请审批表PDF
*
* @param applicationId 申请ID
* @param response HTTP响应对象
*/
void downloadApprovalPdf(Long applicationId, HttpServletResponse response);
}
@@ -558,7 +558,7 @@ public class RsOverseasApplicationServiceImpl extends ServiceImpl<RsOverseasAppl
@Override
@Transactional
public Boolean approveApplication(Long applicationId, Long approverId, String approverName,
String approvalStatus, String approvalOpinion) {
String approvalStatus, String approvalOpinion, String approvalSignature) {
RsOverseasApplication application = baseMapper.selectById(applicationId);
if (application == null) {
throw new RuntimeException("申请不存在");
@@ -584,8 +584,16 @@ public class RsOverseasApplicationServiceImpl extends ServiceImpl<RsOverseasAppl
approval.setApprovalLevel(application.getApprovalLevel());
approval.setApprovalStatus(approvalStatus);
approval.setApprovalOpinion(approvalOpinion);
approval.setApprovalSignature(approvalSignature); // 🔴 保存审批签字
approval.setApprovalTime(new Date());
// 日志记录签字数据
if (approvalSignature != null && !approvalSignature.trim().isEmpty()) {
log.info("✅ 保存审批签字数据,长度: {} 字符", approvalSignature.length());
} else {
log.info("ℹ️ 未提供审批签字");
}
// 手动设置多租户和删除标志字段
if (approval.getTenantId() == null || approval.getTenantId().isEmpty()) {
approval.setTenantId(LoginHelper.getTenantId());
@@ -652,7 +660,7 @@ public class RsOverseasApplicationServiceImpl extends ServiceImpl<RsOverseasAppl
@Override
@Transactional
public Boolean approveApplication(Long applicationId, Long approverId, String approverName,
String approvalStatus, String approvalOpinion, String taskId) {
String approvalStatus, String approvalOpinion, String approvalSignature, String taskId) {
// 🔴 标准化改造:如果提供了taskId,说明是标准工作流模式
if (taskId != null && !taskId.trim().isEmpty()) {
@@ -666,8 +674,9 @@ public class RsOverseasApplicationServiceImpl extends ServiceImpl<RsOverseasAppl
log.info("⚠️ [兼容模式] 暂时使用原有审批逻辑,等待集成工作流引擎API");
}
// 调用原有的审批方法
return approveApplication(applicationId, approverId, approverName, approvalStatus, approvalOpinion);
// 调用原有的审批方法(增加签字参数)
return approveApplication(applicationId, approverId, approverName, approvalStatus, approvalOpinion,
approvalSignature);
}
/**
@@ -1,11 +1,25 @@
package org.dromara.rs.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.itextpdf.text.Chunk;
import com.itextpdf.text.Document;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Element;
import com.itextpdf.text.Font;
import com.itextpdf.text.Image;
import com.itextpdf.text.PageSize;
import com.itextpdf.text.Paragraph;
import com.itextpdf.text.Phrase;
import com.itextpdf.text.pdf.BaseFont;
import com.itextpdf.text.pdf.PdfPCell;
import com.itextpdf.text.pdf.PdfPTable;
import com.itextpdf.text.pdf.PdfWriter;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.satoken.utils.LoginHelper;
@@ -22,10 +36,18 @@ import org.dromara.rs.service.IRsOverseasApprovalMonitorService;
import org.dromara.rs.service.IRsOverseasApprovalService;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -46,6 +68,7 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
/**
* 查询审批中的申请列表(待审核状态)
* 🔴 查询所有待审批申请(不限制申请人)
*/
@Override
public TableDataInfo<RsOverseasApprovalMonitorVo> queryPendingList(RsOverseasApprovalMonitorQuery query) {
@@ -54,6 +77,7 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
// 构建查询条件
QueryWrapper<RsOverseasApplication> wrapper = buildQueryWrapper(query);
wrapper.eq("status", "0"); // 只查询待审核状态
// 🔴 不再限制申请人,拥有权限的用户可以看到所有数据
// 分页查询
Page<RsOverseasApplication> page = applicationMapper.selectPage(
@@ -70,11 +94,13 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
resultPage.setRecords(voList);
resultPage.setTotal(page.getTotal());
log.info("✅ 查询到 {} 条待审批申请", voList.size());
return TableDataInfo.build(resultPage);
}
/**
* 查询已完成审批的申请列表(已通过状态)
* 🔴 查询所有已完成审批申请(不限制申请人)
*/
@Override
public TableDataInfo<RsOverseasApprovalMonitorVo> queryCompletedList(RsOverseasApprovalMonitorQuery query) {
@@ -83,6 +109,7 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
// 构建查询条件
QueryWrapper<RsOverseasApplication> wrapper = buildQueryWrapper(query);
wrapper.eq("status", "1"); // 只查询已通过状态
// 🔴 不再限制申请人,拥有权限的用户可以看到所有数据
// 分页查询
Page<RsOverseasApplication> page = applicationMapper.selectPage(
@@ -99,6 +126,7 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
resultPage.setRecords(voList);
resultPage.setTotal(page.getTotal());
log.info("✅ 查询到 {} 条已完成审批申请", voList.size());
return TableDataInfo.build(resultPage);
}
@@ -143,11 +171,13 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
.eq("tenant_id", tenantId));
statistics.put("pendingCount", pendingCount);
// 2. 各审批级别的待审批数量
// 2. 各审批级别的待审批数量(五级审批制度)
Map<String, Long> pendingByLevel = new HashMap<>();
pendingByLevel.put("level1", countByStatusAndLevel("0", "1", tenantId)); // 部门主管
pendingByLevel.put("level2", countByStatusAndLevel("0", "2", tenantId)); // 分管领导
pendingByLevel.put("level3", countByStatusAndLevel("0", "3", tenantId)); // 校领导
pendingByLevel.put("level1", countByStatusAndLevel("0", "1", tenantId)); // 部门负责人
pendingByLevel.put("level2", countByStatusAndLevel("0", "2", tenantId)); // 人事处
pendingByLevel.put("level3", countByStatusAndLevel("0", "3", tenantId)); // 纪委
pendingByLevel.put("level4", countByStatusAndLevel("0", "4", tenantId)); // 学院分管领导
pendingByLevel.put("level5", countByStatusAndLevel("0", "5", tenantId)); // 学院党委书记
statistics.put("pendingByLevel", pendingByLevel);
// 3. 已完成审批总数
@@ -285,9 +315,20 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
// 审批状态信息
vo.setStatus(application.getStatus());
vo.setStatusText(getStatusText(application.getStatus()));
vo.setCurrentApprover(application.getCurrentApprover());
vo.setApprovalLevel(application.getApprovalLevel());
vo.setApprovalLevelText(getApprovalLevelText(application.getApprovalLevel()));
// 根据申请状态设置当前审批人和审批级别
if ("1".equals(application.getStatus()) || "2".equals(application.getStatus())) {
// 审批已完成(通过或拒绝)
vo.setCurrentApprover("审批已完成");
vo.setApprovalLevel(application.getApprovalLevel());
vo.setApprovalLevelText("审批已完成");
} else {
// 待审核状态,显示当前审批信息
vo.setCurrentApprover(application.getCurrentApprover());
vo.setApprovalLevel(application.getApprovalLevel());
vo.setApprovalLevelText(getApprovalLevelText(application.getApprovalLevel()));
}
vo.setApplyTime(application.getApplyTime());
vo.setApprovalTime(application.getApprovalTime());
@@ -336,8 +377,8 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
.count();
vo.setApprovedLevels((int) approvedCount);
// 总审批级别数(假设最多3级:部门主管、分管领导、校领导)
vo.setTotalLevels(3);
// 总审批级别数(五级审批制度:部门负责人、人事处、纪委、学院分管领导、学院党委书记)
vo.setTotalLevels(5);
// 审批进度文本
vo.setApprovalProgress(approvedCount + "/" + vo.getTotalLevels());
@@ -350,8 +391,8 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
} else {
// 未开始审批
vo.setApprovedLevels(0);
vo.setTotalLevels(3);
vo.setApprovalProgress("0/3");
vo.setTotalLevels(5);
vo.setApprovalProgress("0/5");
}
}
@@ -380,6 +421,7 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
/**
* 获取审批级别文本
* 五级审批制度:1-部门负责人,2-人事处,3-纪委,4-学院分管领导,5-学院党委书记
*/
private String getApprovalLevelText(String level) {
if (level == null) {
@@ -387,11 +429,15 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
}
switch (level) {
case "1":
return "部门主管审批";
return "部门负责人审批";
case "2":
return "分管领导审批";
return "人事处审批";
case "3":
return "校领导审批";
return "纪委审批";
case "4":
return "学院分管领导审批";
case "5":
return "学院党委书记审批";
default:
return "未知";
}
@@ -495,4 +541,560 @@ public class RsOverseasApprovalMonitorServiceImpl implements IRsOverseasApproval
return politicalStatus;
}
}
/**
* 下载出国申请审批表PDF
*/
@Override
public void downloadApprovalPdf(Long applicationId, HttpServletResponse response) {
log.info("开始生成出国申请审批表PDF,申请ID:{}", applicationId);
try {
// 查询申请信息
RsOverseasApplication application = applicationMapper.selectById(applicationId);
if (application == null) {
throw new ServiceException("未找到申请信息");
}
// 查询审批历史
List<RsOverseasApprovalVo> approvalHistory = approvalService.queryByApplicationId(applicationId);
// 调试日志:输出审批历史数据
log.info("📋 查询到审批历史记录数量: {}", approvalHistory != null ? approvalHistory.size() : 0);
if (approvalHistory != null && !approvalHistory.isEmpty()) {
for (RsOverseasApprovalVo approval : approvalHistory) {
log.info(" - 级别{}: 审批人={}, 意见={}, 签字数据长度={}",
approval.getApprovalLevel(),
approval.getApproverName(),
approval.getApprovalOpinion(),
approval.getApprovalSignature() != null ? approval.getApprovalSignature().length() : 0);
}
}
// 设置响应头
String fileName = "出国申请审批表_" + application.getApplicantName() + "_"
+ new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".pdf";
response.setContentType("application/pdf");
response.setHeader("Content-Disposition",
"attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
// 生成PDF
generateApprovalPdf(application, approvalHistory, response.getOutputStream());
log.info("✅ 出国申请审批表PDF生成成功");
} catch (Exception e) {
log.error("❌ 生成出国申请审批表PDF失败:{}", e.getMessage(), e);
throw new ServiceException("生成PDF失败:" + e.getMessage());
}
}
/**
* 生成审批表PDF文档
*/
private void generateApprovalPdf(RsOverseasApplication application,
List<RsOverseasApprovalVo> approvalHistory, OutputStream outputStream)
throws DocumentException, IOException {
Document document = new Document(PageSize.A4, 40, 40, 40, 40);
PdfWriter.getInstance(document, outputStream);
document.open();
try {
// 创建中文字体
BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
Font titleFont = new Font(baseFont, 16, Font.BOLD);
Font headerFont = new Font(baseFont, 14, Font.BOLD);
Font normalFont = new Font(baseFont, 10, Font.NORMAL);
Font smallFont = new Font(baseFont, 9, Font.NORMAL);
// 1. 标题
Paragraph title1 = new Paragraph("杭州第一技师学院", titleFont);
title1.setAlignment(Element.ALIGN_CENTER);
title1.setSpacingAfter(5);
document.add(title1);
Paragraph title2 = new Paragraph("登记报备人员因私事出国(境)审批表", headerFont);
title2.setAlignment(Element.ALIGN_CENTER);
title2.setSpacingAfter(15);
document.add(title2);
// 2. 个人信息表格(优化列宽,使垂直线对齐)
PdfPTable infoTable = new PdfPTable(12); // 12列
infoTable.setWidthPercentage(100);
// 列宽:使关键分界线对齐
infoTable.setWidths(new float[] { 6f, 9f, 5f, 7f, 8f, 9f, 8f, 9f, 6f, 9f, 8f, 10f });
// 第一行:姓名、性别、出生年月、政治面貌
addCell(infoTable, "姓名", normalFont, true, 1); // 列1: 6f
addCell(infoTable, application.getApplicantName(), normalFont, false, 2); // 列2-3: 9f+5f=14f
addCell(infoTable, "性别", normalFont, true, 1); // 列4: 7f
addCell(infoTable, translateGender(application.getGender()), normalFont, false, 1); // 列5: 8f
addCell(infoTable, "出生年月", normalFont, true, 1); // 列6: 9f
addCell(infoTable, formatBirthDate(application.getBirthDate()), normalFont, false, 2); // 列7-8: 8f+9f=17f
addCell(infoTable, "政治面貌", normalFont, true, 1); // 列9: 6f
addCell(infoTable, translatePoliticalStatus(application.getPoliticalStatus()), normalFont, false, 3); // 列10-12:
// 9f+8f+10f=27f
// 第二行:现工作部门、职称、职务
addCell(infoTable, "现工作部门\n(教学系)", normalFont, true, 3); // 列1-3: 6f+9f+5f=20f
addCell(infoTable, application.getDepartment(), normalFont, false, 2); // 列4-5: 7f+8f=15f
addCell(infoTable, "职称", normalFont, true, 1); // 列6: 9f
addCell(infoTable, application.getProfessionalTitle(), normalFont, false, 2); // 列7-8: 8f+9f=17f
addCell(infoTable, "职务", normalFont, true, 1); // 列9: 6f
addCell(infoTable, application.getPosition(), normalFont, false, 3); // 列10-12: 9f+8f+10f=27f
// 第三行:申领证件名称、证件号码
addCell(infoTable, "申领证件名称", normalFont, true, 1); // 列1: 6f
addCell(infoTable, application.getDocumentName(), normalFont, false, 2); // 列2-3: 9f+5f=14f
addCell(infoTable, "证件号码", normalFont, true, 2); // 列4-5: 7f+8f=15f
addCell(infoTable, application.getDocumentNumber(), normalFont, false, 7); // 列6-12:
// 9f+8f+9f+6f+9f+8f+10f=59f
// 第四行:前往国家或地区(按照附件2标准格式)
addCell(infoTable, "前往国家\n或地区", normalFont, true, 1); // 列1: 6f
PdfPCell destinationCell = new PdfPCell();
destinationCell.setColspan(11); // 列2-12: 占据剩余11列
destinationCell.setMinimumHeight(60);
destinationCell.setVerticalAlignment(Element.ALIGN_TOP);
destinationCell.setPadding(0);
// 创建嵌套表格:5列布局(国家名称 | 出境事由 | 旅游/探亲 | 探望对象关系标签 | 探望对象关系内容)
// 列宽精确对齐外层表格的垂直线位置
PdfPTable innerTable = new PdfPTable(5);
innerTable.setWidthPercentage(100);
// 对齐到外层列3|列5|列8|列10|列12的位置: 14f, 15f, 26f, 15f, 18f (总计88f)
innerTable.setWidths(new float[] { 14f, 15f, 26f, 15f, 18f });
// 第一列:国家名称(合并3行)
String destination = StringUtils.isNotBlank(application.getDestinationCountry())
? application.getDestinationCountry()
: "";
PdfPCell countryCell = new PdfPCell(new Phrase(destination, smallFont));
countryCell.setRowspan(3);
countryCell.setMinimumHeight(60);
countryCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
countryCell.setHorizontalAlignment(Element.ALIGN_CENTER);
countryCell.setPadding(3);
innerTable.addCell(countryCell);
// 第一行:出境事由(合并2行)| 旅游选项(占据右侧3列)
// 出境事由标签(合并2行)
PdfPCell reasonLabelCell = new PdfPCell(new Phrase("出境事由", smallFont));
reasonLabelCell.setRowspan(2);
reasonLabelCell.setMinimumHeight(40);
reasonLabelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
reasonLabelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
innerTable.addCell(reasonLabelCell);
// 旅游选项框(占据3列)
String travelMark = "旅游".equals(application.getExitReason()) ? "√" : "";
PdfPCell travelCell = new PdfPCell(new Phrase("旅游( " + travelMark + " )", smallFont));
travelCell.setColspan(3);
travelCell.setMinimumHeight(20);
travelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
travelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
innerTable.addCell(travelCell);
// 第二行:探亲选项 | 探望对象关系标签 | 探望对象关系内容
// 探亲选项框
String visitMark = "探亲".equals(application.getExitReason()) ? "√" : "";
PdfPCell visitCell = new PdfPCell(new Phrase("探亲( " + visitMark + " )", smallFont));
visitCell.setMinimumHeight(20);
visitCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
visitCell.setHorizontalAlignment(Element.ALIGN_CENTER);
innerTable.addCell(visitCell);
// 探望对象关系标签(独立单元格)
PdfPCell relationLabelCell = new PdfPCell(new Phrase("探望对象姓名及\n与申请者关系", smallFont));
relationLabelCell.setMinimumHeight(20);
relationLabelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
relationLabelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
relationLabelCell.setPadding(3);
innerTable.addCell(relationLabelCell);
// 探望对象关系内容(独立单元格)
String relationship = StringUtils.isNotBlank(application.getVisitRelationship())
? application.getVisitRelationship()
: "";
PdfPCell relationContentCell = new PdfPCell(new Phrase(relationship, smallFont));
relationContentCell.setMinimumHeight(20);
relationContentCell.setVerticalAlignment(Element.ALIGN_TOP);
relationContentCell.setPadding(3);
innerTable.addCell(relationContentCell);
// 第三行:备注 | 备注内容(国家名称已合并,从第2列开始,备注内容占据3列)
PdfPCell remarkLabelCell = new PdfPCell(new Phrase("备注", smallFont));
remarkLabelCell.setMinimumHeight(20);
remarkLabelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
remarkLabelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
remarkLabelCell.setPadding(3);
innerTable.addCell(remarkLabelCell);
String remarks = StringUtils.isNotBlank(application.getRemarks())
? application.getRemarks()
: "";
PdfPCell remarkValueCell = new PdfPCell(new Phrase(remarks, smallFont));
remarkValueCell.setColspan(3); // 占据第3、4、5列
remarkValueCell.setMinimumHeight(20);
remarkValueCell.setVerticalAlignment(Element.ALIGN_TOP);
remarkValueCell.setPadding(3);
innerTable.addCell(remarkValueCell);
destinationCell.addElement(innerTable);
infoTable.addCell(destinationCell);
// 第五行:出国时间
addCell(infoTable, "出过(境)时间", normalFont, true, 2); // 列1-2
PdfPCell timeCell = new PdfPCell();
timeCell.setColspan(10); // 列3-12: 占据剩余10列
timeCell.setMinimumHeight(30);
timeCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
String timeText = String.format("拟于 %s 至 %s 出国(境),为期 %d 天。",
formatDateChinese(application.getStartDate()),
formatDateChinese(application.getEndDate()),
application.getDuration() != null ? application.getDuration() : 0);
Paragraph timePara = new Paragraph(timeText, smallFont);
timeCell.addElement(timePara);
infoTable.addCell(timeCell);
// 第六行:承诺声明(修复断层问题,使用单一段落)
PdfPCell promiseCell = new PdfPCell();
promiseCell.setColspan(12); // 占据全部12列
promiseCell.setMinimumHeight(70);
promiseCell.setVerticalAlignment(Element.ALIGN_TOP);
promiseCell.setPadding(5);
// 使用单一段落避免断层,通过Chunk控制对齐
Paragraph promisePara = new Paragraph();
promisePara.setSpacingAfter(0);
promisePara.setSpacingBefore(0);
// 承诺内容 - 居中部分
Chunk promiseText1 = new Chunk("本人承诺,在出国(境)期间遵守当地法律法规和中国的法律法规,结束后如期回国并到学校", smallFont);
Chunk promiseText2 = new Chunk("\n报到,如逾期不归,同意作自动离职处理。\n", smallFont);
promisePara.add(promiseText1);
promisePara.add(promiseText2);
promisePara.setAlignment(Element.ALIGN_CENTER);
promiseCell.addElement(promisePara);
// 联系信息和签字 - 左对齐部分
Paragraph contactPara = new Paragraph();
contactPara.setSpacingAfter(0);
contactPara.setSpacingBefore(2);
contactPara.setAlignment(Element.ALIGN_LEFT);
contactPara.setIndentationLeft(10);
contactPara.add(new Chunk("手写签字:", smallFont));
// 如果有签字,插入签字图片
if (StringUtils.isNotBlank(application.getSignature())) {
try {
Image signatureImage = getImageFromBase64(application.getSignature());
if (signatureImage != null) {
signatureImage.scaleToFit(60, 20);
contactPara.add(new Chunk(signatureImage, 0, -5));
}
} catch (Exception e) {
log.warn("插入申请人签字图片失败: {}", e.getMessage());
}
}
contactPara.add(new Chunk(" 联系电话:", smallFont));
if (StringUtils.isNotBlank(application.getMobilePhone())) {
contactPara.add(new Chunk(application.getMobilePhone(), smallFont));
} else {
contactPara.add(new Chunk("13333333333", smallFont));
}
contactPara.add(new Chunk(" 应急联系人及联系电话:", smallFont));
if (StringUtils.isNotBlank(application.getEmergencyContact())) {
contactPara.add(new Chunk(application.getEmergencyContact(), smallFont));
} else {
contactPara.add(new Chunk("13333333333", smallFont));
}
promiseCell.addElement(contactPara);
// 日期 - 靠右对齐部分(使用申请日期)
Paragraph datePara = new Paragraph();
datePara.setSpacingAfter(0);
datePara.setSpacingBefore(2);
datePara.setAlignment(Element.ALIGN_RIGHT);
datePara.setIndentationRight(20);
// 使用申请时间作为申请人签名日期(优先使用applyTime,其次使用createTime)
Date applicationDateTime = application.getApplyTime() != null
? application.getApplyTime()
: application.getCreateTime();
String applicationDate = applicationDateTime != null
? formatDateChinese(applicationDateTime)
: "年 月 日";
datePara.add(new Chunk(applicationDate, smallFont));
promiseCell.addElement(datePara);
infoTable.addCell(promiseCell);
document.add(infoTable);
// 3. 审批意见表格(紧贴个人信息表格,无间隔)
PdfPTable approvalTable = new PdfPTable(2);
approvalTable.setWidthPercentage(100);
approvalTable.setWidths(new float[] { 20f, 80f });
approvalTable.setSpacingBefore(0); // 设置为0,与上方表格紧密连接
// 部门(教学系)意见
addApprovalRowWithSignature(approvalTable, "部门\n(教学\n系)\n意见",
approvalHistory, "1", normalFont, smallFont);
// 人事(组织)处审核意见
addApprovalRowWithSignature(approvalTable, "人事\n(组\n织)处\n审核\n意见",
approvalHistory, "2", normalFont, smallFont);
// 纪委意见
addApprovalRowWithSignature(approvalTable, "纪委\n意见",
approvalHistory, "3", normalFont, smallFont);
// 学院分管领导意见(独立行)
addApprovalRowWithLeaderSignature(approvalTable, "学院分\n管领导\n意见",
approvalHistory, "4", normalFont, smallFont);
// 学院党委书记意见(独立行)
addApprovalRowWithSignature(approvalTable, "学院党\n委书记\n意见",
approvalHistory, "5", normalFont, smallFont);
document.add(approvalTable);
// 4. 备注说明(移除空段落,直接连接)
Paragraph note = new Paragraph();
note.setSpacingBefore(5); // 只留很小的间距
note.add(new Chunk("备注:1. 因私出境时间,除特殊原因外,原则上应为假期;\n", smallFont));
note.add(new Chunk(" 2. 新申办证件名,本表证件号码一栏不填。", smallFont));
document.add(note);
} finally {
document.close();
}
}
/**
* 添加表格单元格
*/
private void addCell(PdfPTable table, String text, Font font, boolean isHeader, int colspan) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setColspan(colspan);
cell.setMinimumHeight(25);
cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
// 移除灰色背景,所有单元格统一白色背景
// if (isHeader) {
// cell.setBackgroundColor(BaseColor.LIGHT_GRAY);
// }
table.addCell(cell);
}
/**
* 添加审批意见行(带签字显示)
*/
private void addApprovalRowWithSignature(PdfPTable table, String label,
List<RsOverseasApprovalVo> approvalHistory, String level, Font labelFont, Font contentFont) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, labelFont));
labelCell.setMinimumHeight(50);
labelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
labelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(labelCell);
PdfPCell opinionCell = new PdfPCell();
opinionCell.setMinimumHeight(50);
opinionCell.setVerticalAlignment(Element.ALIGN_TOP);
opinionCell.setPadding(5);
Paragraph para = new Paragraph();
String opinion = getApprovalOpinion(approvalHistory, level);
para.add(new Chunk(opinion, contentFont));
para.add(new Chunk("\n\n负责人签字:", contentFont));
// 添加签字图片
String signature = getApprovalSignature(approvalHistory, level);
if (StringUtils.isNotBlank(signature)) {
try {
Image signImage = getImageFromBase64(signature);
if (signImage != null) {
signImage.scaleToFit(60, 20);
para.add(new Chunk(signImage, 0, -5));
}
} catch (Exception e) {
log.warn("插入审批人签字图片失败: {}", e.getMessage());
}
}
// 日期靠右(使用审批日期)
opinionCell.addElement(para);
Paragraph datePara = new Paragraph();
datePara.setAlignment(Element.ALIGN_RIGHT);
datePara.setIndentationRight(10);
// 获取审批日期
Date approvalDate = getApprovalDate(approvalHistory, level);
String dateText = approvalDate != null ? formatDateChinese(approvalDate) : "年 月 日";
datePara.add(new Chunk(dateText, contentFont));
opinionCell.addElement(datePara);
table.addCell(opinionCell);
}
/**
* 添加学院分管领导审批行(使用"领导签字"并包含"学院"文字)
*/
private void addApprovalRowWithLeaderSignature(PdfPTable table, String label,
List<RsOverseasApprovalVo> approvalHistory, String level, Font labelFont, Font contentFont) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, labelFont));
labelCell.setMinimumHeight(50);
labelCell.setVerticalAlignment(Element.ALIGN_MIDDLE);
labelCell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(labelCell);
PdfPCell opinionCell = new PdfPCell();
opinionCell.setMinimumHeight(50);
opinionCell.setVerticalAlignment(Element.ALIGN_TOP);
opinionCell.setPadding(5);
Paragraph para = new Paragraph();
String opinion = getApprovalOpinion(approvalHistory, level);
para.add(new Chunk(opinion, contentFont));
para.add(new Chunk("\n\n领导签字:", contentFont));
// 添加签字图片
String signature = getApprovalSignature(approvalHistory, level);
if (StringUtils.isNotBlank(signature)) {
try {
Image signImage = getImageFromBase64(signature);
if (signImage != null) {
signImage.scaleToFit(60, 20);
para.add(new Chunk(signImage, 0, -5));
}
} catch (Exception e) {
log.warn("插入分管领导签字图片失败: {}", e.getMessage());
}
}
// 日期靠右(使用审批日期)
opinionCell.addElement(para);
Paragraph datePara = new Paragraph();
datePara.setAlignment(Element.ALIGN_RIGHT);
datePara.setIndentationRight(10);
// 获取审批日期
Date approvalDate = getApprovalDate(approvalHistory, level);
String dateText = approvalDate != null ? formatDateChinese(approvalDate) : "年 月 日";
datePara.add(new Chunk(dateText, contentFont));
opinionCell.addElement(datePara);
table.addCell(opinionCell);
}
/**
* 获取指定级别的审批意见
*/
private String getApprovalOpinion(List<RsOverseasApprovalVo> approvalHistory, String level) {
if (approvalHistory == null || approvalHistory.isEmpty()) {
return "";
}
return approvalHistory.stream()
.filter(a -> level.equals(a.getApprovalLevel()))
.findFirst()
.map(a -> {
String opinion = StringUtils.isNotBlank(a.getApprovalOpinion())
? a.getApprovalOpinion()
: ("1".equals(a.getApprovalStatus()) ? "同意" : "");
return opinion;
})
.orElse("");
}
/**
* 格式化日期
*/
private String formatDate(Date date) {
if (date == null) {
return "";
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.format(date);
}
/**
* 格式化日期为中文格式(yyyy 年 MM 月 dd 日)
*/
private String formatDateChinese(Date date) {
if (date == null) {
return "";
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy 年 MM 月 dd 日");
return sdf.format(date);
}
/**
* 格式化出生日期(只显示年月)
*/
private String formatBirthDate(Date date) {
if (date == null) {
return "";
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月");
return sdf.format(date);
}
/**
* 获取指定级别的审批签字
*/
private String getApprovalSignature(List<RsOverseasApprovalVo> approvalHistory, String level) {
if (approvalHistory == null || approvalHistory.isEmpty()) {
return null;
}
return approvalHistory.stream()
.filter(a -> level.equals(a.getApprovalLevel()))
.findFirst()
.map(RsOverseasApprovalVo::getApprovalSignature)
.orElse(null);
}
/**
* 获取指定级别的审批日期
*/
private Date getApprovalDate(List<RsOverseasApprovalVo> approvalHistory, String level) {
if (approvalHistory == null || approvalHistory.isEmpty()) {
return null;
}
return approvalHistory.stream()
.filter(a -> level.equals(a.getApprovalLevel()))
.findFirst()
.map(RsOverseasApprovalVo::getApprovalTime)
.orElse(null);
}
/**
* 从Base64字符串创建Image对象
*/
private Image getImageFromBase64(String base64String) {
if (StringUtils.isBlank(base64String)) {
return null;
}
try {
// 处理data URL格式:data:image/png;base64,xxxxx
String base64Data = base64String;
if (base64String.contains(",")) {
base64Data = base64String.substring(base64String.indexOf(",") + 1);
}
// Base64解码
byte[] imageBytes = java.util.Base64.getDecoder().decode(base64Data);
// 创建Image对象
Image image = Image.getInstance(imageBytes);
return image;
} catch (Exception e) {
log.error("从Base64创建图片失败: {}", e.getMessage());
return null;
}
}
}
@@ -121,9 +121,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
ELSE oa.purpose
END as purpose_text,
CASE oa.approval_level
WHEN '1' THEN '部门主管'
WHEN '2' THEN '分管领导'
WHEN '3' THEN '校领导'
WHEN '1' THEN '部门负责人'
WHEN '2' THEN '人事处'
WHEN '3' THEN '纪委'
WHEN '4' THEN '学院分管领导'
WHEN '5' THEN '学院党委书记'
ELSE '未知级别'
END as approval_level_text,
CASE oa.gender
@@ -12,6 +12,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="approvalLevel" column="approval_level"/>
<result property="approvalStatus" column="approval_status"/>
<result property="approvalOpinion" column="approval_opinion"/>
<result property="approvalSignature" column="approval_signature"/>
<result property="approvalTime" column="approval_time"/>
<result property="tenantId" column="tenant_id"/>
<result property="createBy" column="create_by"/>
@@ -39,16 +40,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectOverseasApprovalVo">
select oa.approval_id, oa.application_id, oa.approver_id, oa.approver_name, oa.approval_level,
oa.approval_status, oa.approval_opinion, oa.approval_time, oa.tenant_id,
oa.approval_status, oa.approval_opinion, oa.approval_signature, oa.approval_time, oa.tenant_id,
oa.create_by, oa.create_time, oa.update_by, oa.update_time, oa.deleted,
<!-- 申请信息字段 -->
app.applicant_id, app.applicant_name, app.department, app.purpose, app.destination,
app.start_date, app.end_date, app.duration,
<!-- 文本转换 -->
CASE oa.approval_level
WHEN '1' THEN '部门主管'
WHEN '2' THEN '分管领导'
WHEN '3' THEN '校领导'
WHEN '1' THEN '部门负责人'
WHEN '2' THEN '人事处'
WHEN '3' THEN '纪委'
WHEN '4' THEN '学院分管领导'
WHEN '5' THEN '学院党委书记'
ELSE '未知级别'
END as approval_level_text,
CASE oa.approval_status
@@ -169,9 +172,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
app.duration,
-- 文本转换
CASE oa.approval_level
WHEN '1' THEN '部门主管'
WHEN '2' THEN '分管领导'
WHEN '3' THEN '校领导'
WHEN '1' THEN '部门负责人'
WHEN '2' THEN '人事处'
WHEN '3' THEN '纪委'
WHEN '4' THEN '学院分管领导'
WHEN '5' THEN '学院党委书记'
ELSE '未知级别'
END as approval_level_text,
CASE oa.approval_status
+53
View File
@@ -157,6 +157,7 @@ export interface ApprovalRequest {
applicationId: number | string;
approvalStatus: string;
approvalOpinion?: string;
approvalSignature?: string; // 审批签字(base64图片)
taskId?: string; // 🔴 新增:工作流任务ID,支持标准工作流模式
}
@@ -885,6 +886,7 @@ export interface ItineraryChangeApprovalRequest {
changeId: number | string;
approved: boolean; // true=通过,false=拒绝
opinion?: string; // 审批意见
signature?: string; // 审批签字(base64图片)
}
// 查询行程变更列表
@@ -1030,3 +1032,54 @@ export function getHrApprovers(applicantId: string | number): Promise<{ data: nu
method: 'get'
});
}
// ==================== 出国申请进度查询(监控)相关API ====================
// 审批监控查询参数类型
export interface OverseasMonitorQuery {
pageNum: number;
pageSize: number;
applicantName?: string;
department?: string;
destination?: string;
approvalLevel?: string;
currentApprover?: string;
status?: string;
applyTimeStart?: string;
applyTimeEnd?: string;
}
// 查询待审批列表(监控)
export function getPendingList(query: OverseasMonitorQuery) {
return request({
url: '/rs/overseas/monitor/pending/list',
method: 'get',
params: query
});
}
// 查询已完成审批列表(监控)
export function getCompletedList(query: OverseasMonitorQuery) {
return request({
url: '/rs/overseas/monitor/completed/list',
method: 'get',
params: query
});
}
// 查询申请详情(监控)
export function getDetail(applicationId: string | number) {
return request({
url: `/rs/overseas/monitor/detail/${applicationId}`,
method: 'get'
});
}
// 下载审批表PDF
export function downloadApprovalPdf(applicationId: string | number) {
return request({
url: `/rs/overseas/monitor/downloadApprovalPdf/${applicationId}`,
method: 'get',
responseType: 'blob'
});
}
+196 -227
View File
@@ -1,278 +1,247 @@
<template>
<div class="signature-pad-wrapper">
<div class="signature-header">
<span class="signature-title">手写签名</span>
<el-button v-if="hasSignature" size="small" type="danger" :icon="Delete" @click="handleClear">清空</el-button>
</div>
<div class="signature-canvas-container">
<div class="signature-pad-container">
<div class="signature-canvas-wrapper">
<canvas
ref="canvasRef"
class="signature-canvas"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
<div v-if="!hasSignature" class="signature-placeholder">请在此处签名</div>
:width="width"
:height="height"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="handleTouch"
@touchmove="handleTouch"
@touchend="stopDrawing"
/>
</div>
<div class="signature-buttons">
<el-button size="small" @click="clearSignature">清除</el-button>
<el-button v-if="showSaveButton" size="small" type="primary" @click="saveSignature">保存</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { ref, onMounted, watch } from 'vue';
import { ElMessage } from 'element-plus';
// Props
interface Props {
modelValue?: string; // Base64签名数据
modelValue?: string; // base64图片数据
width?: number;
height?: number;
lineWidth?: number;
lineColor?: string;
showSaveButton?: boolean; // 是否显示保存按钮
autoSave?: boolean; // 是否自动保存(绘制时实时保存)
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
width: 500,
height: 200,
lineWidth: 2,
lineColor: '#000000'
width: 400,
height: 150,
showSaveButton: true,
autoSave: true
});
// Emits
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
'update:modelValue': [value: string];
'save': [value: string];
}>();
// Refs
const canvasRef = ref<HTMLCanvasElement>();
const hasSignature = ref(false);
const isDrawing = ref(false);
let ctx: CanvasRenderingContext2D | null = null;
let resizeObserver: ResizeObserver | null = null;
const lastPoint = ref({ x: 0, y: 0 });
onMounted(() => {
initCanvas();
// 如果有初始值,加载到画布上
if (props.modelValue) {
loadSignature(props.modelValue);
// Methods
const startDrawing = (event: MouseEvent) => {
if (!canvasRef.value) return;
isDrawing.value = true;
const rect = canvasRef.value.getBoundingClientRect();
lastPoint.value = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
};
const draw = (event: MouseEvent) => {
if (!isDrawing.value || !canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const rect = canvasRef.value.getBoundingClientRect();
const currentPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// 绘制线条
ctx.beginPath();
ctx.moveTo(lastPoint.value.x, lastPoint.value.y);
ctx.lineTo(currentPoint.x, currentPoint.y);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
lastPoint.value = currentPoint;
// 自动保存
if (props.autoSave) {
saveToModel();
}
};
// 监听容器大小变化
if (canvasRef.value?.parentElement) {
resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(canvasRef.value.parentElement);
const stopDrawing = () => {
isDrawing.value = false;
// 如果不是自动保存,在停止绘制时保存
if (!props.autoSave) {
saveToModel();
}
});
};
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
// 触摸事件处理
const handleTouch = (event: TouchEvent) => {
event.preventDefault();
const touch = event.touches[0];
if (!touch || !canvasRef.value) return;
const rect = canvasRef.value.getBoundingClientRect();
const mouseEvent = new MouseEvent(
event.type === 'touchstart' ? 'mousedown' : 'mousemove',
{
clientX: touch.clientX,
clientY: touch.clientY
}
);
if (event.type === 'touchstart') {
startDrawing(mouseEvent);
} else if (event.type === 'touchmove') {
draw(mouseEvent);
}
});
};
// 监听外部值变化
// 清除签名
const clearSignature = () => {
if (!canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
emit('update:modelValue', '');
};
// 保存到 v-model
const saveToModel = () => {
if (!canvasRef.value) return;
try {
const dataURL = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataURL);
} catch (error) {
console.error('保存签名失败:', error);
}
};
// 手动保存(触发save事件)
const saveSignature = () => {
if (!canvasRef.value) return;
try {
const dataURL = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', dataURL);
emit('save', dataURL);
ElMessage.success('签名已保存');
} catch (error) {
console.error('保存签名失败:', error);
ElMessage.error('保存签名失败');
}
};
// 从base64恢复签名
const restoreSignature = (base64Image: string) => {
if (!canvasRef.value || !base64Image) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
if (!canvasRef.value) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
ctx.drawImage(img, 0, 0);
};
img.src = base64Image;
};
// 监听 modelValue 变化,恢复签名
watch(
() => props.modelValue,
(newVal) => {
if (newVal && !hasSignature.value) {
loadSignature(newVal);
(newValue) => {
if (newValue) {
restoreSignature(newValue);
} else {
clearSignature();
}
}
);
const initCanvas = () => {
if (!canvasRef.value) return;
const canvas = canvasRef.value;
const container = canvas.parentElement;
// 获取容器的实际宽度
const containerWidth = container?.clientWidth || props.width;
// 设置画布的物理尺寸(实际像素)
canvas.width = containerWidth;
canvas.height = props.height;
// 同时设置CSS样式,确保显示尺寸与物理尺寸一致
canvas.style.width = `${containerWidth}px`;
canvas.style.height = `${props.height}px`;
ctx = canvas.getContext('2d');
if (ctx) {
ctx.strokeStyle = props.lineColor;
ctx.lineWidth = props.lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 组件挂载时恢复签名
onMounted(() => {
if (props.modelValue) {
restoreSignature(props.modelValue);
}
};
});
// 处理容器大小变化
const handleResize = () => {
if (!canvasRef.value) return;
// 保存当前签名
const currentSignature = hasSignature.value ? canvasRef.value.toDataURL('image/png') : '';
// 重新初始化画布
initCanvas();
// 恢复签名
if (currentSignature && hasSignature.value) {
nextTick(() => {
loadSignature(currentSignature);
});
}
};
const startDrawing = (x: number, y: number) => {
if (!ctx || !canvasRef.value) return;
isDrawing.value = true;
const rect = canvasRef.value.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(x - rect.left, y - rect.top);
};
const draw = (x: number, y: number) => {
if (!isDrawing.value || !ctx || !canvasRef.value) return;
const rect = canvasRef.value.getBoundingClientRect();
ctx.lineTo(x - rect.left, y - rect.top);
ctx.stroke();
hasSignature.value = true;
};
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
if (ctx) {
ctx.closePath();
}
// 导出签名数据
if (hasSignature.value && canvasRef.value) {
const signatureData = canvasRef.value.toDataURL('image/png');
emit('update:modelValue', signatureData);
}
};
// 鼠标事件处理
const handleMouseDown = (e: MouseEvent) => {
startDrawing(e.clientX, e.clientY);
};
const handleMouseMove = (e: MouseEvent) => {
draw(e.clientX, e.clientY);
};
const handleMouseUp = () => {
stopDrawing();
};
// 触摸事件处理(移动端支持)
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
startDrawing(touch.clientX, touch.clientY);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
draw(touch.clientX, touch.clientY);
};
const handleTouchEnd = (e: TouchEvent) => {
e.preventDefault();
stopDrawing();
};
// 清空签名
const handleClear = () => {
if (!canvasRef.value || !ctx) return;
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
hasSignature.value = false;
emit('update:modelValue', '');
};
// 加载签名
const loadSignature = (base64Data: string) => {
if (!canvasRef.value || !ctx) return;
const img = new Image();
img.onload = () => {
ctx!.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height);
ctx!.drawImage(img, 0, 0);
hasSignature.value = true;
};
img.src = base64Data;
};
// 获取签名数据
const getSignature = (): string => {
if (!canvasRef.value || !hasSignature.value) return '';
return canvasRef.value.toDataURL('image/png');
};
// 对外暴露方法
// 暴露方法供父组件调用
defineExpose({
clear: handleClear,
getSignature,
hasSignature: () => hasSignature.value
clearSignature,
saveSignature
});
</script>
<style scoped lang="scss">
.signature-pad-wrapper {
.signature-pad-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.signature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.signature-canvas-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fafafa;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px;
}
.signature-title {
font-size: 14px;
color: #606266;
font-weight: 500;
}
}
.signature-canvas {
border: 1px dashed #dcdfe6;
border-radius: 4px;
cursor: crosshair;
background-color: #fff;
touch-action: none; /* 防止触摸时页面滚动 */
}
.signature-canvas-container {
position: relative;
width: 100%;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #ffffff;
overflow: hidden;
.signature-canvas:hover {
border-color: #409eff;
}
&:hover {
border-color: #c0c4cc;
}
.signature-canvas {
display: block;
width: 100%;
cursor: crosshair;
touch-action: none;
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #c0c4cc;
font-size: 14px;
pointer-events: none;
user-select: none;
}
}
.signature-buttons {
display: flex;
gap: 8px;
justify-content: center;
}
</style>
+28 -3
View File
@@ -325,11 +325,23 @@
</el-dialog>
<!-- 审批对话框 -->
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="600px" append-to-body>
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="650px" append-to-body>
<el-form ref="approvalFormRef" :model="approvalForm" :rules="approvalRules" label-width="100px">
<el-form-item label="审批意见" prop="approvalOpinion">
<el-input v-model="approvalForm.approvalOpinion" type="textarea" :rows="4" placeholder="请输入审批意见" />
</el-form-item>
<!-- 手写签字 -->
<el-form-item label="审批签字" prop="approvalSignature">
<SignaturePad
v-model="approvalForm.approvalSignature"
:width="450"
:height="120"
:auto-save="true"
:show-save-button="false"
/>
<div class="signature-tip">请在上方签字板中手写签名</div>
</el-form-item>
</el-form>
<template #footer>
@@ -360,6 +372,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Document, Warning, Paperclip, View, Download } from '@element-plus/icons-vue';
import animate from '@/animate';
import request from '@/utils/request';
import SignaturePad from '@/components/SignaturePad/index.vue';
import { getToken } from '@/utils/auth';
// 响应式数据
@@ -400,7 +413,8 @@ const state = reactive({
approvalForm: {
applicationId: '',
approvalStatus: '1',
approvalOpinion: ''
approvalOpinion: '',
approvalSignature: '' // 审批签字
} as ApprovalRequest,
approvalRules: {
approvalOpinion: [{ required: true, message: '请输入审批意见', trigger: 'blur' }]
@@ -550,6 +564,7 @@ const handleApprovalPass = (row: OverseasApplicationVO) => {
state.approvalForm.applicationId = row.applicationId;
state.approvalForm.approvalStatus = '1';
state.approvalForm.approvalOpinion = '';
state.approvalForm.approvalSignature = ''; // 清空签字
state.approvalDialog.title = '通过';
state.approvalDialog.visible = true;
};
@@ -559,6 +574,7 @@ const handleApprovalReject = (row: OverseasApplicationVO) => {
state.approvalForm.applicationId = row.applicationId;
state.approvalForm.approvalStatus = '2';
state.approvalForm.approvalOpinion = '';
state.approvalForm.approvalSignature = ''; // 清空签字
state.approvalDialog.title = '拒绝';
state.approvalDialog.visible = true;
};
@@ -586,7 +602,8 @@ const cancelApproval = () => {
state.approvalForm = {
applicationId: '',
approvalStatus: '1',
approvalOpinion: ''
approvalOpinion: '',
approvalSignature: ''
};
};
@@ -913,4 +930,12 @@ const handlePreviewStampedFile = async (data: OverseasApplicationVO) => {
font-size: 12px;
}
}
/* 签字样式 */
.signature-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>
@@ -257,7 +257,7 @@
</el-dialog>
<!-- 审批对话框 -->
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="600px" append-to-body :close-on-click-modal="false">
<el-dialog :title="approvalDialog.title" v-model="approvalDialog.visible" width="650px" append-to-body :close-on-click-modal="false">
<el-form ref="approvalFormRef" :model="approvalForm" :rules="approvalRules" label-width="100px">
<el-form-item label="审批结果" prop="approved">
<el-radio-group v-model="approvalForm.approved">
@@ -268,6 +268,18 @@
<el-form-item label="审批意见" prop="opinion">
<el-input v-model="approvalForm.opinion" type="textarea" :rows="4" placeholder="请输入审批意见" maxlength="500" show-word-limit />
</el-form-item>
<!-- 手写签字 -->
<el-form-item label="审批签字">
<SignaturePad
v-model="approvalForm.signature"
:width="450"
:height="120"
:auto-save="true"
:show-save-button="false"
/>
<div class="signature-tip">请在上方签字板中手写签名</div>
</el-form-item>
</el-form>
<template #footer>
@@ -295,6 +307,7 @@ import {
type ItineraryChangeVO,
type ItineraryChangeApprovalRequest
} from '@/api/rs/overseas';
import SignaturePad from '@/components/SignaturePad/index.vue';
// ==================== 响应式数据 ====================
@@ -346,7 +359,8 @@ const state = reactive({
approvalForm: {
changeId: 0,
approved: true, // true=通过,false=拒绝
opinion: ''
opinion: '',
signature: '' // 审批签字
} as ItineraryChangeApprovalRequest,
approvalLoading: false,
@@ -549,4 +563,12 @@ const getStatusTagType = (status: string): 'primary' | 'success' | 'info' | 'war
.tab-content {
min-height: 400px;
}
/* 签字样式 */
.signature-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>