wlzboy
2026-02-01 0ffdf00009b0bede0859fa33deddefb55c075a7b
feat:优化增加任务同步接口,允许前端手动控制同步
10个文件已修改
2个文件已添加
873 ■■■■■ 已修改文件
app/pagesTask/create-emergency.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/HospDataController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskController.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/HospitalTokenizerUtil.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/image/ImageCompressUtil.java 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/image/package-info.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/controller/OCRController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/UserSyncServiceImpl.java 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/utils/TencentOCRUtil.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysTaskMapper.xml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/task.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/task/general/detail.vue 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pagesTask/create-emergency.vue
@@ -2006,6 +2006,14 @@
      // - YYYYMMDD
      // - yyyy-MM-dd HH:mm:ss
      console.log('尝试格式化日期字符串:', dateStr)
      // 如果输入为空或无效,返回空字符串
      if (!dateStr || typeof dateStr !== 'string') {
        console.warn('日期字符串无效:', dateStr)
        return ''
      }
      // 清洗日期字符串
      let cleaned = dateStr
        .replace(/[年月]/g, '-')
        .replace(/[日号]/g, ' ')  // 日/号 → 空格,保留日期和时间的分隔
@@ -2014,50 +2022,76 @@
        .replace(/秒/g, '')
        .replace(/\s+/g, ' ')  // 多个空格合并为一个
        .trim()
      console.log('清理后的日期字符串:', cleaned)
      // 分离日期和时间部分
      const parts = cleaned.split(' ')
      let datePart = parts[0] || ''
      let timePart = parts[1] || ''
      let dateResult = ''
      
      // 处理日期部分
      // 如果是YYMMDD格式
      if (/^\d{6}$/.test(cleaned)) {
        const year = '20' + cleaned.substring(0, 2)
        const month = cleaned.substring(2, 4)
        const day = cleaned.substring(4, 6)
        dateResult = `${year}-${month}-${day}`;
      }
      // 如果是YYYYMMDD格式
      else if (/^\d{8}$/.test(cleaned)) {
        const year = cleaned.substring(0, 4)
        const month = cleaned.substring(4, 6)
        const day = cleaned.substring(6, 8)
      if (/^\d{6}$/.test(datePart)) {
        const year = '20' + datePart.substring(0, 2)
        const month = datePart.substring(2, 4)
        const day = datePart.substring(4, 6)
        dateResult = `${year}-${month}-${day}`
      }
      // 如果已经是合理格式,直接使用
      else if (cleaned.match(/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/)) {
        dateResult = cleaned.replace(/[//]/g, '-')
      // 如果是YYYYMMDD格式
      else if (/^\d{8}$/.test(datePart)) {
        const year = datePart.substring(0, 4)
        const month = datePart.substring(4, 6)
        const day = datePart.substring(6, 8)
        dateResult = `${year}-${month}-${day}`
      }
      // 如果已经包含时分秒,直接返回
      else if (cleaned.match(/^\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2}$/)) {
        return cleaned.replace(/[//]/g, '-')
      }
      // 如果包含时分但缺少秒(yyyy-MM-dd HH:mm:)
      else if (cleaned.match(/^\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}:$/)) {
        // 去掉末尾的冒号,补上秒数00
        return cleaned.replace(/[//]/g, '-').replace(/:$/, '') + ':00'
      }
      // 如果只包含时分(yyyy-MM-dd HH:mm)
      else if (cleaned.match(/^\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}$/)) {
        return cleaned.replace(/[//]/g, '-') + ':00'
      // 如果是yyyy-MM-dd或yyyy/MM/dd格式
      else if (datePart.match(/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/)) {
        dateResult = datePart.replace(/\//g, '-')
      }
      else {
        dateResult = dateStr
        dateResult = datePart
      }
      
      // 如果日期格式正确,添加默认时分秒 00:00:00
      if (dateResult && dateResult.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
        return dateResult + ' 00:00:00'
      // 验证日期部分是否有效
      if (!dateResult.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
        console.warn('日期格式不正确:', dateResult)
        return ''
      }
      
      return dateResult
      // 处理时间部分
      let timeResult = '00:00:00'  // 默认时间
      if (timePart) {
        // 移除末尾多余的冒号
        timePart = timePart.replace(/:+$/, '')
        // 分割时、分、秒
        const timeParts = timePart.split(':')
        const hour = timeParts[0] || '00'
        const minute = timeParts[1] || '00'
        const second = timeParts[2] || '00'
        // 验证时间数字是否有效
        const hourNum = parseInt(hour, 10)
        const minuteNum = parseInt(minute, 10)
        const secondNum = parseInt(second, 10)
        if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(secondNum) &&
            hourNum >= 0 && hourNum < 24 && minuteNum >= 0 && minuteNum < 60 && secondNum >= 0 && secondNum < 60) {
          // 补齐两位数
          timeResult = `${String(hourNum).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}:${String(secondNum).padStart(2, '0')}`
        } else {
          console.warn('时间数值超出范围,使用默认值00:00:00')
        }
      }
      const finalResult = `${dateResult} ${timeResult}`
      console.log('最终格式化结果:', finalResult)
      return finalResult
    }
  }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/HospDataController.java
@@ -39,7 +39,7 @@
    /**
     * 医院搜索最低匹配分数阈值(低于此分数的结果将被过滤)
     */
    private static final int MIN_MATCH_SCORE_THRESHOLD = 50;
    private static final int MIN_MATCH_SCORE_THRESHOLD = 1;
    
    @Autowired
    private HospDataMapper hospDataMapper;
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskController.java
@@ -11,6 +11,8 @@
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.domain.SysTaskEmergency;
import com.ruoyi.system.service.*;
import com.ruoyi.system.service.ILegacySystemSyncService;
import com.ruoyi.system.service.ITaskDispatchSyncService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
@@ -70,6 +72,12 @@
    @Autowired
    @Qualifier("tiandituMapService")
    private IMapService mapService;
    @Autowired
    private ILegacySystemSyncService legacySystemSyncService;
    @Autowired
    private ITaskDispatchSyncService taskDispatchSyncService;
    /**
     * 查询任务管理列表(后台管理端)
@@ -615,4 +623,85 @@
            this.actualEndTime = actualEndTime;
        }
    }
    /**
     * 手动同步服务单到旧系统
     * 当服务单同步失败或未同步时,可以通过此接口手动触发同步
     */
    @PreAuthorize("@ss.hasPermi('task:general:edit')")
    @Log(title = "手动同步服务单", businessType = BusinessType.UPDATE)
    @PostMapping("/syncServiceOrder/{taskId}")
    public AjaxResult syncServiceOrder(@PathVariable Long taskId) {
        try {
            // 查询任务信息
            SysTask task = sysTaskService.selectSysTaskByTaskId(taskId);
            if (task == null) {
                return error("任务不存在");
            }
            // 只支持急救转运任务
            if (!"EMERGENCY_TRANSFER".equals(task.getTaskType())) {
                return error("只有急救转运任务才能同步到旧系统");
            }
            // 调用同步服务
            Long serviceOrdId = legacySystemSyncService.syncEmergencyTaskToLegacy(taskId);
            if (serviceOrdId != null && serviceOrdId > 0) {
                return success("服务单同步成功,ServiceOrdID: " + serviceOrdId);
            } else {
                return error("服务单同步失败,请查看同步错误信息");
            }
        } catch (Exception e) {
            logger.error("手动同步服务单异常,taskId: {}", taskId, e);
            return error("同步异常: " + e.getMessage());
        }
    }
    /**
     * 手动同步调度单到旧系统
     * 当调度单同步失败或未同步时,可以通过此接口手动触发同步
     */
    @PreAuthorize("@ss.hasPermi('task:general:edit')")
    @Log(title = "手动同步调度单", businessType = BusinessType.UPDATE)
    @PostMapping("/syncDispatchOrder/{taskId}")
    public AjaxResult syncDispatchOrder(@PathVariable Long taskId) {
        try {
            // 查询任务信息
            SysTask task = sysTaskService.selectSysTaskByTaskId(taskId);
            if (task == null) {
                return error("任务不存在");
            }
            // 只支持急救转运任务
            if (!"EMERGENCY_TRANSFER".equals(task.getTaskType())) {
                return error("只有急救转运任务才能同步到旧系统");
            }
            // 查询急救转运扩展信息
            SysTaskEmergency emergency = sysTaskEmergencyService.selectSysTaskEmergencyByTaskId(taskId);
            if (emergency == null) {
                return error("急救转运扩展信息不存在");
            }
            // 必须先有服务单
            if (emergency.getLegacyServiceOrdId() == null || emergency.getLegacyServiceOrdId() <= 0) {
                return error("请先同步服务单");
            }
            // 调用同步服务
            Long dispatchOrdId = taskDispatchSyncService.syncDispatch(taskId);
            if (dispatchOrdId != null && dispatchOrdId > 0) {
                return success("调度单同步成功,DispatchOrdID: " + dispatchOrdId);
            } else {
                return error("调度单同步失败,请查看同步错误信息");
            }
        } catch (Exception e) {
            logger.error("手动同步调度单异常,taskId: {}", taskId, e);
            return error("同步异常: " + e.getMessage());
        }
    }
}
ruoyi-common/src/main/java/com/ruoyi/common/utils/HospitalTokenizerUtil.java
@@ -25,7 +25,7 @@
    private static final Set<String> STOP_WORDS = new HashSet<>(Arrays.asList(
        "医院", "诊所", "卫生", "镇", "乡", 
        "街道", "路", "号", "栋", "单元", "室", "层", "楼", "的", "了", 
        "在", "与", "和", "及", "等", "之", "于", "为", "有", "无"
        "在", "与", "和", "及", "等", "之", "于", "为", "有", "无","(",")","(",")","、",",","。","!","?",";",":","“","”","‘","’"
    ));
    
    /**
@@ -35,8 +35,10 @@
    private static final Set<String> HIGH_WEIGHT_WORDS = new HashSet<>(Arrays.asList(
        "人民", "中医", "中西医", "中西医结合", "医疗", "妇幼", "儿童", "肤科", 
        "口腔", "眼科", "骨科", "整形", "精神", "康复", "急救", "医学院", 
        "医科大学", "专科", "第一", "第二", "第三", "第四", "第五",
        "军区", "军医", "中心", "附属", "省立", "市立", "区立"
        "医科大学", "专科",
        "军区", "军医", "中心", "附属", "省立", "市立", "区立", "脑科", "总院", "慈善", "保健院", "口腔", "祈福", "眼科", "铁路", "附一", "附二", "附三", "附四", "附五", "附六",
            "第一", "第二", "第三", "第四", "第五", "第六", "第七", "第八", "第九", "第十",
            "肿瘤"
    ));
    /**
@@ -46,7 +48,7 @@
    private static final Set<String> HOSPITAL_KEYWORD_DICT = new HashSet<>(Arrays.asList(
        "中医院", "中医医院", "市医院", "省医院", "人民医院", "中心医院", "口腔医院",
        "华侨医院", "儿童医院", "眼科中心", "福利院", "门诊部", "中山大学", "附属医院",
        "孙逸仙"
        "孙逸仙","门诊"
    ));
    /** 组合词生成的最小字符长度 */
@@ -568,8 +570,11 @@
        
        // 分院特征关键词
        String[] branchKeywords = {
            "分院", "分部", "门诊部", "社区卫生", "卫生站", "卫生服务中心",
            "东院", "西院", "南院", "北院", "新院", "老院"
            "分院", "分部", "门诊部","门诊", "社区卫生", "卫生站", "卫生服务中心",
            "东院", "西院", "南院", "北院", "新院", "老院",
           "人民医院","附属医院","福利院","分院"
        };
        
        for (String keyword : branchKeywords) {
ruoyi-common/src/main/java/com/ruoyi/common/utils/image/ImageCompressUtil.java
New file
@@ -0,0 +1,374 @@
package com.ruoyi.common.utils.image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Iterator;
/**
 * 图片压缩工具类
 * 专为OCR识别优化,在保证文字清晰度的前提下压缩图片大小
 *
 * @author ruoyi
 * @date 2025-01-20
 */
public class ImageCompressUtil {
    private static final Logger log = LoggerFactory.getLogger(ImageCompressUtil.class);
    /**
     * 默认最大文件大小(10MB)
     */
    public static final long DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
    /**
     * OCR最佳识别分辨率(最小边像素)
     */
    public static final int OCR_OPTIMAL_MIN_SIZE = 1500;
    /**
     * 高质量压缩质量参数(0.90 = 90%质量)
     */
    public static final float HIGH_QUALITY = 0.90f;
    /**
     * 中等质量压缩质量参数(0.80 = 80%质量)
     */
    public static final float MEDIUM_QUALITY = 0.80f;
    /**
     * 低质量压缩质量参数(0.75 = 75%质量)
     */
    public static final float LOW_QUALITY = 0.75f;
    /**
     * 智能压缩图片(针对OCR优化)
     * 使用默认3MB限制
     *
     * @param file 上传的图片文件
     * @return 压缩后的临时文件
     * @throws IOException IO异常
     */
    public static File compressForOCR(MultipartFile file) throws IOException {
        return compressForOCR(file, DEFAULT_MAX_FILE_SIZE);
    }
    /**
     * 智能压缩图片(针对OCR优化)
     * 保证识别准确率的同时压缩到指定大小以下
     *
     * @param file 上传的图片文件
     * @param maxSize 最大文件大小(字节)
     * @return 压缩后的临时文件
     * @throws IOException IO异常
     */
    public static File compressForOCR(MultipartFile file, long maxSize) throws IOException {
        long fileSize = file.getSize();
        // 如果文件小于限制,直接保存
        if (fileSize <= maxSize) {
            log.debug("图片大小 {} KB 未超过限制 {} KB,无需压缩",
                fileSize / 1024.0, maxSize / 1024.0);
            return saveToTempFile(file);
        }
        log.info("图片大小 {} MB 超过限制 {} MB,开始智能压缩...",
            fileSize / 1024.0 / 1024.0, maxSize / 1024.0 / 1024.0);
        // 读取原始图片
        BufferedImage originalImage = ImageIO.read(file.getInputStream());
        if (originalImage == null) {
            throw new IOException("无法读取图片文件,可能格式不支持");
        }
        int originalWidth = originalImage.getWidth();
        int originalHeight = originalImage.getHeight();
        log.info("原始图片尺寸: {}x{}", originalWidth, originalHeight);
        // 策略1:先尝试高质量JPEG压缩(不改变尺寸)
        File compressedFile = compressWithQuality(originalImage, file.getOriginalFilename(), HIGH_QUALITY);
        if (compressedFile.length() <= maxSize) {
            log.info("压缩成功(高质量压缩),文件大小: {} MB",
                compressedFile.length() / 1024.0 / 1024.0);
            return compressedFile;
        }
        // 策略2:如果还是太大,适度缩小尺寸(保证OCR识别)
        double scaleFactor = calculateScaleFactor(originalWidth, originalHeight, maxSize, compressedFile.length());
        if (scaleFactor < 1.0) {
            int newWidth = (int) (originalWidth * scaleFactor);
            int newHeight = (int) (originalHeight * scaleFactor);
            log.info("调整图片尺寸至: {}x{} (缩放比例: {}%)",
                newWidth, newHeight, (int)(scaleFactor * 100));
            BufferedImage resizedImage = resizeImageHighQuality(originalImage, newWidth, newHeight);
            compressedFile.delete(); // 删除之前的临时文件
            compressedFile = compressWithQuality(resizedImage, file.getOriginalFilename(), HIGH_QUALITY);
        }
        // 策略3:如果还是太大,降低压缩质量(最后手段)
        if (compressedFile.length() > maxSize) {
            log.warn("尺寸调整后仍超限,降低压缩质量");
            BufferedImage finalImage;
            if (scaleFactor < 1.0) {
                int newWidth = (int) (originalWidth * scaleFactor);
                int newHeight = (int) (originalHeight * scaleFactor);
                finalImage = resizeImageHighQuality(originalImage, newWidth, newHeight);
            } else {
                finalImage = originalImage;
            }
            compressedFile.delete(); // 删除之前的临时文件
            // 尝试中等质量
            compressedFile = compressWithQuality(finalImage, file.getOriginalFilename(), MEDIUM_QUALITY);
            // 如果还不行,使用低质量
            if (compressedFile.length() > maxSize) {
                log.warn("使用低质量压缩(75%)");
                compressedFile.delete();
                compressedFile = compressWithQuality(finalImage, file.getOriginalFilename(), LOW_QUALITY);
            }
        }
        long finalSize = compressedFile.length();
        double compressionRatio = (1 - (double)finalSize / fileSize) * 100;
        log.info("最终压缩完成,文件大小: {} MB (压缩率: {}%)",
            finalSize / 1024.0 / 1024.0, (int)compressionRatio);
        if (finalSize > maxSize) {
            log.warn("警告:压缩后文件大小 {} MB 仍超过限制 {} MB",
                finalSize / 1024.0 / 1024.0, maxSize / 1024.0 / 1024.0);
        }
        return compressedFile;
    }
    /**
     * 使用指定质量压缩图片为JPEG格式
     *
     * @param image 图片对象
     * @param originalFilename 原始文件名
     * @param quality 压缩质量(0.0-1.0)
     * @return 压缩后的文件
     * @throws IOException IO异常
     */
    public static File compressWithQuality(BufferedImage image, String originalFilename, float quality) throws IOException {
        if (quality < 0.0f || quality > 1.0f) {
            throw new IllegalArgumentException("压缩质量必须在0.0到1.0之间");
        }
        // 转换为RGB格式(JPEG不支持透明度)
        BufferedImage rgbImage = convertToRGB(image);
        // 创建临时文件
        String tempDir = System.getProperty("java.io.tmpdir");
        String filename = System.currentTimeMillis() + "_compressed_" +
            getJpegFilename(originalFilename);
        File outputFile = new File(tempDir, filename);
        // 使用ImageWriter进行高质量压缩
        Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
        if (!writers.hasNext()) {
            throw new IOException("系统不支持JPEG编码");
        }
        ImageWriter writer = writers.next();
        ImageWriteParam param = writer.getDefaultWriteParam();
        // 设置压缩模式和质量
        param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
        param.setCompressionQuality(quality);
        try (ImageOutputStream ios = ImageIO.createImageOutputStream(outputFile)) {
            writer.setOutput(ios);
            writer.write(null, new IIOImage(rgbImage, null, null), param);
        } finally {
            writer.dispose();
        }
        log.debug("压缩完成,质量: {}%, 文件大小: {} KB",
            (int)(quality * 100), outputFile.length() / 1024.0);
        return outputFile;
    }
    /**
     * 高质量图片缩放(专为OCR优化)
     * 使用双三次插值算法保证文字清晰度
     *
     * @param originalImage 原始图片
     * @param targetWidth 目标宽度
     * @param targetHeight 目标高度
     * @return 缩放后的图片
     */
    public static BufferedImage resizeImageHighQuality(BufferedImage originalImage, int targetWidth, int targetHeight) {
        if (targetWidth <= 0 || targetHeight <= 0) {
            throw new IllegalArgumentException("目标宽度和高度必须大于0");
        }
        BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
        Graphics2D g2d = resizedImage.createGraphics();
        try {
            // 设置高质量渲染参数(关键!)
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
            g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null);
        } finally {
            g2d.dispose();
        }
        return resizedImage;
    }
    /**
     * 通用图片压缩(按目标宽高)
     *
     * @param file 上传的图片文件
     * @param targetWidth 目标宽度
     * @param targetHeight 目标高度
     * @param quality 压缩质量(0.0-1.0)
     * @return 压缩后的文件
     * @throws IOException IO异常
     */
    public static File compress(MultipartFile file, int targetWidth, int targetHeight, float quality) throws IOException {
        BufferedImage originalImage = ImageIO.read(file.getInputStream());
        if (originalImage == null) {
            throw new IOException("无法读取图片文件");
        }
        BufferedImage resizedImage = resizeImageHighQuality(originalImage, targetWidth, targetHeight);
        return compressWithQuality(resizedImage, file.getOriginalFilename(), quality);
    }
    /**
     * 按比例压缩图片
     *
     * @param file 上传的图片文件
     * @param scale 缩放比例(0.0-1.0)
     * @param quality 压缩质量(0.0-1.0)
     * @return 压缩后的文件
     * @throws IOException IO异常
     */
    public static File compressByScale(MultipartFile file, double scale, float quality) throws IOException {
        if (scale <= 0.0 || scale > 1.0) {
            throw new IllegalArgumentException("缩放比例必须在0.0到1.0之间");
        }
        BufferedImage originalImage = ImageIO.read(file.getInputStream());
        if (originalImage == null) {
            throw new IOException("无法读取图片文件");
        }
        int newWidth = (int) (originalImage.getWidth() * scale);
        int newHeight = (int) (originalImage.getHeight() * scale);
        BufferedImage resizedImage = resizeImageHighQuality(originalImage, newWidth, newHeight);
        return compressWithQuality(resizedImage, file.getOriginalFilename(), quality);
    }
    /**
     * 计算最佳缩放比例
     *
     * @param width 原始宽度
     * @param height 原始高度
     * @param targetSize 目标文件大小
     * @param currentSize 当前文件大小
     * @return 缩放比例
     */
    private static double calculateScaleFactor(int width, int height, long targetSize, long currentSize) {
        int minDimension = Math.min(width, height);
        double scaleFactor = 1.0;
        // 基于文件大小估算缩放比例
        double sizeRatio = Math.sqrt((double) targetSize / currentSize);
        // 保护最小尺寸(保证OCR识别)
        if (minDimension > 2000) {
            // 大图片,可以适度缩小
            scaleFactor = Math.min(sizeRatio, 2000.0 / minDimension);
        } else if (minDimension > OCR_OPTIMAL_MIN_SIZE) {
            // 中等图片,轻微缩小
            scaleFactor = Math.min(sizeRatio, (double) OCR_OPTIMAL_MIN_SIZE / minDimension);
        } else {
            // 小图片,尽量不缩小
            scaleFactor = Math.min(sizeRatio, 0.95);
        }
        return scaleFactor;
    }
    /**
     * 将图片转换为RGB格式
     *
     * @param image 原始图片
     * @return RGB格式图片
     */
    private static BufferedImage convertToRGB(BufferedImage image) {
        if (image.getType() == BufferedImage.TYPE_INT_RGB) {
            return image;
        }
        BufferedImage rgbImage = new BufferedImage(
            image.getWidth(),
            image.getHeight(),
            BufferedImage.TYPE_INT_RGB
        );
        Graphics2D g = rgbImage.createGraphics();
        try {
            g.drawImage(image, 0, 0, null);
        } finally {
            g.dispose();
        }
        return rgbImage;
    }
    /**
     * 将文件名转换为JPEG格式
     *
     * @param originalFilename 原始文件名
     * @return JPEG文件名
     */
    private static String getJpegFilename(String originalFilename) {
        if (originalFilename == null || originalFilename.isEmpty()) {
            return "image.jpg";
        }
        return originalFilename.replaceAll("\\.(png|PNG|gif|GIF|bmp|BMP|webp|WEBP)$", ".jpg");
    }
    /**
     * 保存MultipartFile到临时文件
     *
     * @param file 上传的文件
     * @return 临时文件
     * @throws IOException IO异常
     */
    private static File saveToTempFile(MultipartFile file) throws IOException {
        String tempDir = System.getProperty("java.io.tmpdir");
        String originalFilename = file.getOriginalFilename();
        File tempFile = new File(tempDir, System.currentTimeMillis() + "_" + originalFilename);
        file.transferTo(tempFile);
        return tempFile;
    }
}
ruoyi-common/src/main/java/com/ruoyi/common/utils/image/package-info.java
New file
@@ -0,0 +1,63 @@
/**
 * 图片处理工具包
 *
 * <h2>主要功能</h2>
 * <ul>
 *   <li>智能图片压缩(专为OCR优化)</li>
 *   <li>高质量图片缩放</li>
 *   <li>图片格式转换</li>
 * </ul>
 *
 * <h2>使用示例</h2>
 *
 * <h3>1. OCR图片智能压缩(推荐)</h3>
 * <pre>
 * // 自动压缩到3MB以下,保证OCR识别准确率
 * File compressedFile = ImageCompressUtil.compressForOCR(multipartFile);
 *
 * // 自定义大小限制
 * File compressedFile = ImageCompressUtil.compressForOCR(multipartFile, 5 * 1024 * 1024); // 5MB
 * </pre>
 *
 * <h3>2. 按尺寸压缩</h3>
 * <pre>
 * // 压缩到指定宽高
 * File compressedFile = ImageCompressUtil.compress(multipartFile, 1920, 1080, 0.85f);
 * </pre>
 *
 * <h3>3. 按比例压缩</h3>
 * <pre>
 * // 缩小到原来的50%
 * File compressedFile = ImageCompressUtil.compressByScale(multipartFile, 0.5, 0.90f);
 * </pre>
 *
 * <h3>4. 高质量图片缩放</h3>
 * <pre>
 * BufferedImage originalImage = ImageIO.read(file);
 * BufferedImage resizedImage = ImageCompressUtil.resizeImageHighQuality(originalImage, 800, 600);
 * </pre>
 *
 * <h3>5. 自定义质量压缩</h3>
 * <pre>
 * BufferedImage image = ImageIO.read(file);
 * File compressed = ImageCompressUtil.compressWithQuality(image, "photo.jpg", 0.80f); // 80%质量
 * </pre>
 *
 * <h2>压缩质量常量</h2>
 * <ul>
 *   <li>{@code ImageCompressUtil.HIGH_QUALITY} - 0.90(高质量,推荐OCR使用)</li>
 *   <li>{@code ImageCompressUtil.MEDIUM_QUALITY} - 0.80(中等质量)</li>
 *   <li>{@code ImageCompressUtil.LOW_QUALITY} - 0.75(低质量)</li>
 * </ul>
 *
 * <h2>注意事项</h2>
 * <ul>
 *   <li>压缩后的文件保存在系统临时目录,使用完毕后需手动删除</li>
 *   <li>OCR压缩会保证最小边不小于1500px,确保文字清晰</li>
 *   <li>支持PNG、BMP、GIF等格式自动转为JPEG</li>
 * </ul>
 *
 * @author ruoyi
 * @since 2025-01-20
 */
package com.ruoyi.common.utils.image;
ruoyi-system/src/main/java/com/ruoyi/system/controller/OCRController.java
@@ -4,6 +4,7 @@
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.common.utils.image.ImageCompressUtil;
import com.ruoyi.system.utils.AliOCRUtil;
import com.ruoyi.system.utils.BaiduOCRUtil;
import com.ruoyi.system.utils.TencentOCRUtil;
@@ -55,11 +56,8 @@
                return error("不支持的识别类型: " + type + ", 支持的类型: " + String.join(",", SUPPORTED_TYPES));
            }
            // 保存临时文件
            String tempDir = System.getProperty("java.io.tmpdir");
            String originalFilename = file.getOriginalFilename();
            File tempFile = new File(tempDir, System.currentTimeMillis() + "_" + originalFilename);
            file.transferTo(tempFile);
            // 智能压缩图片(自动处理超过3MB的图片)
            File tempFile = ImageCompressUtil.compressForOCR(file);
            // 根据提供商调用不同的OCR服务
            JSONObject ocrResult;
@@ -92,7 +90,10 @@
            // 构建返回结果
            Map<String, Object> result = new HashMap<>();
            result.put("ocrResult", ocrResult);
            result.put("fileName", originalFilename);
            result.put("fileName", file.getOriginalFilename());
            result.put("originalSize", file.getSize());
            result.put("processedSize", tempFile.length());
            result.put("compressed", file.getSize() > tempFile.length());
            result.put("type", type);
            result.put("provider", provider);
@@ -263,11 +264,8 @@
                return error("上传图片不能为空");
            }
            // 保存临时文件
            String tempDir = System.getProperty("java.io.tmpdir");
            String originalFilename = file.getOriginalFilename();
            File tempFile = new File(tempDir, System.currentTimeMillis() + "_" + originalFilename);
            file.transferTo(tempFile);
            // 智能压缩图片(自动处理超过3MB的图片)
            File tempFile = ImageCompressUtil.compressForOCR(file);
            // 调用腾讯云手写体识别
            Map<String, String> resultMap = TencentOCRUtil.handwritingRecognizeWith(tempFile.getAbsolutePath(), itemNames);
@@ -282,7 +280,7 @@
            // 构建返回结果
            Map<String, Object> result = new HashMap<>();
            result.put("fileName", originalFilename);
            result.put("fileName", file.getOriginalFilename());
            result.put("type", "HandWriting");
            result.put("provider", "tencent");
            result.put("fields", resultMap);
@@ -356,11 +354,8 @@
                }
                try {
                    // 保存临时文件
                    String tempDir = System.getProperty("java.io.tmpdir");
                    String originalFilename = file.getOriginalFilename();
                    File tempFile = new File(tempDir, System.currentTimeMillis() + "_" + originalFilename);
                    file.transferTo(tempFile);
                    // 智能压缩图片(自动处理超过3MB的图片)
                    File tempFile = ImageCompressUtil.compressForOCR(file);
                    // 调用腾讯云手写体识别
                    Map<String, String> resultMap = TencentOCRUtil.handwritingRecognizeWith(tempFile.getAbsolutePath(), itemNames);
@@ -371,8 +366,8 @@
                    // 检查是否有错误
                    if (resultMap.containsKey("error")) {
                        failCount++;
                        errorMessages.append(originalFilename).append(":").append(resultMap.get("error")).append("; ");
                        logger.warn("图片 {} 识别失败: {}", originalFilename, resultMap.get("error"));
                        errorMessages.append(file.getOriginalFilename()).append(":").append(resultMap.get("error")).append("; ");
                        logger.warn("图片 {} 识别失败: {}", file.getOriginalFilename(), resultMap.get("error"));
                    } else {
                        // 合并识别结果(如果key已存在,不覆盖)
                        for (Map.Entry<String, String> entry : resultMap.entrySet()) {
@@ -381,7 +376,7 @@
                            }
                        }
                        successCount++;
                        logger.info("图片 {} 识别成功,提取 {} 个字段", originalFilename, resultMap.size());
                        logger.info("图片 {} 识别成功,提取 {} 个字段", file.getOriginalFilename(), resultMap.size());
                    }
                } catch (Exception e) {
                    failCount++;
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/UserSyncServiceImpl.java
@@ -65,7 +65,7 @@
                return AjaxResult.warn("传入的用户数据为空");
            }
            
            log.info("开始同步 {} 条OA用户数据到 MySQL 数据库...", oaUsers.size());
//            log.info("开始同步 {} 条OA用户数据到 MySQL 数据库...", oaUsers.size());
            
            int createdCount = 0;
            int updatedCount = 0;
@@ -109,8 +109,8 @@
                        // 用户已存在,更新信息
                        updateExistingUser(existingUser, dto, deptId);
                        updatedCount++;
                        log.info("更新用户: {} ({}), oaUserId: {}",
                            dto.getNickName(), dto.getUserName(), dto.getOaUserId());
//                        log.info("更新用户: {} ({}), oaUserId: {}",
//                            dto.getNickName(), dto.getUserName(), dto.getOaUserId());
                    }
                    else
                    {
@@ -140,16 +140,16 @@
                            userByName.setUpdateBy("sync");
                            sysUserMapper.updateUser(userByName);
                            updatedCount++;
                            log.info("更新已存在用户名的用户: {} ({}), 设置oaUserId: {}",
                                dto.getNickName(), dto.getUserName(), dto.getOaUserId());
//                            log.info("更新已存在用户名的用户: {} ({}), 设置oaUserId: {}",
//                                dto.getNickName(), dto.getUserName(), dto.getOaUserId());
                        }
                        else
                        {
                            // 创建新用户
                            createNewUser(dto, deptId);
                            createdCount++;
                            log.info("创建新用户: {} ({}), oaUserId: {}, deptId: {}",
                                dto.getNickName(), dto.getUserName(), dto.getOaUserId(), deptId);
//                            log.info("创建新用户: {} ({}), oaUserId: {}, deptId: {}",
//                                dto.getNickName(), dto.getUserName(), dto.getOaUserId(), deptId);
                        }
                    }
                }
