wlzboy
3 天以前 40a8157440e3b906da8f52e07d939d78c3f4c313
feat: 任务增加统计、同步增加通知
25个文件已修改
7个文件已添加
1655 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskStatController.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application-dev.yml 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application-prod.yml 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DeptOrderStatVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/listener/TaskMessageListener.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/NotifyTaskMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTaskMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/INotifyTaskService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTaskService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/LegacyTransferSyncServiceImpl.java 216 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/NotifyDispatchServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/NotifyTaskServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTaskServiceImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/NotifyTaskMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysTaskMapper.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/dept.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/gps.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/user.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/task.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/router/index.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/gps/trackMap.vue 570 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/user/index.vue 83 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/task/stat/index.vue 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/vue.config.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/dept_order_stat_menu.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_gps_track_map_menu.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
test/1.html 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
@@ -74,6 +74,18 @@
    }
    /**
     * 获取所有分公司列表(parentId=100的部门)
     */
    @GetMapping("/branch/all")
    public AjaxResult listAllBranches()
    {
        SysDept query = new SysDept();
        query.setParentId(100L);
        List<SysDept> depts = deptService.selectDeptList(query);
        return success(depts);
    }
    /**
     * 查询部门列表(排除节点)
     */
    @GetMapping("/list/exclude/{deptId}")
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java
@@ -350,4 +350,48 @@
            return error("未找到对应的用户信息");
        }
    }
    /**
     * 更新用户可管理分公司(通过oaOrderClass字段保存编码列表)
     * 接收选中的分公司deptId列表,查询对应编码后合并写入oaOrderClass
     */
    @PreAuthorize("@ss.hasPermi('system:user:edit')")
    @Log(title = "用户管理-分公司配置", businessType = BusinessType.UPDATE)
    @PutMapping("/branch/{userId}")
    public AjaxResult updateUserBranch(@PathVariable Long userId, @RequestBody java.util.List<Long> deptIds)
    {
        userService.checkUserDataScope(userId);
        SysUser user = userService.selectUserById(userId);
        if (user == null) {
            return error("用户不存在");
        }
        // 根据deptIds查询分公司信息,收集编码
        java.util.Set<String> codeSet = new java.util.LinkedHashSet<>();
        if (deptIds != null && !deptIds.isEmpty()) {
            SysDept queryDept = new SysDept();
            queryDept.setParentId(100L);
            List<SysDept> allBranches = deptService.selectDeptList(queryDept);
            java.util.Map<Long, SysDept> deptMap = new java.util.HashMap<>();
            for (SysDept d : allBranches) {
                deptMap.put(d.getDeptId(), d);
            }
            for (Long deptId : deptIds) {
                SysDept dept = deptMap.get(deptId);
                if (dept != null) {
                    if (StringUtils.isNotEmpty(dept.getServiceOrderClass())) {
                        codeSet.add(dept.getServiceOrderClass().trim());
                    }
                    if (StringUtils.isNotEmpty(dept.getDispatchOrderClass())) {
                        codeSet.add(dept.getDispatchOrderClass().trim());
                    }
                }
            }
        }
        String newOaOrderClass = String.join(",", codeSet);
        SysUser updateUser = new SysUser();
        updateUser.setUserId(userId);
        updateUser.setOaOrderClass(newOaOrderClass);
        updateUser.setUpdateBy(getUsername());
        return toAjax(userService.updateUserProfile(updateUser));
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java
@@ -241,7 +241,7 @@
    }
    /**
     * 查询车辆历史轨迹
     * 查询车辆历史轨迹(调用GPS平台接口)
     */
    @PreAuthorize("@ss.hasPermi('system:gps:query')")
    @GetMapping("/tracks")
@@ -249,6 +249,35 @@
        return getAnonymousTracks(vehicleNo, beginTime, endTime);
    }
    /**
     * 从本地数据库查询车辆行驶轨迹(天地图轨迹页使用)
     * 支持车牌号模糊查询 + 时间范围精确查询
     */
    @PreAuthorize("@ss.hasPermi('system:gps:list')")
    @GetMapping("/tracksByPlate")
    public TableDataInfo getTracksByPlate(String vehicleNo, String beginTime, String endTime) {
        try {
            if (vehicleNo == null || vehicleNo.trim().isEmpty()) {
                return getDataTable(new ArrayList<>());
            }
            VehicleGps query = new VehicleGps();
            query.setVehicleNo(vehicleNo.trim());
            if (beginTime != null && !beginTime.isEmpty()) {
                query.setBeginTime(beginTime.replace("T", " "));
            }
            if (endTime != null && !endTime.isEmpty()) {
                query.setEndTime(endTime.replace("T", " "));
            }
            query.setOrderByColumn("collect_time");
            query.setIsAsc("asc");
            List<VehicleGps> list = vehicleGpsService.selectVehicleGpsList(query);
            return getDataTable(list);
        } catch (Exception e) {
            logger.error("从数据库查询车辆轨迹失败", e);
            return getDataTable(new ArrayList<>());
        }
    }
   
    /**
     * 匿名查询车辆历史轨迹
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskStatController.java
New file
@@ -0,0 +1,77 @@
package com.ruoyi.web.controller.task;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.domain.vo.DeptOrderStatVO;
import com.ruoyi.system.service.ISysTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
/**
 * 分公司录单统计 Controller
 */
@RestController
@RequestMapping("/task/stat")
public class SysTaskStatController extends BaseController {
    @Autowired
    private ISysTaskService sysTaskService;
    /**
     * 按分公司、时间范围查询每天录单统计
     *
     * @param deptIds   分公司ID列表,逗号分隔,为空则查全部
     * @param startDate 开始日期 yyyy-MM-dd
     * @param endDate   结束日期 yyyy-MM-dd
     */
    @PreAuthorize("@ss.hasPermi('task:stat:query')")
    @GetMapping("/deptOrder")
    public AjaxResult deptOrderStat(
            @RequestParam(required = false) String deptIds,
            @RequestParam String startDate,
            @RequestParam String endDate) {
        List<Long> deptIdList = parseDeptIds(deptIds);
        List<DeptOrderStatVO> list = sysTaskService.selectDeptOrderStat(deptIdList, startDate, endDate);
        return AjaxResult.success(list);
    }
    /**
     * 导出 Excel
     */
    @PreAuthorize("@ss.hasPermi('task:stat:export')")
    @GetMapping("/deptOrder/export")
    public void exportDeptOrderStat(
            HttpServletResponse response,
            @RequestParam(required = false) String deptIds,
            @RequestParam String startDate,
            @RequestParam String endDate) {
        List<Long> deptIdList = parseDeptIds(deptIds);
        List<DeptOrderStatVO> list = sysTaskService.selectDeptOrderStat(deptIdList, startDate, endDate);
        ExcelUtil<DeptOrderStatVO> util = new ExcelUtil<>(DeptOrderStatVO.class);
        util.exportExcel(response, list, "分公司录单统计");
    }
    /** 解析逗号分隔的 deptIds 字符串为 Long 列表 */
    private List<Long> parseDeptIds(String deptIds) {
        List<Long> result = new ArrayList<>();
        if (deptIds == null || deptIds.trim().isEmpty()) {
            return result;
        }
        for (String id : deptIds.split(",")) {
            try {
                result.add(Long.parseLong(id.trim()));
            } catch (NumberFormatException ignored) {
            }
        }
        return result;
    }
}
ruoyi-admin/src/main/resources/application-dev.yml
@@ -12,12 +12,16 @@
            # 从库数据源
            # SQL Server数据源
            sqlserver:
                url: jdbc:sqlserver://120.25.98.119:1432;databaseName=came
                url: jdbc:sqlserver://120.25.98.119:1432;databaseName=came;loginTimeout=60;queryTimeout=300
                username: camesa
                password: camesa
                driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
                enabled: true
                validationQuery: SELECT 1
                # 连接超时时间(毫秒):获取连接最长等待时间,60秒
                connectTimeout: 60000
                # Socket读取超时(毫秒):SQL执行最长等待时间,5分钟
                socketTimeout: 300000
            slave:
                # 从数据源开关/默认关闭
                enabled: false
ruoyi-admin/src/main/resources/application-prod.yml
@@ -12,12 +12,16 @@
      # 从库数据源
      # SQL Server数据源
      sqlserver:
        url: jdbc:sqlserver://39.108.160.52;databaseName=came
        url: jdbc:sqlserver://39.108.160.52;databaseName=came;loginTimeout=60;queryTimeout=300
        username: camesa
        password: camesa
        driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
        enabled: true
        validationQuery: SELECT 1
        # 连接超时时间(毫秒):获取连接最长等待时间,改为60秒
        connectTimeout: 60000
        # Socket读取超时(毫秒):SQL执行最长等待时间,改为5分钟
        socketTimeout: 300000
      slave:
        # 从数据源开关/默认关闭
        enabled: false
ruoyi-admin/src/main/resources/application.yml
@@ -58,7 +58,7 @@
    basename: i18n/messages
  profiles:
    # 环境 dev|test|prod
    active: dev
    active: prod
  # 文件上传
  servlet:
    multipart:
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DeptOrderStatVO.java
New file
@@ -0,0 +1,36 @@
package com.ruoyi.system.domain.vo;
import com.ruoyi.common.annotation.Excel;
/**
 * 分公司每日录单统计 VO
 */
public class DeptOrderStatVO {
    /** 分公司ID */
    private Long deptId;
    /** 分公司名称 */
    @Excel(name = "分公司")
    private String deptName;
    /** 统计日期,格式 yyyy-MM-dd */
    @Excel(name = "日期")
    private String statDate;
    /** 录单数量 */
    @Excel(name = "录单数量")
    private Integer orderCount;
    public Long getDeptId() { return deptId; }
    public void setDeptId(Long deptId) { this.deptId = deptId; }
    public String getDeptName() { return deptName; }
    public void setDeptName(String deptName) { this.deptName = deptName; }
    public String getStatDate() { return statDate; }
    public void setStatDate(String statDate) { this.statDate = statDate; }
    public Integer getOrderCount() { return orderCount; }
    public void setOrderCount(Integer orderCount) { this.orderCount = orderCount; }
}
ruoyi-system/src/main/java/com/ruoyi/system/listener/TaskMessageListener.java
@@ -1,6 +1,7 @@
package com.ruoyi.system.listener;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.config.WechatConfig;
import com.ruoyi.common.utils.DeptUtil;
import com.ruoyi.common.utils.LongUtil;
import com.ruoyi.system.domain.*;
@@ -56,6 +57,12 @@
    @Autowired
    private INotifyDispatchService notifyDispatchService;
    @Autowired
    private IQyWechatService qyWechatService;
    @Autowired
    private WechatConfig wechatConfig;
    /** 待准备状态 - 可以发送短信通知 */
    private static final String TASK_STATUS_PENDING = "PENDING";
@@ -266,6 +273,7 @@
    /**
     * 监听任务分配事件
     * 创建通知任务,由通知分发服务决定发送渠道
     * 同时直接发送企业微信通知
     * 
     * @param event 任务分配事件
     */
@@ -295,15 +303,18 @@
            Long creatorId = task.getCreatorId();
            String taskStatus = task.getTaskStatus();
            task.setEmergencyInfo(emergency);
            // 仅在待准备状态下发送通知
            if (!TASK_STATUS_PENDING.equals(taskStatus) && !TASK_STATUS_PREPARING.equals(taskStatus)) {
                log.info("任务状态({})非待准备状态,跳过通知,taskId={}", taskStatus, event.getTaskId());
                return;
            }
            // 构建通知内容
            String notifyContent = buildNotifyContent(task, emergency);
            // 直接发送企业微信通知给执行人员
            sendQyWechatNotifyToAssignees(event.getAssigneeIds(), creatorId, event.getTaskId(), notifyContent);
            // 同时走原有通知分发流程(站内消息等)
            // 仅在待准备状态下发送其他通知
            if (TASK_STATUS_PENDING.equals(taskStatus) || TASK_STATUS_PREPARING.equals(taskStatus)) {
            this.sendDispatchNotify(event.getAssigneeIds(), creatorId, event.getTaskId(),task.getShowTaskCode(), notifyContent);
            }
            
        } catch (Exception e) {
@@ -312,6 +323,44 @@
    }
    /**
     * 直接发送企业微信通知给执行人员
     */
    private void sendQyWechatNotifyToAssignees(List<Long> assigneeIds, Long creatorId, Long taskId, String content) {
        String appId = wechatConfig.getAppId();
        String pathPage = "/pagesTask/detail?id=" + taskId;
        int successCount = 0;
        for (Long assigneeId : assigneeIds) {
            // 排除创建人
            if (creatorId != null && creatorId.equals(assigneeId)) {
                log.debug("跳过创建人,不发送企业微信通知,userId={}", assigneeId);
                continue;
            }
            try {
                boolean success = qyWechatService.sendNotifyMessage(
                        assigneeId,
                        "转运单任务派单通知",
                        content,
                        appId,
                        pathPage
                );
                if (success) {
                    successCount++;
                    log.info("企业微信派单通知发送成功,taskId={}, userId={}", taskId, assigneeId);
                } else {
                    log.warn("企业微信派单通知发送失败,taskId={}, userId={}", taskId, assigneeId);
                }
            } catch (Exception e) {
                log.error("企业微信派单通知发送异常,taskId={}, userId={}", taskId, assigneeId, e);
            }
        }
        log.info("企业微信派单通知发送完成,taskId={}, 成功数量={}/{}", taskId, successCount, assigneeIds.size());
    }
    /**
     * 向执行人发送任务分配通知
     * @param assigneeIds
     * @param creatorId
ruoyi-system/src/main/java/com/ruoyi/system/mapper/NotifyTaskMapper.java
@@ -47,6 +47,15 @@
    int countByTaskUserType(@Param("taskId") Long taskId, @Param("userId") Long userId, @Param("notifyType") String notifyType);
    /**
     * 按taskId和通知类型查询所有通知任务
     *
     * @param taskId     业务任务ID
     * @param notifyType 通知类型
     * @return 通知任务列表
     */
    List<NotifyTask> selectByTaskIdAndType(@Param("taskId") Long taskId, @Param("notifyType") String notifyType);
    /**
     * 新增通知任务
     * 
     * @param notifyTask 通知任务
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTaskMapper.java
@@ -2,11 +2,13 @@
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.ruoyi.common.annotation.DataSource;
import com.ruoyi.common.enums.DataSourceType;
import com.ruoyi.system.domain.SysTask;
import com.ruoyi.system.domain.vo.TaskQueryVO;
import com.ruoyi.system.domain.vo.DeptOrderStatVO;
import com.ruoyi.system.domain.vo.TaskStatisticsVO;
import org.apache.ibatis.annotations.Param;
@@ -156,7 +158,7 @@
     * @param params包含vehicleId、startTime、endTime的参数Map
     * @return 任务列表
     */
    public List<SysTask> selectVehicleTasksInTimeRange(java.util.Map<String, Object> params);
    public List<SysTask> selectVehicleTasksInTimeRange(Map<String, Object> params);
    
    /**
     * 优化的多码查询方法,关联sys_task_emergency表并计算dispatchCode和serviceCode
@@ -165,4 +167,16 @@
     * @return 任务管理集合
     */
    public List<SysTask> selectSysTaskListByMultiCodeOptimized(TaskQueryVO queryVO);
    /**
     * 按分公司按天统计录单数量
     *
     * @param deptIds   分公司ID列表(为null时查全部)
     * @param startDate 开始日期,yyyy-MM-dd
     * @param endDate   结束日期,yyyy-MM-dd
     * @return 统计结果列表
     */
    List<DeptOrderStatVO> selectDeptOrderStat(
            @Param("deptIds") List<Long> deptIds,
            @Param("startDate") String startDate,
            @Param("endDate") String endDate);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/INotifyTaskService.java
@@ -46,6 +46,15 @@
    boolean existsNotifyTask(Long taskId, Long userId, String notifyType);
    /**
     * 按taskId和通知类型查询所有通知任务
     *
     * @param taskId     业务任务ID
     * @param notifyType 通知类型
     * @return 通知任务列表
     */
    List<NotifyTask> selectByTaskIdAndType(Long taskId, String notifyType);
    /**
     * 创建通知任务(带防重)
     * 如果已存在则返回null,否则创建并返回
     * 
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTaskService.java
@@ -364,4 +364,14 @@
     */
    public boolean checkTaskDuplicate(String phone, String createDate);
    /**
     * 按分公司按天统计录单数量
     *
     * @param deptIds   分公司ID列表(为null或空时查全部)
     * @param startDate 开始日期,yyyy-MM-dd
     * @param endDate   结束日期,yyyy-MM-dd
     * @return 统计结果列表
     */
    List<DeptOrderStatVO> selectDeptOrderStat(List<Long> deptIds, String startDate, String endDate);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/LegacyTransferSyncServiceImpl.java