@@ -163,7 +163,7 @@
            String message = String.format("同步完成!创建用户: %d, 更新用户: %d, 跳过: %d, 失败: %d",
                createdCount, updatedCount, skippedCount, errorCount);
            log.info(message);
//            log.info(message);
            Map<String, Object> result = new HashMap<>();
            result.put("created", createdCount);
ruoyi-system/src/main/java/com/ruoyi/system/utils/TencentOCRUtil.java
@@ -18,10 +18,8 @@
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.Base64;
import java.util.*;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
/**
 * 腾讯云OCR工具类
@@ -222,7 +220,7 @@
            // {"患者签名(手印)", "签字人身份证号码", "日期", "联系电话", "本人", "签字人与患者关系"}
            req.setItemNames(itemNames != null ? itemNames : new String[]{"患者姓名", "性别", "年龄", "身份证号", "诊断", "需支付转运费用", "行程", "开始时间", "结束时间", "家属签名"});
            req.setOutputLanguage("cn");
            req.setReturnFullText(false);
            req.setReturnFullText(true);
            req.setItemNamesShowMode(false);
            ExtractDocMultiResponse resp = client.ExtractDocMulti(req);
@@ -295,6 +293,41 @@
                    }
                }
            }
            //将 WordList
            List<String> wordListResult = new ArrayList<>();
            if (responseData.containsKey("WordList") && responseData.getJSONArray("WordList") != null){
                JSONArray wordList = responseData.getJSONArray("WordList");
                for (int i = 0; i < wordList.size(); i++) {
                    JSONObject word = wordList.getJSONObject(i);
                    // {
                    //        "Coord": {
                    //          "LeftBottom": {
                    //            "X": 472,
                    //            "Y": 1500
                    //          },
                    //          "LeftTop": {
                    //            "X": 467,
                    //            "Y": 1420
                    //          },
                    //          "RightBottom": {
                    //            "X": 636,
                    //            "Y": 1490
                    //          },
                    //          "RightTop": {
                    //            "X": 631,
                    //            "Y": 1410
                    //          }
                    //        },
                    //        "DetectedText": "行程:"
                    //      }
                    String detectedText = word.getString("DetectedText");
                    wordListResult.add(detectedText);
                }
            }
            //我们从wordListResult中行程:后面,需要支付转运费用:之间的文字
            String content = extractContentFromWordList(wordListResult, "行程:", "需支付转运费用:");
            log.info("提取到行程: {}", content);
            resultMap.put("行程", content);
            
            log.info("手写体识别提取到 {} 个字段", resultMap.size());
            return resultMap;
@@ -306,6 +339,42 @@
        }
    }
    private static String extractContentFromWordList(List<String> wordListResult, String s, String s1) {
        //提取s和s1之间的内容
        //如果word中只有一或-或->,统一处理成->
        int startIndex = -1;
        int endIndex = -1;
        for (int i = 0; i < wordListResult.size(); i++) {
            String word = wordListResult.get(i);
            if (word.contains(s)) {
                startIndex = i;
            }
            if (word.contains(s1)) {
                endIndex = i;
            }
        }
        if (startIndex == -1 || endIndex == -1 || startIndex >= endIndex) {
            return "";
        }
        List<String> w=wordListResult.subList(startIndex + 1, endIndex);
        Boolean findAle=false;
        List<String> result=new ArrayList<>();
       for(String word:w){
            if (!findAle && (word.equals("-") || word.equals("->") || word.equals("→") || word.equals("一") || word.equals("=>")) ){
                findAle = true;
                word = word.replace("-", "→")
                        .replace("一", "→")
                        .replace("=>", "→");
            }
            result.add(word);
        };
        return String.join("", result);
    }
    /**
     * 身份证识别
     * @param imagePath 图片路径
ruoyi-system/src/main/resources/mapper/system/SysTaskMapper.xml
@@ -354,4 +354,33 @@
          and e.patient_phone = #{phone}
          and DATE(t.create_time) = #{createDate}
    </select>
    <!-- 查询车辆在指定时间范围内的任务列表 -->
    <select id="selectVehicleTasksInTimeRange" parameterType="map" resultMap="SysTaskResult">
        select t.task_id, t.task_code, t.task_type, t.task_status,
               t.departure_address, t.destination_address,
               t.actual_start_time, t.actual_end_time,
               t.planned_start_time, t.planned_end_time,
               t.estimated_distance,
               tv.vehicle_id
        from sys_task t
        inner join sys_task_vehicle tv on t.task_id = tv.task_id
        where tv.vehicle_id = #{vehicleId}
          and t.del_flag = '0'
          and t.task_status not in ('CANCELLED')
          and (
              <!-- 实际时间有值时,使用实际时间判断重叠 -->
              (t.actual_start_time is not null and t.actual_end_time is not null
               and t.actual_start_time &lt;= #{endTime} and t.actual_end_time &gt;= #{startTime})
              or
              <!-- 实际开始时间有值但未结束时,使用当前时间作为结束时间 -->
              (t.actual_start_time is not null and t.actual_end_time is null
               and t.actual_start_time &lt;= #{endTime})
              or
              <!-- 实际时间都为空时,使用计划时间判断重叠 -->
              (t.actual_start_time is null and t.actual_end_time is null
               and t.planned_start_time &lt;= #{endTime} and t.planned_end_time &gt;= #{startTime})
          )
        order by t.actual_start_time, t.planned_start_time
    </select>
</mapper>
ruoyi-ui/src/api/task.js
@@ -285,3 +285,21 @@
    params: { taskId }
  })
}
// ========== 旧系统同步相关API ==========
// 手动同步服务单到旧系统
export function syncServiceOrder(taskId) {
  return request({
    url: '/task/syncServiceOrder/' + taskId,
    method: 'post'
  })
}
// 手动同步调度单到旧系统
export function syncDispatchOrder(taskId) {
  return request({
    url: '/task/syncDispatchOrder/' + taskId,
    method: 'post'
  })
}
ruoyi-ui/src/views/task/general/detail.vue
@@ -74,6 +74,16 @@
            <i class="el-icon-error"></i> 同步失败
          </el-tag>
          <span v-else style="color: #C0C4CC;">--</span>
          <!-- 未同步或同步失败时显示同步按钮 -->
          <el-button
            v-if="taskDetail.emergencyInfo.syncStatus === 0 || taskDetail.emergencyInfo.syncStatus === 3"
            type="primary"
            size="mini"
            icon="el-icon-refresh"
            :loading="syncingServiceOrder"
            @click="syncServiceOrder"
            style="margin-left: 10px;"
          >同步服务单</el-button>
        </el-descriptions-item>
        <el-descriptions-item label="服务单号">
          <span v-if="taskDetail.emergencyInfo.legacyServiceOrdId">
@@ -109,6 +119,16 @@
            <i class="el-icon-error"></i> 同步失败
          </el-tag>
          <span v-else style="color: #C0C4CC;">--</span>
          <!-- 未同步或同步失败时显示同步按钮 -->
          <el-button
            v-if="taskDetail.emergencyInfo.dispatchSyncStatus === 0 || taskDetail.emergencyInfo.dispatchSyncStatus === 3"
            type="primary"
            size="mini"
            icon="el-icon-refresh"
            :loading="syncingDispatchOrder"
            @click="syncDispatchOrder"
            style="margin-left: 10px;"
          >同步调度单</el-button>
        </el-descriptions-item>
        <el-descriptions-item label="调度单号">
          <span v-if="taskDetail.emergencyInfo.legacyDispatchOrdId">
@@ -738,7 +758,7 @@
</template>
<script>
import { getTask, updateTask, assignTask, changeTaskStatus, uploadAttachment, deleteAttachment, getTaskVehicles, getAvailableVehicles, assignVehiclesToTask, unassignVehicleFromTask, getPaymentInfo } from "@/api/task";
import { getTask, updateTask, assignTask, changeTaskStatus, uploadAttachment, deleteAttachment, getTaskVehicles, getAvailableVehicles, assignVehiclesToTask, unassignVehicleFromTask, getPaymentInfo, syncServiceOrder, syncDispatchOrder } from "@/api/task";
import { listUser } from "@/api/system/user";
import { getToken } from "@/utils/auth";
@@ -827,7 +847,10 @@
        category: [
          { required: true, message: "业务分类不能为空", trigger: "change" }
        ]
      }
      },
      // 同步加载状态
      syncingServiceOrder: false,
      syncingDispatchOrder: false
    };
  },
  created() {
@@ -1125,6 +1148,36 @@
      if (!fileType) return false;
      const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
      return imageTypes.includes(fileType.toLowerCase());
    },
    /** 手动同步服务单 */
    syncServiceOrder() {
      this.$modal.confirm('是否确认同步服务单到旧系统?').then(() => {
        this.syncingServiceOrder = true;
        return syncServiceOrder(this.taskDetail.taskId);
      }).then(() => {
        this.$modal.msgSuccess("服务单同步成功");
        // 重新加载任务详情
        this.getDetail();
      }).catch(() => {
        // 处理取消和错误
      }).finally(() => {
        this.syncingServiceOrder = false;
      });
    },
    /** 手动同步调度单 */
    syncDispatchOrder() {
      this.$modal.confirm('是否确认同步调度单到旧系统?').then(() => {
        this.syncingDispatchOrder = true;
        return syncDispatchOrder(this.taskDetail.taskId);
      }).then(() => {
        this.$modal.msgSuccess("调度单同步成功");
        // 重新加载任务详情
        this.getDetail();
      }).catch(() => {
        // 处理取消和错误
      }).finally(() => {
        this.syncingDispatchOrder = false;
      });
    }
  }
};