@@ -3,6 +3,7 @@
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.*;
import com.ruoyi.system.domain.SysTask;
import com.ruoyi.system.domain.SysTaskEmergency;
import com.ruoyi.system.domain.VehicleInfo;
import com.ruoyi.system.domain.enums.TaskStatus;
@@ -17,6 +18,9 @@
import com.ruoyi.system.mapper.VehicleInfoMapper;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.system.service.IWechatTaskNotifyService;
import com.ruoyi.system.service.INotifyTaskService;
import com.ruoyi.system.service.INotifyDispatchService;
import com.ruoyi.system.domain.NotifyTask;
import com.ruoyi.system.utils.TaskStatusConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,8 +32,10 @@
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * 旧系统转运单同步Service业务层处理
@@ -66,18 +72,23 @@
    @Autowired
    private IWechatTaskNotifyService wechatTaskNotifyService;
    @Autowired
    private INotifyTaskService notifyTaskService;
    @Autowired
    private INotifyDispatchService notifyDispatchService;
    /**
     * 同步指定日期范围的旧系统转运单到新系统
     * 优化:将多天范围拆分为逐天循环,每次仅查询1天数据,避免大数据量导致SQL Server超时
     * 
     * @param daysAgo 多少天前的数据(如7表示7天前的数据)
     * @param daysAgo 多少天前的数据(如7表示同步最近7天的数据)
     * @return 成功同步的转运单数量
     */
    @Override
    public int syncLegacyTransferOrders(int daysAgo) {
//        log.info("开始同步{}天前的旧系统转运单数据", daysAgo);
        try {
            // 参数验证
            if (daysAgo <= 0) {
@@ -85,16 +96,47 @@
                return 0;
            }
            
            // 计算日期范围
            Date startDate = DateUtils.addDays(new Date(), -daysAgo);
            String startDateStr = DateUtils.parseDateToStr("yyyy-MM-dd", startDate);
            String endDateStr = DateUtils.parseDateToStr("yyyy-MM-dd", new Date());
            log.info("[转运单同步] 开始同步,范围: 最近{}天", daysAgo);
            int totalSuccessCount = 0;
            int totalDays = daysAgo + 1;
            
            // Keyset游标分页从 SQL Server 拉取转运单数据,每页 10 条,走主键索引彻底规避超时
            // 按天拆分,每次只同步1天的数据,避免大范围查询超时
            for (int i = daysAgo; i >= 0; i--) {
                Date dayStart = DateUtils.addDays(new Date(), -i);
                String dayStartStr = DateUtils.parseDateToStr("yyyy-MM-dd", dayStart) + " 00:00:00";
                String dayEndStr   = DateUtils.parseDateToStr("yyyy-MM-dd", dayStart) + " 23:59:59";
                int dayIndex = totalDays - i;
                log.info("[转运单同步] 处理天 {}/{}: {}", dayIndex, totalDays, dayStartStr);
                int daySuccessCount = syncSingleDayOrders(dayStartStr, dayEndStr);
                totalSuccessCount += daySuccessCount;
                log.info("[转运单同步] {} 完成,新增同步: {}条,累计: {}条", dayStartStr, daySuccessCount, totalSuccessCount);
            }
            log.info("[转运单同步] 全部完成,共新增同步 {}条", totalSuccessCount);
            return totalSuccessCount;
        } catch (Exception e) {
            log.error("同步{}天前的旧系统转运单数据异常", daysAgo, e);
            return 0;
        }
    }
    /**
     * 同步单天的转运单数据(Keyset游标分页)
     *
     * @param startDateStr 开始日期字符串(yyyy-MM-dd)
     * @param endDateStr   结束日期字符串(yyyy-MM-dd)
     * @return 成功同步的转运单数量
     */
    private int syncSingleDayOrders(String startDateStr, String endDateStr) {
            final int PAGE_SIZE = 5;
            long lastId = 0L;   // 游标:记录上一页最后一条的 ServiceOrdID,首次传 0
        long lastId = 0L;
            int successCount = 0;
        int pageNum = 0;
        int totalProcessed = 0;
        try {
            while (true) {
                List<Map<String, Object>> transferOrders = legacyTransferSyncMapper.selectTransferOrders(startDateStr, endDateStr, lastId, PAGE_SIZE);
@@ -102,36 +144,39 @@
                    break;
                }
                pageNum++;
                int totalCount = transferOrders.size();
                int processedCount = 0;
                log.info("[转运单同步] {} 第{}页,获取{}.条数据,lastId={}", startDateStr, pageNum, totalCount, lastId);
                for (Map<String, Object> order : transferOrders) {
                    processedCount++;
                    totalProcessed++;
                    try {
                        Long serviceOrdID = MapValueUtils.getLongValue(order, "ServiceOrdID");
                        Long dispatchOrdID = MapValueUtils.getLongValue(order, "DispatchOrdID");
                        // 检查参数有效性
                        if (serviceOrdID == null || serviceOrdID <= 0) {
                            log.warn("第{}条数据服务单ID为空,跳过处理", processedCount);
                            continue;
                        }
//                        log.debug("正在处理第{}/{}条转运单: ServiceOrdID={}, DispatchOrdID={}",
//                                 processedCount, totalCount, serviceOrdID, dispatchOrdID);
                        // 检查是否已同步
                        if (isTransferOrderSynced(serviceOrdID, dispatchOrdID)) {
//                            log.debug("转运单已同步,跳过: ServiceOrdID={}, DispatchOrdID={}", serviceOrdID, dispatchOrdID);
                            //进行更新操作
                            log.debug("[转运单同步] 已存在,执行更新: ServiceOrdID={}", serviceOrdID);
                            updateTransferOrder(serviceOrdID, dispatchOrdID, order);
                            continue;
                        }
                        // 同步单个转运单
                        log.info("[转运单同步] 新增同步: ServiceOrdID={}, DispatchOrdID={}", serviceOrdID, dispatchOrdID);
                        boolean success = syncSingleTransferOrder(serviceOrdID, dispatchOrdID, order);
                        if (success) {
                            successCount++;
                            log.info("[转运单同步] 同步成功: ServiceOrdID={}, 当天新增累计: {}", serviceOrdID, successCount);
                        } else {
                            log.warn("[转运单同步] 同步失败: ServiceOrdID={}, DispatchOrdID={}", serviceOrdID, dispatchOrdID);
                        }
                        // 控制同步频率,避免请求过快
@@ -139,7 +184,7 @@
                    } catch (InterruptedException ie) {
                        log.warn("同步任务被中断");
                        Thread.currentThread().interrupt();
                        break;
                        return successCount;
                    } catch (Exception e) {
                        log.error("同步单个转运单失败: ServiceOrdID={}, DispatchOrdID={}",
                                MapValueUtils.getStringValue(order, "ServiceOrdID"),
@@ -162,13 +207,12 @@
                }
            }
//            log.info("同步完成,成功同步{}条转运单数据", successCount);
            return successCount;
            log.info("[转运单同步] {} 分页完成,共处理: {}条,新增同步: {}条", startDateStr, totalProcessed, successCount);
        } catch (Exception e) {
            log.error("同步{}天前的旧系统转运单数据异常", daysAgo, e);
            return 0;
            log.error("同步单天转运单数据异常: date={}", startDateStr, e);
        }
        return successCount;
    }
    
    /**
@@ -288,7 +332,13 @@
//                log.info("转运单同步成功: ServiceOrdID={}, DispatchOrdID={}, 创建的任务ID={}", serviceOrdID, dispatchOrdID, result);
                try {
                    notifyTransferOrderByWechat((long) result, serviceOrdID, dispatchOrdID, serviceOrdNo, ServiceOrd_CC_Time, dept, order);
                    // 直接使用方法头部已查询的 emergency 获取 taskId
                    Long taskId = emergency.getTaskId();
                    if (taskId != null) {
                        notifyTransferOrderByWechat(taskId, serviceOrdID, dispatchOrdID, serviceOrdNo, ServiceOrd_CC_Time, dept, order);
                    } else {
                        log.warn("更新后找不到taskId,跳过通知: ServiceOrdID={}", serviceOrdID);
                    }
                } catch (Exception e) {
                    log.error("转运单同步成功后发送微信通知失败: ServiceOrdID={}, DispatchOrdID={}", serviceOrdID, dispatchOrdID, e);
                }
@@ -873,27 +923,123 @@
                                             SysDept dept,
                                             Map<String, Object> order) {
        try {
            // 获取通知接收人列表
            List<SysUser> receivers = getWechatNotifyUsers(dispatchOrdID, dept);
            if (receivers == null || receivers.isEmpty()) {
//                log.info("旧系统同步转运单无可用微信接收人,taskId={}", taskId);
            // 1. 获取执行人列表
            List<TaskCreateVO.AssigneeInfo> assignees = queryAssignees(dispatchOrdID);
            if (assignees.isEmpty()) {
                log.info("旧系统同步转运单无执行人,taskId={}", taskId);
                return;
            }
            // 提取接收人 ID 列表
            List<Long> userIds = new ArrayList<>();
            for (SysUser user : receivers) {
                if (user != null && user.getUserId() != null) {
                    userIds.add(user.getUserId());
            // 2. 查询任务获取showTaskCode
            SysTask sysTask = sysTaskService.getTaskDetail(taskId);
            String showTaskCode = sysTask != null ? sysTask.getShowTaskCode() : serviceOrdNo;
            // 3. 构建通知内容
            String notifyContent = buildLegacyNotifyContent(showTaskCode, serviceOrdCcTime, order);
            // 4. 查询该taskId已有的通知记录,收集已存在的userId集合
            List<NotifyTask> existingTasks = notifyTaskService.selectByTaskIdAndType(taskId, NotifyTask.NOTIFY_TYPE_TASK_ASSIGN);
            Set<Long> existingUserIds = new HashSet<>();
            List<NotifyTask> pendingTasks = new ArrayList<>();
            if (existingTasks != null && !existingTasks.isEmpty()) {
                for (NotifyTask t : existingTasks) {
                    existingUserIds.add(t.getUserId());
                    // 将未完成的记录收集给待分发列表
                    if (!NotifyTask.STATUS_COMPLETED.equals(t.getStatus())) {
                        pendingTasks.add(t);
                    }
                }
            }
            // 调用统一的微信通知服务
            int successCount = wechatTaskNotifyService.sendTaskNotifyMessage(taskId, userIds);
//            log.info("旧系统同步转运单微信通知发送完成,taskId={}, 成功={}", taskId, successCount);
            // 5. 只对新执行人创建通知任务
            List<NotifyTask> notifyTasks = new ArrayList<>(pendingTasks);
            for (TaskCreateVO.AssigneeInfo assignee : assignees) {
                if (assignee == null || assignee.getUserId() == null) {
                    continue;
                }
                // 该用户已有通知记录,跳过
                if (existingUserIds.contains(assignee.getUserId())) {
                    log.info("用户已有通知记录,跳过创建,taskId={}, userId={}", taskId, assignee.getUserId());
                    continue;
                }
                SysUser user = sysUserService.selectUserById(assignee.getUserId());
                if (user == null) {
                    log.warn("找不到执行人用户信息,userId={}", assignee.getUserId());
                    continue;
                }
                NotifyTask notifyTask = new NotifyTask();
                notifyTask.setTaskId(taskId);
                notifyTask.setTaskCode(showTaskCode);
                notifyTask.setNotifyType(NotifyTask.NOTIFY_TYPE_TASK_ASSIGN);
                notifyTask.setUserId(user.getUserId());
                notifyTask.setUserName(user.getNickName());
                notifyTask.setUserPhone(user.getPhonenumber());
                notifyTask.setTitle("转运单任务派单通知");
                notifyTask.setContent(notifyContent);
                notifyTask.setCreateBy("系统同步");
                NotifyTask created = notifyTaskService.createNotifyTask(notifyTask);
                if (created != null) {
                    notifyTasks.add(created);
                    log.info("创建通知任务成功,id={}, userId={}", created.getId(), user.getUserId());
                }
            }
            // 6. 分发通知任务
            if (!notifyTasks.isEmpty()) {
                int successCount = notifyDispatchService.dispatchNotifies(notifyTasks);
                log.info("旧系统同步转运单通知分发完成,taskId={}, 分发数量={}, 成功数量={}",
                        taskId, notifyTasks.size(), successCount);
            } else {
                log.info("旧系统同步转运单无需新增通知,taskId={}", taskId);
            }
            // 5. 同时保留原有的微信通知服务(兼容)
            // List<Long> userIds = new ArrayList<>();
            // for (TaskCreateVO.AssigneeInfo assignee : assignees) {
            //     if (assignee != null && assignee.getUserId() != null) {
            //         userIds.add(assignee.getUserId());
            //     }
            // }
            // if (!userIds.isEmpty()) {
            //     int wxCount = wechatTaskNotifyService.sendTaskNotifyMessage(taskId, userIds);
            //     log.info("旧系统同步转运单微信通知发送完成,taskId={}, 成功={}", taskId, wxCount);
            // }
        } catch (Exception e) {
            log.error("notifyTransferOrderByWechat发生异常, serviceOrdID={}, dispatchOrdID={}", serviceOrdID, dispatchOrdID, e);
        }
    }
    /**
     * 构建旧系统同步转运单的通知内容
     */
    private String buildLegacyNotifyContent(String serviceOrdNo, Date serviceOrdCcTime, Map<String, Object> order) {
        StringBuilder content = new StringBuilder();
        content.append("您有新的转运任务,任务单号:").append(serviceOrdNo);
        // 出发时间
        if (serviceOrdCcTime != null) {
            SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
            content.append(",出发时间:").append(df.format(serviceOrdCcTime));
        }
        // 出发地
        String departure = MapValueUtils.getStringValue(order, "ServiceOrdTraVia");
        if (StringUtils.isNotEmpty(departure)) {
            content.append(",出发地:").append(departure);
        }
        // 目的地
        String destination = MapValueUtils.getStringValue(order, "ServiceOrdTraEnd");
        if (StringUtils.isNotEmpty(destination)) {
            content.append(",目的地:").append(destination);
        }
        content.append(",请及时处理。");
        return content.toString();
    }
    private List<SysUser> getWechatNotifyUsers(Long dispatchOrdID, SysDept dept) {
@@ -901,7 +1047,7 @@
            List<SysUser> result = new ArrayList<>();
            List<TaskCreateVO.AssigneeInfo> assignees = queryAssignees(dispatchOrdID);
            if (assignees != null && !assignees.isEmpty()) {
            if (!assignees.isEmpty()) {
                for (TaskCreateVO.AssigneeInfo assigneeInfo : assignees) {
                    if (assigneeInfo == null || assigneeInfo.getUserId() == null) {
                        continue;
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/NotifyDispatchServiceImpl.java
@@ -191,6 +191,7 @@
            sendLog.setSendTime(DateUtils.getNowDate());
            sendLog.setSendContent(notifyTask.getContent());
            sendLog.setResponseMsg(errorMsg);
            sendLog.setSendResult(success ? "发送成功" : ("发送失败: " + (errorMsg != null ? errorMsg : "未知原因")));
            
            notifySendLogService.insertNotifySendLog(sendLog);
        } catch (Exception e) {
@@ -341,40 +342,34 @@
     */
    @Override
    public boolean sendQyWechatMessage(NotifyTask notifyTask) {
        try {
            // 检查企业微信服务是否启用
            if (!qyWechatService.isEnabled()) {
                log.info("企业微信服务已关闭,跳过发送");
                return false;
            throw new RuntimeException("企业微信服务未启用");
            }
            Long taskId= notifyTask.getTaskId();
           SysTaskEmergency emergency = this.sysEmergencyTaskService.selectSysTaskEmergencyByTaskId(taskId);
           if(emergency==null){
               return false;
            throw new RuntimeException("找不到对应的急救任务信息,taskId=" + taskId);
           }
//           Long dispatchOrderId = emergency.getLegacyDispatchOrdId();
//          String oldsiteUrl= sysConfigService.selectConfigByKey("oldsite.url");
//          if(oldsiteUrl==null){
//              oldsiteUrl="https://sys.966120.com.cn/m_DispatchOrder.gds?DispatchOrdID=";
//          }
        // 检查用户是否绑定企业微信
        Long userId = notifyTask.getUserId();
        String qyUserId = qyWechatService.getQyUserIdByUserId(userId);
        if (qyUserId == null || qyUserId.isEmpty()) {
            throw new RuntimeException("用户未绑定企业微信ID,userId=" + userId);
        }
            String appId=wechatConfig.getAppId();
           String pathPage="/pagesTask/detail?id="+taskId;
            // 发送企业微信消息
            boolean success = qyWechatService.sendNotifyMessage(
                    notifyTask.getUserId(),
                userId,
                    notifyTask.getTitle(), 
                    notifyTask.getContent(),appId,pathPage
            );
            if (success) {
                log.info("企业微信消息发送成功,userId={}", notifyTask.getUserId());
            log.info("企业微信消息发送成功,userId={}", userId);
            } else {
                log.warn("企业微信消息发送失败,userId={}", notifyTask.getUserId());
            throw new RuntimeException("企业微信API返回失败,userId=" + userId);
            }
            return success;
        } catch (Exception e) {
            log.error("企业微信消息发送异常,taskId={}, userId={}", notifyTask.getTaskId(), notifyTask.getUserId(), e);
            return false;
        }
        return true;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/NotifyTaskServiceImpl.java
@@ -58,6 +58,14 @@
    }
    /**
     * 按taskId和通知类型查询所有通知任务
     */
    @Override
    public List<NotifyTask> selectByTaskIdAndType(Long taskId, String notifyType) {
        return notifyTaskMapper.selectByTaskIdAndType(taskId, notifyType);
    }
    /**
     * 创建通知任务(带防重)
     */
    @Override
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTaskServiceImpl.java
@@ -1851,6 +1851,13 @@
        return count > 0;
    }
    @Override
    public List<DeptOrderStatVO> selectDeptOrderStat(List<Long> deptIds, String startDate, String endDate) {
        return sysTaskMapper.selectDeptOrderStat(
                (deptIds != null && !deptIds.isEmpty()) ? deptIds : null,
                startDate, endDate);
    }
   
}
ruoyi-system/src/main/resources/mapper/system/NotifyTaskMapper.xml
@@ -73,6 +73,11 @@
        where task_id = #{taskId} and user_id = #{userId} and notify_type = #{notifyType}
    </select>
    <select id="selectByTaskIdAndType" resultMap="NotifyTaskResult">
        <include refid="selectNotifyTaskVo"/>
        where task_id = #{taskId} and notify_type = #{notifyType}
    </select>
    <insert id="insertNotifyTask" parameterType="NotifyTask" useGeneratedKeys="true" keyProperty="id">
        insert into sys_notify_task (
            task_id, task_code, notify_type, user_id, user_name, user_phone,
ruoyi-system/src/main/resources/mapper/system/SysTaskMapper.xml
@@ -525,4 +525,25 @@
            END,
            t.create_time desc
    </select>
    <!-- 按分公司按天统计录单数量 -->
    <select id="selectDeptOrderStat" resultType="com.ruoyi.system.domain.vo.DeptOrderStatVO">
        SELECT
            d.dept_id   AS deptId,
            d.dept_name AS deptName,
            DATE_FORMAT(t.create_time, '%Y-%m-%d') AS statDate,
            COUNT(t.task_id) AS orderCount
        FROM sys_task t
        LEFT JOIN sys_dept d ON t.dept_id = d.dept_id
        WHERE t.del_flag = '0'
          AND DATE(t.create_time) BETWEEN #{startDate} AND #{endDate}
          <if test="deptIds != null and deptIds.size() > 0">
              AND t.dept_id IN
              <foreach collection="deptIds" item="id" open="(" separator="," close=")">
                  #{id}
              </foreach>
          </if>
        GROUP BY d.dept_id, d.dept_name, DATE_FORMAT(t.create_time, '%Y-%m-%d')
        ORDER BY d.dept_name, statDate
    </select>
</mapper>
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml
@@ -37,10 +37,10 @@
            <if test="direction != null "> and direction = #{direction}</if>
            <if test="collectTime != null "> and collect_time = #{collectTime}</if>
            <if test="beginTime != null and beginTime != ''"><!-- 开始时间检索 -->
                AND date_format(collect_time,'%y%m%d') &gt;= date_format(#{beginTime},'%y%m%d')
                AND collect_time &gt;= #{beginTime}
            </if>
            <if test="endTime != null and endTime != ''"><!-- 结束时间检索 -->
                AND date_format(collect_time,'%y%m%d') &lt;= date_format(#{endTime},'%y%m%d')
                AND collect_time &lt;= #{endTime}
            </if>
        </where>
        order by collect_time desc
ruoyi-ui/src/api/system/dept.js
@@ -74,3 +74,11 @@
    method: 'get'
  })
}
// 获取所有分公司列表(parentId=100的部门)
export function listAllBranches() {
  return request({
    url: '/system/dept/branch/all',
    method: 'get'
  })
}
ruoyi-ui/src/api/system/gps.js
@@ -89,3 +89,17 @@
    }
  })
// 按车牌号和时间范围查询轨迹(从本地数据库tb_vehicle_gps,天地图轨迹页使用)
export function getTracksByPlate(vehicleNo, beginTime, endTime) {
  return request({
    url: '/system/gps/tracksByPlate',
    method: 'get',
    params: {
      vehicleNo,
      beginTime,
      endTime
    },
    timeout: 180000
  })
}
ruoyi-ui/src/api/system/user.js
@@ -134,3 +134,12 @@
    method: 'get'
  })
}
// 更新用户可管理分公司(传入deptId数组)
export function updateUserBranch(userId, deptIds) {
  return request({
    url: '/system/user/branch/' + userId,
    method: 'put',
    data: deptIds
  })
}
ruoyi-ui/src/api/task.js
@@ -339,3 +339,24 @@
    method: 'get'
  })
}
// ========== 分公司录单统计相关API ==========
// 按分公司按天统计录单数量
export function getDeptOrderStat(params) {
  return request({
    url: '/task/stat/deptOrder',
    method: 'get',
    params
  })
}
// 导出分公司录单统计 Excel
export function exportDeptOrderStat(params) {
  return request({
    url: '/task/stat/deptOrder/export',
    method: 'get',
    params,
    responseType: 'blob'
  })
}
ruoyi-ui/src/router/index.js
@@ -131,12 +131,18 @@
    component: (resolve) => require(['@/views/system/payInfoTest/index'], resolve),
    hidden: true,
    meta: { title: '支付信息测试', anonymous: true }
  }
  ,{
  },
  {
    path: '/system/gps/mapNeed',
    component: () => import('@/views/system/gps/mapNeed'),
    name: 'GpsMapNeed',
    meta: { title: '车辆轨迹', icon: 'map' }
  },
  {
    path: '/system/gps/trackMap',
    component: () => import('@/views/system/gps/trackMap'),
    name: 'GpsTrackMap',
    meta: { title: '车辆行驶轨迹', icon: 'map' }
  },
  
  {
@@ -329,6 +335,19 @@
        meta: { title: '任务详情', activeMenu: '/task/general' }
      }
    ]
  },
  {
    path: '/task/stat',
    component: Layout,
    hidden: false,
    children: [
      {
        path: 'index',
        component: () => import('@/views/task/stat/index'),
        name: 'DeptOrderStat',
        meta: { title: '分公司录单统计', icon: 'chart', noCache: false }
      }
    ]
  }
]
ruoyi-ui/src/views/system/gps/trackMap.vue
New file
@@ -0,0 +1,570 @@
<template>
  <div class="app-container">
    <!-- 查询条件 -->
    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
      <el-form-item label="车牌号" prop="vehicleNo">
        <el-autocomplete
          v-model="queryParams.vehicleNo"
          :fetch-suggestions="queryVehicleSearch"
          placeholder="输入车牌号搜索"
          size="small"
          style="width: 200px"
          clearable
          :trigger-on-focus="true"
          value-key="vehicleNo"
          @select="handleVehicleSelect"
          @keyup.enter.native="handleQuery"
        >
          <template slot-scope="{ item }">
            <span>{{ item.vehicleNo }}</span>
            <span style="float:right;color:#909399;font-size:12px;margin-left:12px">{{ item.vehicleType || '' }}</span>
          </template>
        </el-autocomplete>
      </el-form-item>
      <el-form-item label="时间范围">
        <el-date-picker
          v-model="dateRange"
          size="small"
          style="width: 340px"
          value-format="yyyy-MM-dd HH:mm:ss"
          type="datetimerange"
          range-separator="-"
          start-placeholder="开始时间"
          end-placeholder="结束时间"
          :default-time="['00:00:00', '23:59:59']"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="el-icon-search" size="mini" :loading="loading" @click="handleQuery">查询</el-button>
        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>
    <!-- 统计信息条 -->
    <div class="stats-bar" v-if="gpsList.length > 0">
      <el-tag type="info" size="small">共 {{ gpsList.length }} 个轨迹点</el-tag>
      <el-tag type="success" size="small" style="margin-left:8px">起点时间:{{ gpsList[0] && gpsList[0].collectTime }}</el-tag>
      <el-tag type="warning" size="small" style="margin-left:8px">终点时间:{{ gpsList[gpsList.length-1] && gpsList[gpsList.length-1].collectTime }}</el-tag>
      <el-button-group style="margin-left:16px">
        <el-button size="mini" @click="showPreviousSegment" :disabled="segmentIndex === 0">
          <i class="el-icon-arrow-left"></i> 上一段
        </el-button>
        <el-button size="mini" @click="showNextSegment" :disabled="(segmentIndex + 1) * segmentSize >= gpsList.length">
          下一段 <i class="el-icon-arrow-right"></i>
        </el-button>
        <el-button size="mini" style="margin-left:8px">
          第 {{ segmentIndex + 1 }} / {{ Math.ceil(gpsList.length / segmentSize) }} 段
        </el-button>
      </el-button-group>
      <el-button-group style="margin-left:8px">
        <el-button size="mini" type="primary" icon="el-icon-video-play" :disabled="isPlaying" @click="startPlayback">回放</el-button>
        <el-button size="mini" type="danger" icon="el-icon-video-pause" :disabled="!isPlaying" @click="stopPlayback">停止</el-button>
        <el-select v-model="playSpeed" size="mini" style="width:90px;margin-left:4px">
          <el-option label="慢速" :value="1500" />
          <el-option label="正常" :value="800" />
          <el-option label="快速" :value="300" />
          <el-option label="极速" :value="80" />
        </el-select>
      </el-button-group>
    </div>
    <el-row :gutter="10" style="margin-top:10px">
      <!-- 左侧轨迹列表 -->
      <el-col :span="6">
        <el-card class="track-list-card">
          <div slot="header">
            <span>轨迹点列表</span>
            <el-tag size="mini" style="float:right" v-if="gpsList.length">{{ currentSegmentStart }}-{{ currentSegmentEnd }} / {{ gpsList.length }}</el-tag>
          </div>
          <el-table
            v-loading="loading"
            :data="currentSegmentList"
            height="560"
            size="mini"
            highlight-current-row
            @row-click="handleRowClick"
          >
            <el-table-column label="时间" prop="collectTime" min-width="100">
              <template slot-scope="scope">
                <span style="font-size:11px">{{ scope.row.collectTime }}</span>
              </template>
            </el-table-column>
            <el-table-column label="速度" prop="speed" width="65" align="center">
              <template slot-scope="scope">
                <el-tag size="mini" :type="getSpeedTagType(scope.row.speed)">{{ formatSpeed(scope.row.speed) }}</el-tag>
              </template>
            </el-table-column>
            <el-table-column label="经度" prop="longitude" width="80" align="center">
              <template slot-scope="scope">
                <span style="font-size:11px">{{ scope.row.longitude }}</span>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
      <!-- 右侧天地图 -->
      <el-col :span="18">
        <div style="position:relative">
          <div id="tianMapContainer" style="height:620px;border-radius:4px;overflow:hidden"></div>
          <!-- 地图加载中提示 -->
          <div v-if="mapLoading" class="map-loading-mask">
            <i class="el-icon-loading" style="font-size:32px;color:#409EFF"></i>
            <p style="margin-top:8px;color:#409EFF">地图加载中...</p>
          </div>
          <!-- 无数据提示 -->
          <div v-if="!mapLoading && gpsList.length === 0 && !loading" class="map-empty-tip">
            <i class="el-icon-map-location" style="font-size:48px;color:#909399"></i>
            <p style="margin-top:8px;color:#909399">请输入车牌号和时间范围查询轨迹</p>
          </div>
        </div>
      </el-col>
    </el-row>
  </div>
</template>
<script>
import { getTracksByPlate } from '@/api/system/gps'
import { listVehicle } from '@/api/system/vehicle'
// 天地图 API Key(与后端配置保持一致)
const TIAN_DI_TU_TK = '079300b89bce333ead2df1476c43ecb0'
export default {
  name: 'GpsTrackMap',
  data() {
    return {
      loading: false,
      mapLoading: true,
      showSearch: true,
      gpsList: [],
      dateRange: [],
      // 车牌号下拉搜索
      vehicleOptions: [],
      vehicleSearchLoading: false,
      queryParams: {
        vehicleNo: '',
        beginTime: null,
        endTime: null
      },
      // 地图对象
      map: null,
      // 轨迹覆盖物
      trackPolyline: null,
      markerStart: null,
      markerEnd: null,
      markerCurrent: null,
      infoWindow: null,
      // 分段
      segmentIndex: 0,
      segmentSize: 200,
      // 回放
      isPlaying: false,
      playTimer: null,
      playIndex: 0,
      playSpeed: 800
    }
  },
  computed: {
    currentSegmentStart() {
      return this.segmentIndex * this.segmentSize + 1
    },
    currentSegmentEnd() {
      return Math.min((this.segmentIndex + 1) * this.segmentSize, this.gpsList.length)
    },
    currentSegmentList() {
      return this.gpsList.slice(this.currentSegmentStart - 1, this.currentSegmentEnd)
    }
  },
  mounted() {
    // 初始化默认时间为今天
    this.initDefaultDateRange()
    // 加载天地图
    this.loadTianDiTuScript().then(() => {
      this.initMap()
    })
  },
  methods: {
    /** 车牌号自动完成搜索 */
    queryVehicleSearch(queryStr, callback) {
      const keyword = queryStr || ''
      listVehicle({ vehicleNo: keyword, pageSize: 30, pageNum: 1 }).then(res => {
        const list = (res.rows || []).map(item => ({
          vehicleId: item.vehicleId,
          vehicleNo: item.vehicleNo,
          vehicleType: item.vehicleType,
          value: item.vehicleNo
        }))
        callback(list)
      }).catch(() => {
        callback([])
      })
    },
    /** 选中车牌号 */
    handleVehicleSelect(item) {
      this.queryParams.vehicleNo = item.vehicleNo
    },
    /** 远程搜索车牌号 */
    remoteSearchVehicle(query) {
      if (query === '' || query === null) {
        this.vehicleOptions = []
        return
      }
      this.vehicleSearchLoading = true
      listVehicle({ vehicleNo: query, pageSize: 20, pageNum: 1 }).then(res => {
        this.vehicleOptions = res.rows || []
        this.vehicleSearchLoading = false
      }).catch(() => {
        this.vehicleSearchLoading = false
      })
    },
    /** 初始化默认时间范围(今天) */
    initDefaultDateRange() {
      const now = new Date()
      const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
      const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
      this.dateRange = [this.formatDatetime(start), this.formatDatetime(end)]
    },
    /** 格式化日期为字符串 */
    formatDatetime(date) {
      const y = date.getFullYear()
      const m = String(date.getMonth() + 1).padStart(2, '0')
      const d = String(date.getDate()).padStart(2, '0')
      const h = String(date.getHours()).padStart(2, '0')
      const min = String(date.getMinutes()).padStart(2, '0')
      const s = String(date.getSeconds()).padStart(2, '0')
      return `${y}-${m}-${d} ${h}:${min}:${s}`
    },
    /** 格式化速度显示 */
    formatSpeed(speed) {
      if (speed == null) return '0'
      return parseFloat(speed).toFixed(1)
    },
    /** 速度标签类型 */
    getSpeedTagType(speed) {
      const v = parseFloat(speed) || 0
      if (v === 0) return 'info'
      if (v < 60) return 'success'
      if (v < 100) return 'warning'
      return 'danger'
    },
    /** 加载天地图 JS API */
    loadTianDiTuScript() {
      return new Promise((resolve, reject) => {
        if (window.T) {
          resolve()
          return
        }
        const script = document.createElement('script')
        script.type = 'text/javascript'
        script.src = `http://api.tianditu.gov.cn/api?v=4.0&tk=${TIAN_DI_TU_TK}`
        script.onload = () => {
          // 等待 T 对象可用
          const checkT = setInterval(() => {
            if (window.T) {
              clearInterval(checkT)
              resolve()
            }
          }, 100)
        }
        script.onerror = (err) => {
          console.error('天地图脚本加载失败', err)
          reject(err)
        }
        document.head.appendChild(script)
      })
    },
    /** 初始化天地图 */
    initMap() {
      try {
        this.map = new T.Map('tianMapContainer')
        // 默认居中广州
        this.map.centerAndZoom(new T.LngLat(113.33, 23.12), 11)
        // 添加控件
        this.map.addControl(new T.Control.Zoom())
        this.map.addControl(new T.Control.Scale())
        this.mapLoading = false
      } catch (e) {
        console.error('天地图初始化失败', e)
        this.$message.error('地图初始化失败,请刷新重试')
        this.mapLoading = false
      }
    },
    /** 查询按钮 */
    handleQuery() {
      if (!this.queryParams.vehicleNo || !this.queryParams.vehicleNo.trim()) {
        this.$message.warning('请输入车牌号')
        return
      }
      if (!this.dateRange || this.dateRange.length !== 2) {
        this.$message.warning('请选择时间范围')
        return
      }
      this.queryParams.beginTime = this.dateRange[0]
      this.queryParams.endTime = this.dateRange[1]
      this.segmentIndex = 0
      this.stopPlayback()
      this.getTrackData()
    },
    /** 重置 */
    resetQuery() {
      this.$refs.queryForm.resetFields()
      this.initDefaultDateRange()
      this.gpsList = []
      this.segmentIndex = 0
      this.stopPlayback()
      this.clearMap()
    },
    /** 获取轨迹数据 */
    getTrackData() {
      this.loading = true
      getTracksByPlate(
        this.queryParams.vehicleNo.trim(),
        this.queryParams.beginTime,
        this.queryParams.endTime
      ).then(res => {
        this.gpsList = (res.rows || []).sort((a, b) => {
          return new Date(a.collectTime) - new Date(b.collectTime)
        })
        this.loading = false
        if (this.gpsList.length === 0) {
          this.$message.info('该时间段内未查询到轨迹数据')
          this.clearMap()
        } else {
          this.$message.success(`共查询到 ${this.gpsList.length} 个轨迹点`)
          this.drawTrack()
        }
      }).catch(err => {
        this.loading = false
        this.$message.error('查询轨迹失败:' + (err.message || '未知错误'))
      })
    },
    /** 清空地图覆盖物 */
    clearMap() {
      if (!this.map) return
      this.map.clearOverLays()
      this.trackPolyline = null
      this.markerStart = null
      this.markerEnd = null
      this.markerCurrent = null
    },
    /** 绘制轨迹 */
    drawTrack() {
      if (!this.map) return
      this.clearMap()
      const segment = this.currentSegmentList
      if (!segment || segment.length === 0) return
      // 构建轨迹点数组
      const points = segment
        .filter(p => p.longitude != null && p.latitude != null)
        .map(p => new T.LngLat(p.longitude, p.latitude))
      if (points.length < 2) {
        this.$message.warning('有效轨迹点不足,无法绘制')
        return
      }
      // 绘制轨迹线
      this.trackPolyline = new T.Polyline(points, {
        color: '#3388ff',
        weight: 4,
        opacity: 0.85
      })
      this.map.addOverLay(this.trackPolyline)
      // 起点标记
      const startPoint = points[0]
      const startMarker = this.createStartMarker(startPoint, segment[0])
      this.map.addOverLay(startMarker)
      this.markerStart = startMarker
      // 终点标记(车辆图标)
      const endIdx = segment.length - 1
      const endPoint = points[points.length - 1]
      const endMarker = this.createVehicleMarker(endPoint, segment[endIdx])
      this.map.addOverLay(endMarker)
      this.markerEnd = endMarker
      // 自适应视野
      this.map.setViewport(points)
    },
    /** 创建起点标记 */
    createStartMarker(lngLat, data) {
      const icon = new T.Icon({
        iconUrl: this.createSvgIconUrl('#2ecc71', '起'),
        iconSize: new T.Point(28, 28),
        iconAnchor: new T.Point(14, 14)
      })
      const marker = new T.Marker(lngLat, { icon })
      marker.addEventListener('click', () => {
        this.showInfoWindow(lngLat, data, '起点')
      })
      return marker
    },
    /** 创建车辆标记 */
    createVehicleMarker(lngLat, data) {
      const direction = data.direction || 0
      const icon = new T.Icon({
        iconUrl: this.createCarIconUrl(direction),
        iconSize: new T.Point(32, 32),
        iconAnchor: new T.Point(16, 16)
      })
      const marker = new T.Marker(lngLat, { icon })
      marker.addEventListener('click', () => {
        this.showInfoWindow(lngLat, data, '当前位置')
      })
      return marker
    },
    /** 生成起点/终点 SVG 图标 URL */
    createSvgIconUrl(color, text) {
      const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
        <circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>
        <text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${text}</text>
      </svg>`
      return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
    },
    /** 生成汽车图标 SVG URL(带方向旋转) */
    createCarIconUrl(direction) {
      const deg = direction || 0
      const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
        <g transform="rotate(${deg}, 16, 16)">
          <ellipse cx="16" cy="16" rx="10" ry="13" fill="#3388ff" stroke="white" stroke-width="2"/>
          <polygon points="16,3 21,12 11,12" fill="white"/>
          <rect x="11" y="22" width="4" height="3" rx="1" fill="white" opacity="0.8"/>
          <rect x="17" y="22" width="4" height="3" rx="1" fill="white" opacity="0.8"/>
        </g>
      </svg>`
      return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
    },
    /** 显示信息窗口 */
    showInfoWindow(lngLat, data, label) {
      if (!this.map) return
      if (this.infoWindow) {
        this.map.closeInfoWindow()
      }
      const content = `
        <div style="padding:8px;min-width:180px;line-height:1.8">
          <b style="color:#3388ff">${label || ''}</b><br/>
          <span>车牌:${data.vehicleNo || '-'}</span><br/>
          <span>时间:${data.collectTime || '-'}</span><br/>
          <span>速度:${this.formatSpeed(data.speed)} km/h</span><br/>
          <span>方向:${data.direction || 0}°</span><br/>
          <span>经度:${data.longitude}</span><br/>
          <span>纬度:${data.latitude}</span>
        </div>
      `
      this.infoWindow = new T.InfoWindow(content, {
        offset: new T.Point(0, -15)
      })
      this.map.openInfoWindow(this.infoWindow, lngLat)
    },
    /** 点击列表行 */
    handleRowClick(row) {
      if (!row || row.longitude == null) return
      const lngLat = new T.LngLat(row.longitude, row.latitude)
      this.map.panTo(lngLat)
      this.map.setZoom(15)
      this.showInfoWindow(lngLat, row, '选中点')
    },
    /** 上一段 */
    showPreviousSegment() {
      if (this.segmentIndex > 0) {
        this.segmentIndex--
        this.stopPlayback()
        this.drawTrack()
      }
    },
    /** 下一段 */
    showNextSegment() {
      if ((this.segmentIndex + 1) * this.segmentSize < this.gpsList.length) {
        this.segmentIndex++
        this.stopPlayback()
        this.drawTrack()
      }
    },
    /** 开始回放 */
    startPlayback() {
      if (this.isPlaying || !this.gpsList.length) return
      this.isPlaying = true
      this.playIndex = this.currentSegmentStart - 1
      this.playNextPoint()
    },
    /** 回放下一个点 */
    playNextPoint() {
      if (!this.isPlaying) return
      const allList = this.gpsList
      if (this.playIndex >= this.currentSegmentEnd) {
        this.stopPlayback()
        this.$message.success('轨迹回放完成')
        return
      }
      const item = allList[this.playIndex]
      if (item && item.longitude != null) {
        const lngLat = new T.LngLat(item.longitude, item.latitude)
        // 移除旧的回放标记
        if (this.markerCurrent) {
          this.map.removeOverLay(this.markerCurrent)
        }
        const marker = this.createVehicleMarker(lngLat, item)
        this.map.addOverLay(marker)
        this.markerCurrent = marker
        this.map.panTo(lngLat)
      }
      this.playIndex++
      this.playTimer = setTimeout(() => this.playNextPoint(), this.playSpeed)
    },
    /** 停止回放 */
    stopPlayback() {
      if (this.playTimer) {
        clearTimeout(this.playTimer)
        this.playTimer = null
      }
      this.isPlaying = false
    }
  },
  beforeDestroy() {
    this.stopPlayback()
  }
}
</script>
<style scoped>
.stats-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  padding: 8px 12px;
  background: #f5f7fa;
  border-radius: 4px;
  margin-bottom: 4px;
}
.track-list-card {
  height: 660px;
}
.track-list-card >>> .el-card__body {
  padding: 0;
  overflow: hidden;
}
.map-loading-mask {
  position: absolute;
  top: 0; left: 0; right: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: rgba(255,255,255,0.85);
  z-index: 999;
}
.map-empty-tip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  pointer-events: none;
  z-index: 10;
}
</style>
ruoyi-ui/src/views/system/user/index.vue
@@ -248,16 +248,30 @@
    </el-dialog>
    <!-- 新增:用户管理分公司配置对话框 -->
    <el-dialog title="配置管理分公司" :visible.sync="branchDialog.open" width="500px" append-to-body>
      <el-form label-width="100px">
        <el-form-item label="可管理分公司">
    <el-dialog title="配置管理分公司" :visible.sync="branchDialog.open" width="520px" append-to-body>
      <el-form label-width="100px" v-loading="branchDialog.loading">
        <el-form-item label="已关联分公司">
          <div style="margin-bottom:8px;color:#666;font-size:12px;">OA自动关联(只读):</div>
          <div>
            <el-tag v-for="bc in branchDialog.companies" :key="bc.deptId" type="info" size="small" style="margin-right:6px;margin-bottom:6px">{{ bc.deptName }}</el-tag>
            <span v-if="branchDialog.companies.length === 0" style="color:#999">暂无分公司</span>
            <el-tag v-for="bc in branchDialog.companies" :key="'oa-'+bc.deptId" type="success" size="small" style="margin-right:6px;margin-bottom:6px">{{ bc.deptName }}</el-tag>
            <span v-if="branchDialog.companies.length === 0" style="color:#999;font-size:12px">无OA自动关联</span>
          </div>
        </el-form-item>
        <el-form-item label="手动添加">
          <div style="margin-bottom:6px;color:#666;font-size:12px;">选择需要额外添加的分公司:</div>
          <el-checkbox-group v-model="branchDialog.selectedDeptIds">
            <el-checkbox
              v-for="branch in branchDialog.allBranches"
              :key="branch.deptId"
              :label="branch.deptId"
              style="display:block;margin-bottom:4px;"
            >{{ branch.deptName }}</el-checkbox>
          </el-checkbox-group>
          <div v-if="branchDialog.allBranches.length === 0" style="color:#999;font-size:12px">暂无分公司数据</div>
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="submitBranchCompanies" :loading="branchDialog.loading">保 存</el-button>
        <el-button @click="branchDialog.open = false">关 闭</el-button>
      </div>
    </el-dialog>
@@ -266,13 +280,14 @@
<script>
import { listUser, getUser, delUser, addUser, updateUser, resetUserPwd, changeUserStatus, deptTreeSelect } from "@/api/system/user";
import { updateUserBranch } from "@/api/system/user";
import { getToken } from "@/utils/auth";
import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import request from "@/utils/request";
import { listDept, listBranchByUser } from "@/api/system/dept";
import { listDept, listBranchByUser, listAllBranches } from "@/api/system/dept";
export default {
  name: "User",
@@ -388,9 +403,10 @@
      branchDialog: {
        open: false,
        userId: null,
        deptOptions: [],
        allBranches: [],
        selectedDeptIds: [],
        companies: []
        companies: [],
        loading: false
      },
      // 基于 oa_order_class 计算的分公司列表(只读展示)
      userBranchCompanies: []
@@ -606,22 +622,53 @@
    handleManageBranch(row) {
      const userId = row.userId;
      this.branchDialog.userId = userId;
      // 加载分公司列表(OA自动控制,只读展示)
      listBranchByUser(userId).then(res => {
        const list = res.data || [];
        this.branchDialog.companies = (list || []).map(d => ({ deptId: d.deptId, deptName: d.deptName }));
      this.branchDialog.companies = [];
      this.branchDialog.allBranches = [];
      this.branchDialog.selectedDeptIds = [];
      this.branchDialog.loading = true;
        this.branchDialog.open = true;
      // 并行加载:当前用户OA关联分公司 + 所有分公司列表
      const p1 = listBranchByUser(userId).then(res => {
        return (res.data || []).map(d => ({ deptId: d.deptId, deptName: d.deptName }));
      });
      const p2 = listAllBranches().then(res => {
        return res.data || [];
      });
      Promise.all([p1, p2]).then(([oaCompanies, allBranches]) => {
        this.branchDialog.companies = oaCompanies;
        this.branchDialog.allBranches = allBranches;
        // 初始化已选:从 userList 中找到该用户的 oaOrderClass,匹配分公司
        const userRow = (this.userList || []).find(u => u.userId === userId);
        if (userRow && userRow.oaOrderClass) {
          const codes = userRow.oaOrderClass.split(',').map(s => s.trim()).filter(s => s);
          const codeSet = new Set(codes);
          const selected = allBranches
            .filter(d => {
              return (d.serviceOrderClass && codeSet.has(d.serviceOrderClass.trim()))
                || (d.dispatchOrderClass && codeSet.has(d.dispatchOrderClass.trim()));
            })
            .map(d => d.deptId);
          this.branchDialog.selectedDeptIds = selected;
        }
        this.branchDialog.loading = false;
      }).catch(() => {
        this.$modal.msgError('加载分公司配置失败');
        this.branchDialog.loading = false;
      });
    },
    /** 保存分公司配置 */
    // 已取消保存,OA自动控制(仅只读展示)
    // submitBranchCompanies() {
    //   const userId = this.branchDialog.userId;
    //   const deptIds = this.branchDialog.selectedDeptIds || [];
    //   // 保留占位,避免误调用
    // },
    submitBranchCompanies() {
      this.branchDialog.loading = true;
      updateUserBranch(this.branchDialog.userId, this.branchDialog.selectedDeptIds).then(() => {
        this.$modal.msgSuccess('分公司配置保存成功');
        this.branchDialog.loading = false;
        this.branchDialog.open = false;
        this.getList();
      }).catch(() => {
        this.$modal.msgError('保存分公司配置失败');
        this.branchDialog.loading = false;
      });
    },
    /** 提交按钮 */
    submitForm: function() {
      this.$refs["form"].validate(valid => {
ruoyi-ui/src/views/task/stat/index.vue
New file
@@ -0,0 +1,219 @@
<template>
  <div class="app-container">
    <!-- 查询条件 -->
    <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="80px">
      <el-form-item label="分公司" prop="deptIds">
        <el-select
          v-model="queryParams.deptIds"
          placeholder="全部分公司"
          clearable
          filterable
          multiple
          collapse-tags
          style="width: 240px"
        >
          <el-option
            v-for="dept in branchList"
            :key="dept.deptId"
            :label="dept.deptName"
            :value="dept.deptId"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="时间范围" prop="dateRange">
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          value-format="yyyy-MM-dd"
          range-separator="-"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          style="width: 240px"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>
    <!-- 操作栏 -->
    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <el-button
          type="warning"
          plain
          icon="el-icon-download"
          size="mini"
          :loading="exportLoading"
          @click="handleExport"
          v-hasPermi="['task:stat:export']"
        >导出Excel</el-button>
      </el-col>
    </el-row>
    <!-- 数据表格 -->
    <el-table
      v-loading="loading"
      :data="tableData"
      border
      style="width: 100%"
      :span-method="mergeDeptRows"
    >
      <el-table-column label="分公司" prop="deptName" align="center" min-width="140" />
      <el-table-column label="日期" prop="statDate" align="center" width="120" />
      <el-table-column label="录单数量" prop="orderCount" align="center" width="120">
        <template slot-scope="scope">
          <el-tag type="primary">{{ scope.row.orderCount }}</el-tag>
        </template>
      </el-table-column>
    </el-table>
    <!-- 汇总信息 -->
    <div v-if="tableData.length > 0" style="margin-top: 12px; color: #606266; font-size: 13px;">
      共 <strong>{{ tableData.length }}</strong> 条记录,合计录单
      <strong style="color: #E6A23C; font-size: 16px;">{{ totalCount }}</strong> 单
    </div>
  </div>
</template>
<script>
import { getDeptOrderStat, exportDeptOrderStat } from "@/api/task";
import { listAllBranches } from "@/api/system/dept";
export default {
  name: "DeptOrderStat",
  data() {
    const now = new Date();
    const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
    const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
    return {
      loading: false,
      exportLoading: false,
      tableData: [],
      branchList: [],
      // 默认选择当月
      dateRange: [fmt(firstDay), fmt(now)],
      queryParams: {
        deptIds: []
      },
      // 合并行相关
      mergeMap: {}
    };
  },
  computed: {
    totalCount() {
      return this.tableData.reduce((sum, row) => sum + (row.orderCount || 0), 0);
    }
  },
  created() {
    this.loadBranchList();
    this.getList();
  },
  methods: {
    /** 加载分公司列表 */
    loadBranchList() {
      listAllBranches().then(res => {
        this.branchList = res.data || [];
      }).catch(() => {
        this.branchList = [];
      });
    },
    /** 查询统计数据 */
    getList() {
      if (!this.dateRange || this.dateRange.length !== 2) {
        this.$message.warning("请选择时间范围");
        return;
      }
      this.loading = true;
      const params = {
        startDate: this.dateRange[0],
        endDate: this.dateRange[1],
        deptIds: this.queryParams.deptIds && this.queryParams.deptIds.length > 0
          ? this.queryParams.deptIds.join(",")
          : undefined
      };
      getDeptOrderStat(params).then(res => {
        this.tableData = res.data || [];
        this.buildMergeMap();
        this.loading = false;
      }).catch(() => {
        this.loading = false;
      });
    },
    /** 构建合并行映射 */
    buildMergeMap() {
      const map = {};
      const data = this.tableData;
      let i = 0;
      while (i < data.length) {
        let j = i;
        while (j < data.length && data[j].deptName === data[i].deptName) {
          j++;
        }
        map[i] = j - i;
        for (let k = i + 1; k < j; k++) {
          map[k] = 0;
        }
        i = j;
      }
      this.mergeMap = map;
    },
    /** 合并分公司列(相同分公司行合并) */
    mergeDeptRows({ row, column, rowIndex, columnIndex }) {
      if (columnIndex === 0) {
        const span = this.mergeMap[rowIndex];
        if (span !== undefined) {
          return { rowspan: span, colspan: span === 0 ? 0 : 1 };
        }
      }
    },
    /** 搜索 */
    handleQuery() {
      this.getList();
    },
    /** 重置 */
    resetQuery() {
      const now = new Date();
      const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
      const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
      this.dateRange = [fmt(firstDay), fmt(now)];
      this.queryParams.deptIds = [];
      this.getList();
    },
    /** 导出 Excel */
    handleExport() {
      if (!this.dateRange || this.dateRange.length !== 2) {
        this.$message.warning("请选择时间范围");
        return;
      }
      this.exportLoading = true;
      const params = {
        startDate: this.dateRange[0],
        endDate: this.dateRange[1],
        deptIds: this.queryParams.deptIds && this.queryParams.deptIds.length > 0
          ? this.queryParams.deptIds.join(",")
          : undefined
      };
      exportDeptOrderStat(params).then(res => {
        const blob = new Blob([res], { type: 'application/vnd.ms-excel' });
        const url = window.URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `分公司录单统计_${params.startDate}_${params.endDate}.xlsx`;
        a.click();
        window.URL.revokeObjectURL(url);
        this.exportLoading = false;
      }).catch(() => {
        this.exportLoading = false;
      });
    }
  }
};
</script>
ruoyi-ui/vue.config.js
@@ -11,7 +11,7 @@
const baseUrl = 'http://localhost:8080' // 后端接口
const port = process.env.port || process.env.npm_config_port || 80 // 端口
const port = process.env.port || process.env.npm_config_port || 8086 // 端口
// vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
sql/dept_order_stat_menu.sql
New file
@@ -0,0 +1,20 @@
-- ----------------------------
-- 分公司录单统计 菜单权限配置
-- 挂载在已有的"任务管理"目录下
-- ----------------------------
-- 获取"任务管理"目录的菜单ID
SET @taskParentId = (SELECT menu_id FROM sys_menu WHERE menu_name = '任务管理' AND menu_type = 'M' LIMIT 1);
-- 分公司录单统计 菜单(子菜单)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ('分公司录单统计', @taskParentId, 9, 'stat/index', 'task/stat/index', 1, 0, 'C', '0', '0', 'task:stat:query', 'chart', 'admin', SYSDATE(), '', NULL, '分公司录单统计菜单');
-- 获取刚插入的菜单ID
SET @statMenuId = (SELECT menu_id FROM sys_menu WHERE menu_name = '分公司录单统计' AND menu_type = 'C' LIMIT 1);
-- 按钮权限
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES
('录单统计查询', @statMenuId, 1, '#', '', 1, 0, 'F', '0', '0', 'task:stat:query',  '#', 'admin', SYSDATE(), '', NULL, ''),
('录单统计导出', @statMenuId, 2, '#', '', 1, 0, 'F', '0', '0', 'task:stat:export', '#', 'admin', SYSDATE(), '', NULL, '');
sql/vehicle_gps_track_map_menu.sql
New file
@@ -0,0 +1,53 @@
-- =====================================================
-- 车辆行驶轨迹(天地图)菜单配置SQL
-- 说明:在系统管理菜单中添加"车辆行驶轨迹"菜单项
--       前端路由:/system/gps/trackMap
--       组件路径:system/gps/trackMap
--       所需权限:system:gps:list
-- 执行方式:mysql -u root -p 数据库名 < vehicle_gps_track_map_menu.sql
-- =====================================================
-- 添加"车辆行驶轨迹"菜单(父菜单ID=3,即车辆管理目录)
-- parent_id 根据实际数据库中的车辆管理目录ID调整
INSERT INTO sys_menu (
    menu_name,
    parent_id,
    order_num,
    path,
    component,
    is_frame,
    is_cache,
    menu_type,
    visible,
    status,
    perms,
    icon,
    create_by,
    create_time,
    update_by,
    update_time,
    remark
) VALUES (
    '车辆行驶轨迹',
    3,            -- 车辆管理目录ID(如不对请修改为实际ID)
    10,
    'trackMap',
    'system/gps/trackMap',
    1,
    0,
    'C',
    '0',
    '0',
    'system:gps:list',
    'guide',
    'admin',
    sysdate(),
    '',
    null,
    '车辆行驶轨迹(天地图)查询页面'
);
-- 查询结果验证
SELECT menu_id, menu_name, parent_id, path, component, perms, status
FROM sys_menu
WHERE menu_name = '车辆行驶轨迹';
test/1.html
New file
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
    <meta name="keywords" content="天地图"/>
    <title>天地图-地图API-范例-经纬度直投地图</title>
    <script type="text/javascript" src="http://api.tianditu.gov.cn/api?v=4.0&tk=079300b89bce333ead2df1476c43ecb0"></script>
    <style type="text/css">body,html{width:100%;height:100%;margin:0;font-family:"Microsoft YaHei"}#mapDiv{width:100%;height:400px}input,b,p{margin-left:5px;font-size:14px}</style>
    <script>
        var map;
        var zoom = 12;
        function onLoad() {
            map = new T.Map('mapDiv', {
                projection: 'EPSG:4326'
            });
            map.centerAndZoom(new T.LngLat(116.40769, 39.89945), zoom);
        }
    </script>
</head>
<body onLoad="onLoad()">
<div id="mapDiv"></div>
<p>本示例演示如何显示经纬度直投地图。</p>
</body>
</html>