Kaynağa Gözat

更新同步教职工数据,公众号发消息功能

DESKTOP-3Q5PM1M\896986 1 hafta önce
ebeveyn
işleme
adbc549f62
24 değiştirilmiş dosya ile 1791 ekleme ve 106 silme
  1. 3 3
      complaint-admin/src/main/resources/application.yml
  2. 9 0
      complaint-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java
  3. 7 1
      complaint-system/src/main/resources/mapper/system/SysUserMapper.xml
  4. 35 0
      complaint-workorder/src/main/java/com/complaint/workorder/controller/common/UnitSyncController.java
  5. 10 0
      complaint-workorder/src/main/java/com/complaint/workorder/controller/web/ComplaintController.java
  6. 151 0
      complaint-workorder/src/main/java/com/complaint/workorder/controller/web/ComplaintStaffController.java
  7. 126 0
      complaint-workorder/src/main/java/com/complaint/workorder/domain/ComplaintStaff.java
  8. 26 0
      complaint-workorder/src/main/java/com/complaint/workorder/domain/StaffImportDTO.java
  9. 70 0
      complaint-workorder/src/main/java/com/complaint/workorder/mapper/ComplaintStaffMapper.java
  10. 23 10
      complaint-workorder/src/main/java/com/complaint/workorder/push/UmsSoapClient.java
  11. 47 0
      complaint-workorder/src/main/java/com/complaint/workorder/service/IComplaintStaffService.java
  12. 21 0
      complaint-workorder/src/main/java/com/complaint/workorder/service/IStaffSyncService.java
  13. 313 0
      complaint-workorder/src/main/java/com/complaint/workorder/service/impl/ComplaintStaffServiceImpl.java
  14. 3 5
      complaint-workorder/src/main/java/com/complaint/workorder/service/impl/PushServiceImpl.java
  15. 134 0
      complaint-workorder/src/main/java/com/complaint/workorder/service/impl/StaffSyncServiceImpl.java
  16. 14 3
      complaint-workorder/src/main/java/com/complaint/workorder/sync/ODataClient.java
  17. 229 0
      complaint-workorder/src/main/resources/mapper/workorder/ComplaintStaffMapper.xml
  18. 146 34
      h5-app/src/pages/ComplaintDetail.vue
  19. 2 27
      ruoyi-ui/src/api/complaint/school.js
  20. 62 0
      ruoyi-ui/src/api/complaint/staff.js
  21. 2 2
      ruoyi-ui/src/router/index.js
  22. 247 0
      ruoyi-ui/src/views/complaint/staff/index.vue
  23. 49 21
      ruoyi-ui/src/views/complaint/workorder/index.vue
  24. 62 0
      sql/ry_dm_staff.sql

+ 3 - 3
complaint-admin/src/main/resources/application.yml

@@ -169,7 +169,7 @@ xss:
 ums:
   sms:
     enabled: true                                          # 短信通道开关(东软仅支持验证码时可置 false 降级)
-    wsdl: https://www.whjyyun.cn/tp_mp/service/SmsService?wsdl2    # 测试环境;正式环境改 ?wsdl
+    wsdl: https://www.whjyyun.cn/tp_mp/service/SmsService    # 正式环境服务地址(调用方法用,不带?wsdl)
     tp-name: jytspt
     sys-id: mp
     module-id: sms
@@ -177,11 +177,11 @@ ums:
     interface-method: sms
     operator-unit-id: 4d99b438044d4238bc240b3a15a66bfb
     operator-unit-name: ''
-    send-sys-name: 乌教解忧
+    send-sys-name: 投诉平台系统
     send-priority: 2                                        # 测试期 2=验证码;东软放开后改 3=立即发送
   wechat:
     enabled: true
-    wsdl: https://www.whjyyun.cn/tp_mp/service/WechatOAService?wsdl2   # 测试环境;正式环境改 ?wsdl
+    wsdl: https://www.whjyyun.cn/tp_mp/service/WechatOAService   # 正式环境服务地址(不带?wsdl)
     tp-name: jytspt
     sys-id: mp
     module-id: wechatOA

+ 9 - 0
complaint-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java

@@ -152,4 +152,13 @@ public interface SysUserMapper
      * @return 用户列表
      */
     public List<SysUser> selectUsersByUnitUid(String unitUid);
+
+    /**
+     * 根据手机号更新学工号(教职工同步用)
+     *
+     * @param phonenumber 手机号
+     * @param staffNo     学工号(ID_NUMBER)
+     * @return 影响行数
+     */
+    public int updateStaffNoByPhone(@Param("phonenumber") String phonenumber, @Param("staffNo") String staffNo);
 }

+ 7 - 1
complaint-system/src/main/resources/mapper/system/SysUserMapper.xml

@@ -250,5 +250,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 		from sys_user
 		where del_flag = '0' and status = '0' and unit_uid = #{unitUid}
 	</select>
-	
+
+	<!-- 根据手机号更新学工号(教职工同步用:东软 RYJCSJXXZL_4 视图 ID_NUMBER 字段) -->
+	<update id="updateStaffNoByPhone">
+		update sys_user set staff_no = #{staffNo}, update_time = sysdate
+		where del_flag = '0' and phonenumber = #{phonenumber}
+	</update>
+
 </mapper> 

+ 35 - 0
complaint-workorder/src/main/java/com/complaint/workorder/controller/common/UnitSyncController.java

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import com.complaint.workorder.service.IUnitSyncService;
+import com.complaint.workorder.service.IStaffSyncService;
 
 /**
  * 东软机构数据同步接口(手动触发)
@@ -24,6 +25,9 @@ public class UnitSyncController extends BaseController {
     @Autowired
     private IUnitSyncService unitSyncService;
 
+    @Autowired
+    private IStaffSyncService staffSyncService;
+
     /**
      * 手动触发增量同步(从上次同步时间开始拉取新数据)
      * ⚠️ 此方式更安全,符合东软平台权限配置
@@ -74,4 +78,35 @@ public class UnitSyncController extends BaseController {
             return error("初始化失败:" + e.getMessage());
         }
     }
+
+    /**
+     * 手动触发教职工学工号全量同步
+     * 从东软 RYJCSJXXZL_4 视图拉取教职工,根据手机号匹配 sys_user 并更新 staff_no
+     */
+    @PostMapping("/staff/init")
+    public AjaxResult syncStaffAll() {
+        log.info("用户手动触发教职工学工号全量同步");
+        try {
+            staffSyncService.syncAll();
+            return success("教职工学工号同步已完成,请查看同步日志");
+        } catch (Exception e) {
+            log.error("手动触发教职工学工号同步失败", e);
+            return error("同步失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 手动触发教职工学工号增量同步
+     */
+    @PostMapping("/staff/daily")
+    public AjaxResult syncStaffDaily() {
+        log.info("用户手动触发教职工学工号增量同步");
+        try {
+            staffSyncService.syncDaily();
+            return success("教职工学工号同步已完成,请查看同步日志");
+        } catch (Exception e) {
+            log.error("手动触发教职工学工号同步失败", e);
+            return error("同步失败:" + e.getMessage());
+        }
+    }
 }

+ 10 - 0
complaint-workorder/src/main/java/com/complaint/workorder/controller/web/ComplaintController.java

@@ -450,6 +450,16 @@ public class ComplaintController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 学校选项列表(不分页,用于下拉框)
+     */
+    @GetMapping("/school/options")
+    public AjaxResult schoolOptions()
+    {
+        List<UnitSchool> list = unitSchoolMapper.selectUnitList(new UnitSchool());
+        return AjaxResult.success(list);
+    }
+
     @PreAuthorize("@ss.hasPermi('complaint:school:sync')")
     @Log(title = "机构数据同步", businessType = BusinessType.OTHER)
     @PostMapping("/school/sync")

+ 151 - 0
complaint-workorder/src/main/java/com/complaint/workorder/controller/web/ComplaintStaffController.java

@@ -0,0 +1,151 @@
+package com.complaint.workorder.controller.web;
+
+import java.util.List;
+import jakarta.servlet.http.HttpServletResponse;
+import com.complaint.workorder.domain.ComplaintStaff;
+import com.complaint.workorder.service.IComplaintStaffService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 教职工管理Controller
+ */
+@RestController
+@RequestMapping("/workorder/staff")
+public class ComplaintStaffController extends BaseController
+{
+    @Autowired
+    private IComplaintStaffService staffService;
+
+    /**
+     * 查询教职工列表
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(ComplaintStaff complaintStaff)
+    {
+        startPage();
+        List<ComplaintStaff> list = staffService.selectComplaintStaffList(complaintStaff);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出教职工列表
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:export')")
+    @Log(title = "教职工管理", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, ComplaintStaff complaintStaff)
+    {
+        List<ComplaintStaff> list = staffService.selectComplaintStaffList(complaintStaff);
+        ExcelUtil<ComplaintStaff> util = new ExcelUtil<ComplaintStaff>(ComplaintStaff.class);
+        util.exportExcel(response, list, "教职工数据");
+    }
+
+    /**
+     * 获取教职工详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return success(staffService.selectComplaintStaffById(id));
+    }
+
+    /**
+     * 新增教职工
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:add')")
+    @Log(title = "教职工管理", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody ComplaintStaff complaintStaff)
+    {
+        return toAjax(staffService.insertComplaintStaff(complaintStaff));
+    }
+
+    /**
+     * 修改教职工
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:edit')")
+    @Log(title = "教职工管理", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody ComplaintStaff complaintStaff)
+    {
+        return toAjax(staffService.updateComplaintStaff(complaintStaff));
+    }
+
+    /**
+     * 删除教职工
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:remove')")
+    @Log(title = "教职工管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(staffService.deleteComplaintStaffByIds(ids));
+    }
+
+    /**
+     * 从东软同步教职工数据
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:sync')")
+    @Log(title = "教职工同步", businessType = BusinessType.UPDATE)
+    @PostMapping("/sync")
+    public AjaxResult sync()
+    {
+        try
+        {
+            int count = staffService.syncFromDongruan();
+            return success("同步成功,共同步 " + count + " 条教职工数据");
+        }
+        catch (Exception e)
+        {
+            return error("同步失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 导入Excel,匹配后创建系统用户并分配学校管理员角色
+     */
+    @PreAuthorize("@ss.hasPermi('workorder:staff:import')")
+    @Log(title = "教职工导入", businessType = BusinessType.IMPORT)
+    @PostMapping("/import")
+    public AjaxResult importData(@RequestParam("file") MultipartFile file)
+    {
+        try
+        {
+            String message = staffService.importStaff(file);
+            return success(message);
+        }
+        catch (Exception e)
+        {
+            return error("导入失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 下载导入模板
+     */
+    @PostMapping("/importTemplate")
+    public void importTemplate(HttpServletResponse response)
+    {
+        ExcelUtil<ComplaintStaff> util = new ExcelUtil<ComplaintStaff>(ComplaintStaff.class);
+        util.importTemplateExcel(response, "教职工导入模板");
+    }
+}

+ 126 - 0
complaint-workorder/src/main/java/com/complaint/workorder/domain/ComplaintStaff.java

@@ -0,0 +1,126 @@
+package com.complaint.workorder.domain;
+
+import java.util.Date;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+/**
+ * 教职工信息表对象 complaint_staff
+ * <p>
+ * 从东软 RYJCSJXXZL_4 视图同步,单独存储7000+教职工数据。
+ * 导入匹配成功后再创建 sys_user 并分配学校管理员角色。
+ */
+public class ComplaintStaff extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    private Long id;
+
+    /** U_NUMBER(东软人员编号) */
+    private String uNumber;
+
+    /** 学工号/ID_NUMBER(公众号推送匹配关键字段) */
+    private String idNumber;
+
+    /** 用户UID */
+    private String userUid;
+
+    /** 姓名 */
+    private String userName;
+
+    /** 用户别名(身份证号) */
+    private String userAlias;
+
+    /** 性别(1男2女) */
+    private String userSex;
+
+    /** 手机号(匹配条件之一) */
+    private String mobile;
+
+    /** 邮箱 */
+    private String email;
+
+    /** 学校机构UID */
+    private String unitUid;
+
+    /** 学校名称(匹配条件之一) */
+    private String unitName;
+
+    /** 身份类型编码 */
+    private String idType;
+
+    /** 身份类型名称(教职工/学生等) */
+    private String idTypeName;
+
+    /** 是否激活(1激活0未激活) */
+    private String isActive;
+
+    /** 激活状态名称 */
+    private String isActiveName;
+
+    /** 所属学校 */
+    private String belongSchool;
+
+    /** 所属班级 */
+    private String belongClass;
+
+    /** 生效开始时间 */
+    private String beginTime;
+
+    /** 生效结束时间 */
+    private String endTime;
+
+    /** 东软数据更新时间 */
+    private Date dataUpdateTime;
+
+    /** 增量标识(I新增U更新D删除) */
+    private String dataUpdateFlog;
+
+    /** 本地同步时间 */
+    private Date syncTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getUNumber() { return uNumber; }
+    public void setUNumber(String uNumber) { this.uNumber = uNumber; }
+    public String getIdNumber() { return idNumber; }
+    public void setIdNumber(String idNumber) { this.idNumber = idNumber; }
+    public String getUserUid() { return userUid; }
+    public void setUserUid(String userUid) { this.userUid = userUid; }
+    public String getUserName() { return userName; }
+    public void setUserName(String userName) { this.userName = userName; }
+    public String getUserAlias() { return userAlias; }
+    public void setUserAlias(String userAlias) { this.userAlias = userAlias; }
+    public String getUserSex() { return userSex; }
+    public void setUserSex(String userSex) { this.userSex = userSex; }
+    public String getMobile() { return mobile; }
+    public void setMobile(String mobile) { this.mobile = mobile; }
+    public String getEmail() { return email; }
+    public void setEmail(String email) { this.email = email; }
+    public String getUnitUid() { return unitUid; }
+    public void setUnitUid(String unitUid) { this.unitUid = unitUid; }
+    public String getUnitName() { return unitName; }
+    public void setUnitName(String unitName) { this.unitName = unitName; }
+    public String getIdType() { return idType; }
+    public void setIdType(String idType) { this.idType = idType; }
+    public String getIdTypeName() { return idTypeName; }
+    public void setIdTypeName(String idTypeName) { this.idTypeName = idTypeName; }
+    public String getIsActive() { return isActive; }
+    public void setIsActive(String isActive) { this.isActive = isActive; }
+    public String getIsActiveName() { return isActiveName; }
+    public void setIsActiveName(String isActiveName) { this.isActiveName = isActiveName; }
+    public String getBelongSchool() { return belongSchool; }
+    public void setBelongSchool(String belongSchool) { this.belongSchool = belongSchool; }
+    public String getBelongClass() { return belongClass; }
+    public void setBelongClass(String belongClass) { this.belongClass = belongClass; }
+    public String getBeginTime() { return beginTime; }
+    public void setBeginTime(String beginTime) { this.beginTime = beginTime; }
+    public String getEndTime() { return endTime; }
+    public void setEndTime(String endTime) { this.endTime = endTime; }
+    public Date getDataUpdateTime() { return dataUpdateTime; }
+    public void setDataUpdateTime(Date dataUpdateTime) { this.dataUpdateTime = dataUpdateTime; }
+    public String getDataUpdateFlog() { return dataUpdateFlog; }
+    public void setDataUpdateFlog(String dataUpdateFlog) { this.dataUpdateFlog = dataUpdateFlog; }
+    public Date getSyncTime() { return syncTime; }
+    public void setSyncTime(Date syncTime) { this.syncTime = syncTime; }
+}

+ 26 - 0
complaint-workorder/src/main/java/com/complaint/workorder/domain/StaffImportDTO.java

@@ -0,0 +1,26 @@
+package com.complaint.workorder.domain;
+
+import com.ruoyi.common.annotation.Excel;
+
+/**
+ * 教职工导入DTO(Excel导入用)
+ * 匹配条件:姓名 + 所属学校 + 手机号码
+ */
+public class StaffImportDTO
+{
+    @Excel(name = "姓名")
+    private String userName;
+
+    @Excel(name = "所属学校")
+    private String unitName;
+
+    @Excel(name = "手机号码")
+    private String mobile;
+
+    public String getUserName() { return userName; }
+    public void setUserName(String userName) { this.userName = userName; }
+    public String getUnitName() { return unitName; }
+    public void setUnitName(String unitName) { this.unitName = unitName; }
+    public String getMobile() { return mobile; }
+    public void setMobile(String mobile) { this.mobile = mobile; }
+}

+ 70 - 0
complaint-workorder/src/main/java/com/complaint/workorder/mapper/ComplaintStaffMapper.java

@@ -0,0 +1,70 @@
+package com.complaint.workorder.mapper;
+
+import java.util.List;
+import com.complaint.workorder.domain.ComplaintStaff;
+
+/**
+ * 教职工信息表 Mapper 接口
+ */
+public interface ComplaintStaffMapper
+{
+    /**
+     * 查询教职工信息列表
+     */
+    public List<ComplaintStaff> selectComplaintStaffList(ComplaintStaff complaintStaff);
+
+    /**
+     * 根据ID查询教职工
+     */
+    public ComplaintStaff selectComplaintStaffById(Long id);
+
+    /**
+     * 根据手机号查询教职工
+     */
+    public ComplaintStaff selectComplaintStaffByMobile(String mobile);
+
+    /**
+     * 根据姓名+学校+手机号匹配教职工(导入用)
+     */
+    public ComplaintStaff matchStaff(ComplaintStaff staff);
+
+    /**
+     * 新增教职工
+     */
+    public int insertComplaintStaff(ComplaintStaff complaintStaff);
+
+    /**
+     * 修改教职工
+     */
+    public int updateComplaintStaff(ComplaintStaff complaintStaff);
+
+    /**
+     * 根据 user_uid 更新(同步用,存在则更新)
+     */
+    public int updateByUserUid(ComplaintStaff complaintStaff);
+
+    /**
+     * 根据 user_uid 查询是否存在
+     */
+    public ComplaintStaff selectByUserUid(String userUid);
+
+    /**
+     * 删除教职工
+     */
+    public int deleteComplaintStaffById(Long id);
+
+    /**
+     * 批量删除教职工
+     */
+    public int deleteComplaintStaffByIds(Long[] ids);
+
+    /**
+     * 批量插入(大数据量同步用)
+     */
+    public int batchInsert(List<ComplaintStaff> list);
+
+    /**
+     * 清空表(全量同步前调用)
+     */
+    public int truncateTable();
+}

+ 23 - 10
complaint-workorder/src/main/java/com/complaint/workorder/push/UmsSoapClient.java

@@ -32,7 +32,7 @@ public class UmsSoapClient
             + "<soapenv:Header/>"
             + "<soapenv:Body>"
             + "<web:%s>"
-            + "<arg0>%s</arg0>"
+            + "<in0>%s</in0>"
             + "</web:%s>"
             + "</soapenv:Body>"
             + "</soapenv:Envelope>";
@@ -120,7 +120,8 @@ public class UmsSoapClient
     }
 
     /**
-     * 从 SOAP 响应中提取 return 元素内容(兼容多种前缀:return / ns2:return / result)
+     * 从 SOAP 响应中提取返回内容(兼容多种元素名:out / return / ns2:out / result)
+     * 东软平台实际返回元素为 &lt;ns1:out&gt;,非 &lt;return&gt;
      */
     private String extractReturn(String responseXml)
     {
@@ -128,17 +129,29 @@ public class UmsSoapClient
         {
             throw new IllegalStateException("统一通讯平台返回为空");
         }
-        int idx = responseXml.indexOf("return");
-        if (idx < 0)
+        // 依次尝试 out、return、result 三种元素名
+        String[] tags = {"<out>", "<ns1:out>", "<ns2:out>", "<return>", "<ns1:return>", "<ns2:return>", "<result>"};
+        int start = -1;
+        int endTag = -1;
+        for (String openTag : tags)
         {
-            throw new IllegalStateException("统一通讯平台响应中未找到 return:" + responseXml);
+            int idx = responseXml.indexOf(openTag);
+            if (idx >= 0)
+            {
+                int gt = responseXml.indexOf('>', idx);
+                start = gt + 1;
+                // 构造对应的闭合标签
+                String closeTag = openTag.replace("<", "</").replace(">", ">");
+                endTag = responseXml.indexOf(closeTag, start);
+                if (start > 0 && endTag > 0)
+                {
+                    break;
+                }
+            }
         }
-        int gt = responseXml.indexOf('>', idx);
-        int start = gt + 1;
-        int endTag = responseXml.indexOf("</", start);
-        if (gt < 0 || endTag < 0)
+        if (start < 0 || endTag < 0)
         {
-            throw new IllegalStateException("统一通讯平台响应解析失败:" + responseXml);
+            throw new IllegalStateException("统一通讯平台响应中未找到 out/return:" + responseXml);
         }
         String content = responseXml.substring(start, endTag);
         // XML 实体反转义

+ 47 - 0
complaint-workorder/src/main/java/com/complaint/workorder/service/IComplaintStaffService.java

@@ -0,0 +1,47 @@
+package com.complaint.workorder.service;
+
+import java.util.List;
+import org.springframework.web.multipart.MultipartFile;
+import com.complaint.workorder.domain.ComplaintStaff;
+
+/**
+ * 教职工信息Service接口
+ */
+public interface IComplaintStaffService
+{
+    /**
+     * 查询教职工列表
+     */
+    public List<ComplaintStaff> selectComplaintStaffList(ComplaintStaff complaintStaff);
+
+    /**
+     * 查询教职工详情
+     */
+    public ComplaintStaff selectComplaintStaffById(Long id);
+
+    /**
+     * 新增教职工
+     */
+    public int insertComplaintStaff(ComplaintStaff complaintStaff);
+
+    /**
+     * 修改教职工
+     */
+    public int updateComplaintStaff(ComplaintStaff complaintStaff);
+
+    /**
+     * 删除教职工
+     */
+    public int deleteComplaintStaffByIds(Long[] ids);
+
+    /**
+     * 从东软同步教职工数据(全量)
+     */
+    public int syncFromDongruan();
+
+    /**
+     * 导入Excel,匹配后创建系统用户并分配学校管理员角色
+     * 匹配条件:姓名 + 所属学校 + 手机号码
+     */
+    public String importStaff(MultipartFile file) throws Exception;
+}

+ 21 - 0
complaint-workorder/src/main/java/com/complaint/workorder/service/IStaffSyncService.java

@@ -0,0 +1,21 @@
+package com.complaint.workorder.service;
+
+/**
+ * 教职工学工号同步服务接口
+ * <p>
+ * 从东软数据平台 RYJCSJXXZL_4 视图拉取教职工数据,
+ * 根据手机号匹配 sys_user 并更新 staff_no 学工号,
+ * 用于公众号消息推送(公众号通过学工号匹配接收人)。
+ */
+public interface IStaffSyncService
+{
+    /**
+     * 全量同步教职工学工号
+     */
+    void syncAll();
+
+    /**
+     * 每日增量同步教职工学工号
+     */
+    void syncDaily();
+}

+ 313 - 0
complaint-workorder/src/main/java/com/complaint/workorder/service/impl/ComplaintStaffServiceImpl.java

@@ -0,0 +1,313 @@
+package com.complaint.workorder.service.impl;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import com.alibaba.fastjson2.JSONObject;
+import com.complaint.workorder.domain.ComplaintStaff;
+import com.complaint.workorder.domain.StaffImportDTO;
+import com.complaint.workorder.mapper.ComplaintStaffMapper;
+import com.complaint.workorder.service.IComplaintStaffService;
+import com.complaint.workorder.sync.ODataClient;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.entity.SysRole;
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.system.mapper.SysRoleMapper;
+import com.ruoyi.system.mapper.SysUserMapper;
+import com.ruoyi.system.service.ISysUserService;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 教职工信息Service实现
+ */
+@Service
+public class ComplaintStaffServiceImpl implements IComplaintStaffService
+{
+    private static final Logger log = LoggerFactory.getLogger(ComplaintStaffServiceImpl.class);
+
+    private static final String INTERFACE_NAME = "RYJCSJXXZL_4";
+    private static final String FILTER = "ID_TYPE_NAME eq '教职工'";
+    private static final String SCHOOL_ADMIN_ROLE_NAME = "学校管理员";
+    private static final String DEFAULT_PASSWORD = "123456";
+
+    @Autowired
+    private ComplaintStaffMapper staffMapper;
+
+    @Autowired
+    private ODataClient oDataClient;
+
+    @Autowired
+    private ISysUserService sysUserService;
+
+    @Autowired
+    private SysUserMapper sysUserMapper;
+
+    @Autowired
+    private SysRoleMapper sysRoleMapper;
+
+    @Override
+    public List<ComplaintStaff> selectComplaintStaffList(ComplaintStaff complaintStaff)
+    {
+        return staffMapper.selectComplaintStaffList(complaintStaff);
+    }
+
+    @Override
+    public ComplaintStaff selectComplaintStaffById(Long id)
+    {
+        return staffMapper.selectComplaintStaffById(id);
+    }
+
+    @Override
+    public int insertComplaintStaff(ComplaintStaff complaintStaff)
+    {
+        return staffMapper.insertComplaintStaff(complaintStaff);
+    }
+
+    @Override
+    public int updateComplaintStaff(ComplaintStaff complaintStaff)
+    {
+        return staffMapper.updateComplaintStaff(complaintStaff);
+    }
+
+    @Override
+    public int deleteComplaintStaffByIds(Long[] ids)
+    {
+        return staffMapper.deleteComplaintStaffByIds(ids);
+    }
+
+    /**
+     * 从东软同步教职工数据(全量)
+     * 清空表后重新拉取插入
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int syncFromDongruan()
+    {
+        try
+        {
+            log.info("开始从东软同步教职工数据...");
+            List<JSONObject> rows = oDataClient.fetchAll(INTERFACE_NAME, FILTER);
+            log.info("从东软拉取到 {} 条教职工记录", rows.size());
+
+            // 清空表
+            staffMapper.truncateTable();
+
+            int success = 0;
+            int fail = 0;
+            Date now = new Date();
+            final int batchSize = 500;
+            List<ComplaintStaff> batch = new ArrayList<>();
+
+            for (JSONObject row : rows)
+            {
+                try
+                {
+                    ComplaintStaff staff = toStaff(row);
+                    staff.setSyncTime(now);
+                    batch.add(staff);
+                    if (batch.size() >= batchSize)
+                    {
+                        staffMapper.batchInsert(batch);
+                        success += batch.size();
+                        batch.clear();
+                        log.info("教职工批量插入:已插入 {} 条", success);
+                    }
+                }
+                catch (Exception e)
+                {
+                    fail++;
+                    log.warn("教职工数据入库失败 userUid={}", row.getString("USER_UID"), e);
+                }
+            }
+            // 插入最后一批
+            if (!batch.isEmpty())
+            {
+                try
+                {
+                    staffMapper.batchInsert(batch);
+                    success += batch.size();
+                }
+                catch (Exception e)
+                {
+                    fail += batch.size();
+                    log.error("教职工最后一批插入失败", e);
+                }
+            }
+
+            log.info("教职工同步完成:成功={} 失败={}/总 {}", success, fail, rows.size());
+            return success;
+        }
+        catch (Exception e)
+        {
+            log.error("从东软同步教职工数据失败", e);
+            throw new RuntimeException("同步失败:" + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 导入Excel,匹配后创建系统用户并分配学校管理员角色
+     * 匹配条件:姓名 + 所属学校 + 手机号码
+     */
+    @Override
+    public String importStaff(MultipartFile file) throws Exception
+    {
+        ExcelUtil<StaffImportDTO> util = new ExcelUtil<StaffImportDTO>(StaffImportDTO.class);
+        List<StaffImportDTO> importList = util.importExcel(file.getInputStream());
+
+        if (importList == null || importList.isEmpty())
+        {
+            return "导入数据为空";
+        }
+
+        // 查询学校管理员角色ID
+        Long schoolAdminRoleId = getSchoolAdminRoleId();
+        if (schoolAdminRoleId == null)
+        {
+            return "未找到【学校管理员】角色,请先在系统管理中创建该角色";
+        }
+
+        int success = 0;
+        int fail = 0;
+        int notFound = 0;
+        int alreadyExists = 0;
+        List<String> failMsgs = new ArrayList<>();
+
+        for (int i = 0; i < importList.size(); i++)
+        {
+            StaffImportDTO dto = importList.get(i);
+            int rowNum = i + 1;
+
+            if (StringUtils.isBlank(dto.getUserName()) || StringUtils.isBlank(dto.getMobile()))
+            {
+                fail++;
+                failMsgs.add("第" + rowNum + "行:姓名或手机号为空");
+                continue;
+            }
+
+            try
+            {
+                // 根据姓名+学校+手机号匹配教职工表
+                ComplaintStaff match = new ComplaintStaff();
+                match.setUserName(dto.getUserName().trim());
+                match.setMobile(dto.getMobile().trim());
+                match.setUnitName(StringUtils.isBlank(dto.getUnitName()) ? "" : dto.getUnitName().trim());
+                ComplaintStaff staff = staffMapper.matchStaff(match);
+
+                if (staff == null)
+                {
+                    notFound++;
+                    failMsgs.add("第" + rowNum + "行:未匹配到教职工(姓名=" + dto.getUserName() + ",手机=" + dto.getMobile() + ")");
+                    continue;
+                }
+
+                // 检查系统用户是否已存在
+                SysUser existUser = sysUserMapper.checkPhoneUnique(dto.getMobile().trim());
+                if (existUser != null)
+                {
+                    // 已存在,更新学工号和学校UID
+                    existUser.setStaffNo(staff.getIdNumber());
+                    existUser.setUnitUid(staff.getUnitUid());
+                    sysUserMapper.updateUser(existUser);
+                    alreadyExists++;
+                    success++;
+                    continue;
+                }
+
+                // 创建系统用户
+                SysUser user = new SysUser();
+                user.setUserName(dto.getMobile().trim());  // 用户名用手机号
+                user.setNickName(staff.getUserName());
+                user.setPhonenumber(dto.getMobile().trim());
+                user.setPassword(SecurityUtils.encryptPassword(DEFAULT_PASSWORD));
+                user.setUnitUid(staff.getUnitUid());
+                user.setStaffNo(staff.getIdNumber());
+                user.setStatus("0");  // 正常
+                user.setRoleIds(new Long[]{schoolAdminRoleId});
+                user.setCreateBy("import");
+
+                int rows = sysUserService.insertUser(user);
+                if (rows > 0)
+                {
+                    success++;
+                }
+                else
+                {
+                    fail++;
+                    failMsgs.add("第" + rowNum + "行:创建用户失败");
+                }
+            }
+            catch (Exception e)
+            {
+                fail++;
+                failMsgs.add("第" + rowNum + "行:" + e.getMessage());
+                log.error("导入教职工失败 row={}", rowNum, e);
+            }
+        }
+
+        StringBuilder result = new StringBuilder();
+        result.append("导入完成:成功").append(success).append("条");
+        result.append("(其中已存在更新").append(alreadyExists).append("条)");
+        result.append(",未匹配").append(notFound).append("条");
+        result.append(",失败").append(fail).append("条");
+        if (!failMsgs.isEmpty() && failMsgs.size() <= 10)
+        {
+            result.append("。失败详情:").append(String.join(";", failMsgs));
+        }
+        else if (failMsgs.size() > 10)
+        {
+            result.append("。失败详情过多,请查看日志");
+        }
+        return result.toString();
+    }
+
+    /**
+     * 查询学校管理员角色ID
+     */
+    private Long getSchoolAdminRoleId()
+    {
+        SysRole query = new SysRole();
+        query.setRoleName(SCHOOL_ADMIN_ROLE_NAME);
+        List<SysRole> roles = sysRoleMapper.selectRoleList(query);
+        if (roles != null && !roles.isEmpty())
+        {
+            return roles.get(0).getRoleId();
+        }
+        return null;
+    }
+
+    /**
+     * 东软数据转教职工实体
+     */
+    private ComplaintStaff toStaff(JSONObject row)
+    {
+        ComplaintStaff staff = new ComplaintStaff();
+        staff.setUNumber(row.getString("U_NUMBER"));
+        staff.setIdNumber(row.getString("ID_NUMBER"));
+        staff.setUserUid(row.getString("USER_UID"));
+        staff.setUserName(row.getString("USER_NAME"));
+        staff.setUserAlias(row.getString("USER_ALIAS"));
+        staff.setUserSex(row.getString("USER_SEX"));
+        staff.setMobile(row.getString("MOBILE"));
+        staff.setEmail(row.getString("EMAIL"));
+        staff.setUnitUid(row.getString("UNIT_UID"));
+        staff.setUnitName(row.getString("UNIT_NAME"));
+        staff.setIdType(row.getString("ID_TYPE"));
+        staff.setIdTypeName(row.getString("ID_TYPE_NAME"));
+        staff.setIsActive(row.getString("IS_ACTIVE"));
+        staff.setIsActiveName(row.getString("IS_ACTIVE_NAME"));
+        staff.setBelongSchool(row.getString("BELONG_SCHOOL"));
+        staff.setBelongClass(row.getString("BELONG_CLASS"));
+        staff.setBeginTime(row.getString("BEGIN_TIME"));
+        staff.setEndTime(row.getString("END_TIME"));
+        staff.setDataUpdateFlog(row.getString("DATA_UPDATE_FLOG"));
+        return staff;
+    }
+}

+ 3 - 5
complaint-workorder/src/main/java/com/complaint/workorder/service/impl/PushServiceImpl.java

@@ -301,7 +301,7 @@ public class PushServiceImpl implements IPushService
             log.warn("工单 {} 群众未留手机号,跳过短信推送", workorder.getComplaintNo());
             return;
         }
-        String receiverInfo = StringUtils.defaultString(workorder.getName()) + "||||" + workorder.getPhone();
+        String receiverInfo = "||||" + workorder.getPhone();
         sendSmsWithLog(workorder.getComplaintNo(), null, content, receiverInfo);
     }
 
@@ -332,8 +332,7 @@ public class PushServiceImpl implements IPushService
      */
     private String buildPersonInfo(SysUser admin)
     {
-        return StringUtils.defaultString(admin.getNickName()) + "|" + StringUtils.defaultString(admin.getStaffNo())
-                + "||" + StringUtils.defaultString(admin.getUnitUid()) + "|" + StringUtils.defaultString(admin.getPhonenumber());
+        return "||||" + StringUtils.defaultString(admin.getPhonenumber());
     }
 
     /**
@@ -341,8 +340,7 @@ public class PushServiceImpl implements IPushService
      */
     private String buildReceiveInfo(SysUser admin)
     {
-        return StringUtils.defaultString(admin.getNickName()) + "|" + StringUtils.defaultString(admin.getStaffNo())
-                + "||" + StringUtils.defaultString(admin.getUnitUid());
+        return "|" + StringUtils.defaultString(admin.getStaffNo()) + "|||";
     }
 
     /**

+ 134 - 0
complaint-workorder/src/main/java/com/complaint/workorder/service/impl/StaffSyncServiceImpl.java

@@ -0,0 +1,134 @@
+package com.complaint.workorder.service.impl;
+
+import java.util.List;
+import com.alibaba.fastjson2.JSONObject;
+import com.complaint.workorder.config.DsProperties;
+import com.complaint.workorder.constant.ComplaintConstants;
+import com.complaint.workorder.domain.DataSyncLog;
+import com.complaint.workorder.mapper.DataSyncLogMapper;
+import com.complaint.workorder.service.IStaffSyncService;
+import com.complaint.workorder.sync.ODataClient;
+import com.ruoyi.system.mapper.SysUserMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+/**
+ * 教职工学工号同步服务实现
+ * <p>
+ * 从东软数据平台 RYJCSJXXZL_4 视图拉取教职工数据(过滤 ID_TYPE_NAME eq '教职工'),
+ * 根据 MOBILE 手机号匹配 sys_user 表,更新 staff_no 字段为 ID_NUMBER。
+ * 公众号消息推送通过学工号匹配接收人,因此需要先同步学工号。
+ * <p>
+ * 字段映射:
+ * - 学工号 → ID_NUMBER
+ * - 姓名 → USER_NAME
+ * - 手机号 → MOBILE
+ * - 学校UID → UNIT_UID
+ * - 学校名称 → UNIT_NAME
+ * - 身份证号 → USER_ALIAS
+ */
+@Service
+public class StaffSyncServiceImpl implements IStaffSyncService
+{
+    private static final Logger log = LoggerFactory.getLogger(StaffSyncServiceImpl.class);
+
+    private static final String INTERFACE_NAME = "RYJCSJXXZL_4";
+    private static final String FILTER = "ID_TYPE_NAME eq '教职工'";
+
+    private final ODataClient odataClient;
+    private final SysUserMapper sysUserMapper;
+    private final DataSyncLogMapper syncLogMapper;
+    private final DsProperties properties;
+
+    public StaffSyncServiceImpl(ODataClient odataClient, SysUserMapper sysUserMapper,
+            DataSyncLogMapper syncLogMapper, DsProperties properties)
+    {
+        this.odataClient = odataClient;
+        this.sysUserMapper = sysUserMapper;
+        this.syncLogMapper = syncLogMapper;
+        this.properties = properties;
+    }
+
+    @Override
+    public void syncAll()
+    {
+        sync("init");
+    }
+
+    @Override
+    public void syncDaily()
+    {
+        sync("daily");
+    }
+
+    /**
+     * 数据同步(全量/增量)
+     * RYJCSJXXZL_4 视图有 DATA_UPDATE_TIME 字段,支持增量过滤;
+     * 但为简化实现,当前全量拉取后按手机号 upsert。
+     */
+    private void sync(String syncType)
+    {
+        DataSyncLog syncLog = new DataSyncLog();
+        syncLog.setSyncType(syncType);
+        syncLog.setInterfaceName(INTERFACE_NAME);
+
+        try
+        {
+            log.info("教职工学工号同步开始 type={}", syncType);
+            List<JSONObject> rows = odataClient.fetchAll(INTERFACE_NAME, FILTER);
+            log.info("从东软拉取到 {} 条教职工记录", rows.size());
+
+            int success = 0;
+            int fail = 0;
+            int skip = 0;
+
+            for (int i = 0; i < rows.size(); i++)
+            {
+                try
+                {
+                    JSONObject row = rows.get(i);
+                    String mobile = row.getString("MOBILE");
+                    String staffNo = row.getString("ID_NUMBER");
+                    String userName = row.getString("USER_NAME");
+
+                    if (StringUtils.isBlank(mobile) || StringUtils.isBlank(staffNo))
+                    {
+                        skip++;
+                        continue;
+                    }
+
+                    int affected = sysUserMapper.updateStaffNoByPhone(mobile, staffNo);
+                    if (affected > 0)
+                    {
+                        success++;
+                        log.debug("更新学工号成功 mobile={} name={} staffNo={}", mobile, userName, staffNo);
+                    }
+                    else
+                    {
+                        skip++;
+                    }
+                }
+                catch (Exception e)
+                {
+                    fail++;
+                    log.warn("教职工学工号同步失败 index={}", i, e);
+                }
+            }
+
+            log.info("教职工学工号同步完成:成功={} 失败={} 跳过={}/总 {}", success, fail, skip, rows.size());
+            syncLog.setTotal(rows.size());
+            syncLog.setSuccess(success);
+            syncLog.setFail(fail);
+            syncLog.setStatus(1);
+        }
+        catch (Exception e)
+        {
+            log.error("教职工学工号同步失败 type={}", syncType, e);
+            syncLog.setStatus(0);
+            syncLog.setErrorMsg(e.getMessage() == null ? "未知错误" : e.getMessage());
+        }
+        syncLogMapper.insertDataSyncLog(syncLog);
+    }
+}

+ 14 - 3
complaint-workorder/src/main/java/com/complaint/workorder/sync/ODataClient.java

@@ -7,6 +7,7 @@ import java.net.HttpURLConnection;
 import java.net.URL;
 import org.apache.commons.httpclient.util.URIUtil;
 import java.nio.charset.StandardCharsets;
+import java.net.URLEncoder;
 import java.security.MessageDigest;
 import java.util.ArrayList;
 import java.util.List;
@@ -147,11 +148,21 @@ public class ODataClient
             sb.append('/');
         }
         sb.append(interfaceName);
-        sb.append("?$top=").append(top).append("&$skip=").append(skip);
-        sb.append("&$count=true");
+        // $ 编码为 %24,与东软平台测试成功的链接格式一致
+        sb.append("?%24count=true&%24top=").append(top).append("&%24skip=").append(skip);
         if (StringUtils.isNotBlank(filter))
         {
-            sb.append("&$filter=").append(filter);
+            try
+            {
+                // URLEncoder 会把空格编码为 +,OData filter 中空格需用 %20
+                String encodedFilter = URLEncoder.encode(filter, StandardCharsets.UTF_8.name())
+                        .replace("+", "%20");
+                sb.append("&%24filter=").append(encodedFilter);
+            }
+            catch (Exception e)
+            {
+                sb.append("&%24filter=").append(filter);
+            }
         }
         return sb.toString();
     }

+ 229 - 0
complaint-workorder/src/main/resources/mapper/workorder/ComplaintStaffMapper.xml

@@ -0,0 +1,229 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.complaint.workorder.mapper.ComplaintStaffMapper">
+
+    <resultMap type="com.complaint.workorder.domain.ComplaintStaff" id="ComplaintStaffResult">
+        <id     property="id"               column="id"               />
+        <result property="uNumber"          column="u_number"         />
+        <result property="idNumber"         column="id_number"        />
+        <result property="userUid"          column="user_uid"         />
+        <result property="userName"         column="user_name"        />
+        <result property="userAlias"        column="user_alias"       />
+        <result property="userSex"          column="user_sex"         />
+        <result property="mobile"           column="mobile"           />
+        <result property="email"            column="email"            />
+        <result property="unitUid"          column="unit_uid"         />
+        <result property="unitName"         column="unit_name"        />
+        <result property="idType"           column="id_type"          />
+        <result property="idTypeName"       column="id_type_name"     />
+        <result property="isActive"         column="is_active"        />
+        <result property="isActiveName"     column="is_active_name"   />
+        <result property="belongSchool"     column="belong_school"    />
+        <result property="belongClass"      column="belong_class"     />
+        <result property="beginTime"        column="begin_time"       />
+        <result property="endTime"          column="end_time"         />
+        <result property="dataUpdateTime"   column="data_update_time" />
+        <result property="dataUpdateFlog"   column="data_update_flog" />
+        <result property="syncTime"         column="sync_time"        />
+        <result property="createBy"         column="create_by"        />
+        <result property="createTime"       column="create_time"      />
+        <result property="updateBy"         column="update_by"        />
+        <result property="updateTime"       column="update_time"      />
+        <result property="remark"           column="remark"           />
+    </resultMap>
+
+    <sql id="selectComplaintStaffVo">
+        select id, u_number, id_number, user_uid, user_name, user_alias, user_sex, mobile, email,
+               unit_uid, unit_name, id_type, id_type_name, is_active, is_active_name,
+               belong_school, belong_class, begin_time, end_time, data_update_time, data_update_flog,
+               sync_time, create_by, create_time, update_by, update_time, remark
+        from complaint_staff
+    </sql>
+
+    <select id="selectComplaintStaffList" parameterType="com.complaint.workorder.domain.ComplaintStaff" resultMap="ComplaintStaffResult">
+        <include refid="selectComplaintStaffVo"/>
+        <where>
+            <if test="userName != null  and userName != ''"> and user_name like concat('%', #{userName}, '%')</if>
+            <if test="mobile != null  and mobile != ''"> and mobile = #{mobile}</if>
+            <if test="unitUid != null  and unitUid != ''"> and unit_uid = #{unitUid}</if>
+            <if test="unitName != null  and unitName != ''"> and unit_name like concat('%', #{unitName}, '%')</if>
+            <if test="idNumber != null  and idNumber != ''"> and id_number = #{idNumber}</if>
+            <if test="idTypeName != null  and idTypeName != ''"> and id_type_name = #{idTypeName}</if>
+            <if test="isActive != null  and isActive != ''"> and is_active = #{isActive}</if>
+        </where>
+        order by unit_name, id
+    </select>
+
+    <select id="selectComplaintStaffById" parameterType="Long" resultMap="ComplaintStaffResult">
+        <include refid="selectComplaintStaffVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectComplaintStaffByMobile" parameterType="String" resultMap="ComplaintStaffResult">
+        <include refid="selectComplaintStaffVo"/>
+        where mobile = #{mobile}
+        fetch first 1 rows only
+    </select>
+
+    <select id="matchStaff" parameterType="com.complaint.workorder.domain.ComplaintStaff" resultMap="ComplaintStaffResult">
+        <include refid="selectComplaintStaffVo"/>
+        where user_name = #{userName}
+          and mobile = #{mobile}
+          and (unit_name = #{unitName} or belong_school = #{unitName})
+        fetch first 1 rows only
+    </select>
+
+    <select id="selectByUserUid" parameterType="String" resultMap="ComplaintStaffResult">
+        <include refid="selectComplaintStaffVo"/>
+        where user_uid = #{userUid}
+        fetch first 1 rows only
+    </select>
+
+    <insert id="insertComplaintStaff" parameterType="com.complaint.workorder.domain.ComplaintStaff">
+        insert into complaint_staff
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="uNumber != null">u_number,</if>
+            <if test="idNumber != null">id_number,</if>
+            <if test="userUid != null">user_uid,</if>
+            <if test="userName != null">user_name,</if>
+            <if test="userAlias != null">user_alias,</if>
+            <if test="userSex != null">user_sex,</if>
+            <if test="mobile != null">mobile,</if>
+            <if test="email != null">email,</if>
+            <if test="unitUid != null">unit_uid,</if>
+            <if test="unitName != null">unit_name,</if>
+            <if test="idType != null">id_type,</if>
+            <if test="idTypeName != null">id_type_name,</if>
+            <if test="isActive != null">is_active,</if>
+            <if test="isActiveName != null">is_active_name,</if>
+            <if test="belongSchool != null">belong_school,</if>
+            <if test="belongClass != null">belong_class,</if>
+            <if test="beginTime != null">begin_time,</if>
+            <if test="endTime != null">end_time,</if>
+            <if test="dataUpdateTime != null">data_update_time,</if>
+            <if test="dataUpdateFlog != null">data_update_flog,</if>
+            <if test="syncTime != null">sync_time,</if>
+            <if test="createBy != null">create_by,</if>
+            create_time,
+            <if test="remark != null">remark,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="uNumber != null">#{uNumber},</if>
+            <if test="idNumber != null">#{idNumber},</if>
+            <if test="userUid != null">#{userUid},</if>
+            <if test="userName != null">#{userName},</if>
+            <if test="userAlias != null">#{userAlias},</if>
+            <if test="userSex != null">#{userSex},</if>
+            <if test="mobile != null">#{mobile},</if>
+            <if test="email != null">#{email},</if>
+            <if test="unitUid != null">#{unitUid},</if>
+            <if test="unitName != null">#{unitName},</if>
+            <if test="idType != null">#{idType},</if>
+            <if test="idTypeName != null">#{idTypeName},</if>
+            <if test="isActive != null">#{isActive},</if>
+            <if test="isActiveName != null">#{isActiveName},</if>
+            <if test="belongSchool != null">#{belongSchool},</if>
+            <if test="belongClass != null">#{belongClass},</if>
+            <if test="beginTime != null">#{beginTime},</if>
+            <if test="endTime != null">#{endTime},</if>
+            <if test="dataUpdateTime != null">#{dataUpdateTime},</if>
+            <if test="dataUpdateFlog != null">#{dataUpdateFlog},</if>
+            <if test="syncTime != null">#{syncTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            sysdate,
+            <if test="remark != null">#{remark},</if>
+        </trim>
+    </insert>
+
+    <!-- 批量插入(大数据量同步用,每批500条) -->
+    <insert id="batchInsert" parameterType="java.util.List">
+        insert into complaint_staff (
+            u_number, id_number, user_uid, user_name, user_alias, user_sex, mobile, email,
+            unit_uid, unit_name, id_type, id_type_name, is_active, is_active_name,
+            belong_school, belong_class, begin_time, end_time, data_update_flog, sync_time, create_time
+        ) values
+        <foreach collection="list" item="item" separator=",">
+            (
+                #{item.uNumber}, #{item.idNumber}, #{item.userUid}, #{item.userName}, #{item.userAlias},
+                #{item.userSex}, #{item.mobile}, #{item.email}, #{item.unitUid}, #{item.unitName},
+                #{item.idType}, #{item.idTypeName}, #{item.isActive}, #{item.isActiveName},
+                #{item.belongSchool}, #{item.belongClass}, #{item.beginTime}, #{item.endTime},
+                #{item.dataUpdateFlog}, #{item.syncTime}, sysdate
+            )
+        </foreach>
+    </insert>
+
+    <update id="updateComplaintStaff" parameterType="com.complaint.workorder.domain.ComplaintStaff">
+        update complaint_staff
+        <set>
+            <if test="uNumber != null">u_number = #{uNumber},</if>
+            <if test="idNumber != null">id_number = #{idNumber},</if>
+            <if test="userUid != null">user_uid = #{userUid},</if>
+            <if test="userName != null">user_name = #{userName},</if>
+            <if test="userAlias != null">user_alias = #{userAlias},</if>
+            <if test="userSex != null">user_sex = #{userSex},</if>
+            <if test="mobile != null">mobile = #{mobile},</if>
+            <if test="email != null">email = #{email},</if>
+            <if test="unitUid != null">unit_uid = #{unitUid},</if>
+            <if test="unitName != null">unit_name = #{unitName},</if>
+            <if test="idType != null">id_type = #{idType},</if>
+            <if test="idTypeName != null">id_type_name = #{idTypeName},</if>
+            <if test="isActive != null">is_active = #{isActive},</if>
+            <if test="isActiveName != null">is_active_name = #{isActiveName},</if>
+            <if test="belongSchool != null">belong_school = #{belongSchool},</if>
+            <if test="belongClass != null">belong_class = #{belongClass},</if>
+            <if test="beginTime != null">begin_time = #{beginTime},</if>
+            <if test="endTime != null">end_time = #{endTime},</if>
+            <if test="dataUpdateTime != null">data_update_time = #{dataUpdateTime},</if>
+            <if test="dataUpdateFlog != null">data_update_flog = #{dataUpdateFlog},</if>
+            <if test="syncTime != null">sync_time = #{syncTime},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            update_time = sysdate,
+            <if test="remark != null">remark = #{remark},</if>
+        </set>
+        where id = #{id}
+    </update>
+
+    <update id="updateByUserUid" parameterType="com.complaint.workorder.domain.ComplaintStaff">
+        update complaint_staff
+        <set>
+            <if test="uNumber != null">u_number = #{uNumber},</if>
+            <if test="idNumber != null">id_number = #{idNumber},</if>
+            <if test="userName != null">user_name = #{userName},</if>
+            <if test="userAlias != null">user_alias = #{userAlias},</if>
+            <if test="userSex != null">user_sex = #{userSex},</if>
+            <if test="mobile != null">mobile = #{mobile},</if>
+            <if test="email != null">email = #{email},</if>
+            <if test="unitUid != null">unit_uid = #{unitUid},</if>
+            <if test="unitName != null">unit_name = #{unitName},</if>
+            <if test="idType != null">id_type = #{idType},</if>
+            <if test="idTypeName != null">id_type_name = #{idTypeName},</if>
+            <if test="isActive != null">is_active = #{isActive},</if>
+            <if test="isActiveName != null">is_active_name = #{isActiveName},</if>
+            <if test="belongSchool != null">belong_school = #{belongSchool},</if>
+            <if test="belongClass != null">belong_class = #{belongClass},</if>
+            <if test="beginTime != null">begin_time = #{beginTime},</if>
+            <if test="endTime != null">end_time = #{endTime},</if>
+            <if test="dataUpdateTime != null">data_update_time = #{dataUpdateTime},</if>
+            <if test="dataUpdateFlog != null">data_update_flog = #{dataUpdateFlog},</if>
+            sync_time = sysdate,
+        </set>
+        where user_uid = #{userUid}
+    </update>
+
+    <delete id="deleteComplaintStaffById" parameterType="Long">
+        delete from complaint_staff where id = #{id}
+    </delete>
+
+    <delete id="deleteComplaintStaffByIds" parameterType="Long">
+        delete from complaint_staff where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <update id="truncateTable">
+        delete from complaint_staff
+    </update>
+
+</mapper>

+ 146 - 34
h5-app/src/pages/ComplaintDetail.vue

@@ -8,37 +8,72 @@
       </div>
     </div>
 
-    <van-cell-group title="投诉原始信息" style="background: #fff; margin-top: 12px; border-radius: 12px; overflow: hidden;">
-      <van-cell :title="workorder.complaintNo" label="工单编号" />
-      <van-cell :title="workorder.schoolName" label="举报学校" />
-      <van-cell :title="workorder.name + '(' + workorder.identity + ')'" label="举报人" />
-      <van-cell :title="maskPhone(workorder.phone)" label="联系电话" />
-      <van-cell :title="workorder.category" label="投诉分类" />
-      <van-cell :title="formatTime(workorder.createTime)" label="提交时间" />
-      <van-cell title="投诉内容">
-        <template #value><span class="content">{{ workorder.content }}</span></template>
-      </van-cell>
-      <van-cell v-if="imageList.length" title="图片附件">
-        <template #value>
-          <div class="img-list">
-            <img v-for="(img, i) in imageList" :key="i" :src="img" alt="投诉附件" @click="previewImage(i)" />
-          </div>
-        </template>
-      </van-cell>
-    </van-cell-group>
-
-    <van-cell-group title="官方处置信息" v-if="workorder.status === 1 || workorder.status === 2 || workorder.status === 3" style="background: #fff; margin-top: 12px; border-radius: 12px; overflow: hidden;">
-      <van-cell v-if="workorder.investigation" title="调查经过">
-        <template #value><span class="content">{{ workorder.investigation }}</span></template>
-      </van-cell>
-      <van-cell v-if="workorder.status === 2 && workorder.conclusion" title="处置结论">
-        <template #value><span class="content">{{ workorder.conclusion }}</span></template>
-      </van-cell>
-      <van-cell v-if="workorder.status === 2" title="办结时间" :value="formatTime(workorder.closeTime)" />
-      <van-cell v-if="workorder.status === 3 && workorder.rejectReason" title="驳回理由">
-        <template #value><span class="content">{{ workorder.rejectReason }}</span></template>
-      </van-cell>
-    </van-cell-group>
+    <div class="info-card" style="margin-top: 12px;">
+      <div class="card-title">投诉原始信息</div>
+      <!-- 短字段两列网格,减少留白 -->
+      <div class="info-grid">
+        <div class="info-item">
+          <span class="info-label">工单编号</span>
+          <span class="info-value">{{ workorder.complaintNo }}</span>
+        </div>
+        <div class="info-item">
+          <span class="info-label">举报学校</span>
+          <span class="info-value">{{ workorder.schoolName }}</span>
+        </div>
+        <div class="info-item">
+          <span class="info-label">举报人</span>
+          <span class="info-value">{{ workorder.name }}({{ workorder.identity }})</span>
+        </div>
+        <div class="info-item">
+          <span class="info-label">联系电话</span>
+          <span class="info-value">{{ maskPhone(workorder.phone) }}</span>
+        </div>
+        <div class="info-item">
+          <span class="info-label">投诉分类</span>
+          <span class="info-value">{{ workorder.category }}</span>
+        </div>
+        <div class="info-item">
+          <span class="info-label">提交时间</span>
+          <span class="info-value">{{ formatTime(workorder.createTime) }}</span>
+        </div>
+      </div>
+      <!-- 投诉内容大卡片 -->
+      <div class="content-block">
+        <div class="content-label">投诉内容</div>
+        <div class="content-text">{{ workorder.content }}</div>
+      </div>
+      <!-- 图片附件 -->
+      <div v-if="imageList.length" class="content-block" style="padding-bottom: 16px;">
+        <div class="content-label">图片附件</div>
+        <div class="img-list">
+          <img v-for="(img, i) in imageList" :key="i" :src="img" alt="投诉附件" @click="previewImage(i)" />
+        </div>
+      </div>
+    </div>
+
+    <div class="info-card" v-if="workorder.status === 1 || workorder.status === 2 || workorder.status === 3" style="margin-top: 12px;">
+      <div class="card-title">官方处置信息</div>
+      <!-- 调查经过大卡片 -->
+      <div v-if="workorder.investigation" class="content-block">
+        <div class="content-label">调查经过</div>
+        <div class="content-text">{{ workorder.investigation }}</div>
+      </div>
+      <!-- 处置结论大卡片 -->
+      <div v-if="workorder.status === 2 && workorder.conclusion" class="content-block">
+        <div class="content-label">处置结论</div>
+        <div class="content-text">{{ workorder.conclusion }}</div>
+      </div>
+      <!-- 驳回理由大卡片 -->
+      <div v-if="workorder.status === 3 && workorder.rejectReason" class="content-block">
+        <div class="content-label" style="color: #ee0a24;">驳回理由</div>
+        <div class="content-text" style="color: #ee0a24;">{{ workorder.rejectReason }}</div>
+      </div>
+      <!-- 办结时间(短字段,单行显示) -->
+      <div v-if="workorder.status === 2" class="info-single">
+        <span class="info-label">办结时间</span>
+        <span class="info-value">{{ formatTime(workorder.closeTime) }}</span>
+      </div>
+    </div>
 
     <div style="padding: 0 16px 16px; display: flex; justify-content: center;">
       <van-button v-if="workorder.status === 2" block round plain type="danger" style="border-radius: 12px; padding: 14px; font-weight: 600;" @click="showReview = true">提交异议复核</van-button>
@@ -179,16 +214,93 @@ onMounted(async () => {
   font-size: 14px;
   font-weight: 500;
 }
-.content { 
-  line-height: 1.8; 
+
+/* 信息卡片 */
+.info-card {
+  background: #fff;
+  margin: 0 12px;
+  border-radius: 12px;
+  overflow: hidden;
+}
+.card-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #1f2937;
+  padding: 14px 16px 10px;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+/* 短字段两列网格 */
+.info-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0;
+  padding: 4px 0;
+}
+.info-item {
+  display: flex;
+  flex-direction: column;
+  padding: 10px 16px;
+  border-bottom: 1px solid #f9fafb;
+}
+.info-item:nth-child(odd) {
+  border-right: 1px solid #f9fafb;
+}
+.info-label {
+  font-size: 12px;
+  color: #9ca3af;
+  margin-bottom: 4px;
+}
+.info-value {
+  font-size: 14px;
   color: #374151;
+  word-break: break-all;
+  line-height: 1.4;
+}
+
+/* 单行短字段 */
+.info-single {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  border-top: 1px solid #f3f4f6;
+}
+.info-single .info-label {
+  margin-bottom: 0;
+}
+.info-single .info-value {
+  font-weight: 500;
+}
+
+/* 长文本内容块(投诉内容、调查经过、处置结论) */
+.content-block {
+  padding: 14px 16px;
+  border-top: 1px solid #f3f4f6;
+}
+.content-label {
+  font-size: 13px;
+  font-weight: 600;
+  color: #6b7280;
+  margin-bottom: 8px;
+}
+.content-text {
+  font-size: 15px;
+  line-height: 1.9;
+  color: #1f2937;
   word-break: break-word;
+  background: #f9fafb;
+  padding: 12px 14px;
+  border-radius: 8px;
 }
+
+/* 图片列表 */
 .img-list { 
   display: flex; 
   flex-wrap: wrap; 
   gap: 12px;
-  justify-content: center;
+  justify-content: flex-start;
+  margin-top: 8px;
 }
 .img-list img { 
   width: 80px; 

+ 2 - 27
ruoyi-ui/src/api/complaint/school.js

@@ -1,34 +1,9 @@
 import request from '@/utils/request'
 
-// 查询学校机构列表
+// 查询学校列表
 export function listSchool(query) {
   return request({
-    url: '/complaint/school/list',
-    method: 'get',
-    params: query
-  })
-}
-
-// 手动触发机构每日增量同步(推荐)
-export function syncSchoolDaily() {
-  return request({
-    url: '/common/sync/school/daily',
-    method: 'post'
-  })
-}
-
-// 手动触发全量初始化(慎用)
-export function syncSchoolAll() {
-  return request({
-    url: '/common/sync/school/init',
-    method: 'post'
-  })
-}
-
-// 查询同步日志列表
-export function listSyncLog(query) {
-  return request({
-    url: '/complaint/sync/log/list',
+    url: '/complaint/school/options',
     method: 'get',
     params: query
   })

+ 62 - 0
ruoyi-ui/src/api/complaint/staff.js

@@ -0,0 +1,62 @@
+import request from '@/utils/request'
+
+// 查询教职工列表
+export function listStaff(query) {
+  return request({
+    url: '/workorder/staff/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询教职工详细
+export function getStaff(id) {
+  return request({
+    url: '/workorder/staff/' + id,
+    method: 'get'
+  })
+}
+
+// 新增教职工
+export function addStaff(data) {
+  return request({
+    url: '/workorder/staff',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改教职工
+export function updateStaff(data) {
+  return request({
+    url: '/workorder/staff',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除教职工
+export function delStaff(id) {
+  return request({
+    url: '/workorder/staff/' + id,
+    method: 'delete'
+  })
+}
+
+// 从东软同步教职工数据
+export function syncStaff() {
+  return request({
+    url: '/workorder/staff/sync',
+    method: 'post'
+  })
+}
+
+// 导入教职工Excel(匹配后创建系统用户)
+export function importStaff(data) {
+  return request({
+    url: '/workorder/staff/import',
+    method: 'post',
+    data: data,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}

+ 2 - 2
ruoyi-ui/src/router/index.js

@@ -61,10 +61,10 @@ export const constantRoutes = [
     path: '',
     component: Layout,
     hidden: true,
-    redirect: '/dashboard',
+    redirect: '/data/dashboard',
     children: [
       {
-        path: '/dashboard',
+        path: '/data/dashboard',
         component: () => import('@/views/complaint/dashboard'),
         name: 'Dashboard',
         meta: { title: '工作台', icon: 'dashboard', affix: true }

+ 247 - 0
ruoyi-ui/src/views/complaint/staff/index.vue

@@ -0,0 +1,247 @@
+<template>
+  <div class="app-container">
+    <!-- 搜索表单 -->
+    <el-form :model="queryParams" ref="queryRef" :inline="true">
+      <el-form-item label="姓名" prop="userName">
+        <el-input v-model="queryParams.userName" placeholder="请输入姓名" clearable style="width: 180px" @keyup.enter="handleQuery" />
+      </el-form-item>
+      <el-form-item label="手机号" prop="mobile">
+        <el-input v-model="queryParams.mobile" placeholder="请输入手机号" clearable style="width: 160px" @keyup.enter="handleQuery" />
+      </el-form-item>
+      <el-form-item label="学校名称" prop="unitName">
+        <el-select v-model="queryParams.unitName" placeholder="请选择学校" clearable filterable style="width: 220px" @change="handleQuery">
+          <el-option v-for="item in schoolOptions" :key="item.unitUid" :label="item.unitName" :value="item.unitName" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="学工号" prop="idNumber">
+        <el-input v-model="queryParams.idNumber" placeholder="请输入学工号" clearable style="width: 180px" @keyup.enter="handleQuery" />
+      </el-form-item>
+      <el-form-item label="是否激活" prop="isActive">
+        <el-select v-model="queryParams.isActive" placeholder="请选择" clearable style="width: 120px">
+          <el-option label="激活" value="1" />
+          <el-option label="未激活" value="0" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作按钮 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="Refresh" :loading="syncLoading" @click="handleSync" v-hasPermi="['workorder:staff:sync']">从东软同步</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="Upload" @click="handleImport" v-hasPermi="['workorder:staff:import']">导入创建用户</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Download" @click="handleExport" v-hasPermi="['workorder:staff:export']">导出</el-button>
+      </el-col>
+      <el-col :span="15">
+        <span style="color: #909399; line-height: 32px; font-size: 13px">
+          数据来源:东软数据平台 RYJCSJXXZL_4 视图(教职工)<br>
+          导入说明:上传Excel(姓名、所属学校、手机号码),匹配成功后自动创建系统用户并分配【学校管理员】角色
+        </span>
+      </el-col>
+    </el-row>
+
+    <!-- 数据表格 -->
+    <el-table v-loading="loading" :data="staffList" border>
+      <el-table-column label="序号" align="center" type="index" width="60" />
+      <el-table-column label="姓名" align="center" prop="userName" width="100" />
+      <el-table-column label="学工号" align="center" prop="idNumber" width="280" />
+      <el-table-column label="手机号" align="center" prop="mobile" width="130" />
+      <el-table-column label="学校名称" align="center" prop="unitName" min-width="200" show-overflow-tooltip />
+      <el-table-column label="同步时间" align="center" prop="syncTime" width="170">
+        <template #default="scope">
+          <span>{{ parseTime(scope.row.syncTime) }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
+
+    <!-- 详情对话框 -->
+    <el-dialog v-model="detailVisible" title="教职工详情" width="780px" append-to-body destroy-on-close>
+      <el-descriptions title="基本信息" :column="2" border size="small">
+        <el-descriptions-item label="姓名">{{ detail.userName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="手机号">{{ detail.mobile || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="学工号" :span="2">{{ detail.idNumber || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="邮箱">{{ detail.email || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="身份类型">{{ detail.idTypeName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="是否激活">
+          <el-tag :type="detail.isActive === '1' ? 'success' : 'info'" size="small">{{ detail.isActive === '1' ? '激活' : '未激活' }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="学校名称" :span="2">{{ detail.unitName || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <el-descriptions title="其他信息" :column="2" border size="small" style="margin-top: 16px">
+        <el-descriptions-item label="身份证号" :span="2">{{ detail.userAlias || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="所属班级">{{ detail.belongClass || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="同步时间">{{ parseTime(detail.syncTime) || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <template #footer>
+        <el-button @click="detailVisible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 导入对话框 -->
+    <el-dialog v-model="importVisible" title="导入教职工创建系统用户" width="500px" append-to-body>
+      <el-alert type="info" :closable="false" style="margin-bottom: 15px">
+        <p>1. Excel 必须包含三列:<b>姓名</b>、<b>所属学校</b>、<b>手机号码</b></p>
+        <p>2. 系统根据这三个字段在教职工表中匹配,匹配成功后自动创建系统用户</p>
+        <p>3. 创建的用户:用户名=手机号,默认密码=123456,角色=学校管理员</p>
+        <p>4. 若手机号已存在用户,则更新其学工号和学校UID</p>
+      </el-alert>
+      <el-upload
+        ref="uploadRef"
+        :limit="1"
+        :auto-upload="false"
+        :on-change="handleFileChange"
+        :on-exceed="handleExceed"
+        accept=".xlsx,.xls"
+        drag
+      >
+        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+        <template #tip>
+          <div class="el-upload__tip">只能上传 xlsx/xls 文件</div>
+        </template>
+      </el-upload>
+      <template #footer>
+        <el-button @click="importVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="importLoading" @click="submitImport">确 定 导 入</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="Staff">
+import { ref, reactive, onMounted } from 'vue'
+import { UploadFilled } from '@element-plus/icons-vue'
+import { listStaff, getStaff, delStaff, syncStaff, importStaff } from '@/api/complaint/staff'
+import { listSchool } from '@/api/complaint/school'
+
+const { proxy } = getCurrentInstance()
+
+const loading = ref(false)
+const syncLoading = ref(false)
+const importLoading = ref(false)
+const staffList = ref([])
+const total = ref(0)
+const detailVisible = ref(false)
+const importVisible = ref(false)
+const detail = ref({})
+const uploadFile = ref(null)
+const uploadRef = ref()
+const schoolOptions = ref([])
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  userName: undefined,
+  mobile: undefined,
+  unitName: undefined,
+  idNumber: undefined,
+  isActive: undefined
+})
+
+const queryRef = ref()
+
+/** 查询教职工列表 */
+function getList() {
+  loading.value = true
+  listStaff(queryParams).then(response => {
+    staffList.value = response.rows
+    total.value = response.total
+    loading.value = false
+  }).catch(() => {
+    loading.value = false
+  })
+}
+
+/** 搜索 */
+function handleQuery() {
+  queryParams.pageNum = 1
+  getList()
+}
+
+/** 重置 */
+function resetQuery() {
+  queryRef.value.resetFields()
+  handleQuery()
+}
+
+/** 详情 */
+function handleView(id) {
+  getStaff(id).then(response => {
+    detail.value = response.data
+    detailVisible.value = true
+  })
+}
+
+/** 从东软同步 */
+function handleSync() {
+  proxy.$modal.confirm('确认从东软数据平台全量同步教职工数据?同步会清空现有数据后重新拉取。').then(() => {
+    syncLoading.value = true
+    syncStaff().then(response => {
+      proxy.$modal.msgSuccess(response.msg || '同步成功')
+      getList()
+    }).finally(() => {
+      syncLoading.value = false
+    })
+  }).catch(() => {})
+}
+
+/** 导入 */
+function handleImport() {
+  importVisible.value = true
+  uploadFile.value = null
+  if (uploadRef.value) uploadRef.value.clearFiles()
+}
+
+function handleFileChange(file) {
+  uploadFile.value = file.raw
+}
+
+function handleExceed() {
+  proxy.$modal.msgWarning('只能上传一个文件')
+}
+
+function submitImport() {
+  if (!uploadFile.value) {
+    proxy.$modal.msgWarning('请选择要导入的Excel文件')
+    return
+  }
+  const formData = new FormData()
+  formData.append('file', uploadFile.value)
+  importLoading.value = true
+  importStaff(formData).then(response => {
+    proxy.$modal.msgSuccess(response.msg || '导入完成')
+    importVisible.value = false
+  }).finally(() => {
+    importLoading.value = false
+  })
+}
+
+/** 导出 */
+function handleExport() {
+  proxy.download('workorder/staff/export', { ...queryParams }, `教职工数据_${new Date().getTime()}.xlsx`)
+}
+
+/** 加载学校选项 */
+function loadSchoolOptions() {
+  listSchool().then(response => {
+    schoolOptions.value = response.data || []
+  }).catch(() => {})
+}
+
+onMounted(() => {
+  getList()
+  loadSchoolOptions()
+})
+</script>

+ 49 - 21
ruoyi-ui/src/views/complaint/workorder/index.vue

@@ -97,7 +97,18 @@
           <el-descriptions-item label="投诉内容" :span="2">{{ detail.content }}</el-descriptions-item>
           <el-descriptions-item label="投诉附件" :span="2">
             <span v-if="parseImages(detail.images).length === 0">无</span>
-            <el-tag v-for="(img, i) in parseImages(detail.images)" :key="i" size="small" style="margin-right: 6px">{{ imageName(img) }}</el-tag>
+            <div v-else class="attachment-image-list">
+              <el-image
+                v-for="(img, i) in parseImages(detail.images)"
+                :key="i"
+                :src="img"
+                :preview-src-list="parseImages(detail.images)"
+                :initial-index="i"
+                fit="cover"
+                class="attachment-image"
+                preview-teleported
+              />
+            </div>
           </el-descriptions-item>
         </el-descriptions>
 
@@ -125,20 +136,21 @@
         <!-- 复核提示 -->
         <el-alert v-else-if="detail.status === 5" title="群众对办结结果存在异议,工单复核中,请等待复核结论。" type="warning" :closable="false" show-icon style="margin-top: 16px" />
 
-        <!-- 处置留痕 -->
-        <el-divider content-position="left">处置留痕</el-divider>
-        <el-timeline v-if="processLogs.length > 0">
-          <el-timeline-item v-for="log in processLogs" :key="log.id" :timestamp="parseTime(log.createTime)" placement="top">
-            <div>
-              <el-tag size="small" style="margin-right: 8px">{{ log.action }}</el-tag>
-              <span style="color: #909399">{{ log.operatorName }}</span>
-            </div>
-            <div v-if="log.investigation" style="margin-top: 4px">调查经过:{{ log.investigation }}</div>
-            <div v-if="log.conclusion">处置结论:{{ log.conclusion }}</div>
-            <div v-if="log.rejectReason" style="color: #F56C6C">驳回理由:{{ log.rejectReason }}</div>
-          </el-timeline-item>
-        </el-timeline>
-        <el-empty v-else description="暂无处置记录" :image-size="60" />
+        <!-- 处置留痕(有记录时才显示) -->
+        <template v-if="processLogs.length > 0">
+          <el-divider content-position="left">处置留痕</el-divider>
+          <el-timeline>
+            <el-timeline-item v-for="log in processLogs" :key="log.id" :timestamp="parseTime(log.createTime)" placement="top">
+              <div>
+                <el-tag size="small" style="margin-right: 8px">{{ log.action }}</el-tag>
+                <span style="color: #909399">{{ log.operatorName }}</span>
+              </div>
+              <div v-if="log.investigation" style="margin-top: 4px">调查经过:{{ log.investigation }}</div>
+              <div v-if="log.conclusion" style="margin-top: 4px">处置结论:{{ log.conclusion }}</div>
+              <div v-if="log.rejectReason" style="color: #F56C6C">驳回理由:{{ log.rejectReason }}</div>
+            </el-timeline-item>
+          </el-timeline>
+        </template>
       </div>
 
       <template #footer>
@@ -224,8 +236,8 @@ const data = reactive({
 
 const { queryParams, processForm, processRules, rejectForm, rejectRules } = toRefs(data)
 
-/** 待受理或处理中可处置 */
-const canProcess = computed(() => detail.value.status === 0 || detail.value.status === 1)
+/** 处理中填写处置信息(待受理时不显示处置表单) */
+const canProcess = computed(() => detail.value.status === 1)
 
 function statusText(status) {
   return { 0: '待受理', 1: '处理中', 2: '已办结', 3: '已驳回', 5: '复核中' }[status] || '未知'
@@ -282,15 +294,19 @@ function resetQuery() {
 
 /** 打开详情弹窗 */
 function handleDetail(row) {
-  detailOpen.value = true
-  detailLoading.value = true
+  // 先重置所有数据,避免上次内容残留
   detail.value = {}
   processLogs.value = []
+  processForm.value.investigation = ''
+  processForm.value.conclusion = ''
+  detailOpen.value = true
+  detailLoading.value = true
   getWorkorder(row.id).then(response => {
     detail.value = response.data.workorder
     processLogs.value = response.data.processLogs || []
-    processForm.value.investigation = detail.value.investigation
-    processForm.value.conclusion = detail.value.conclusion
+    // 空值兜底,避免 null/undefined 导致不清空
+    processForm.value.investigation = detail.value.investigation || ''
+    processForm.value.conclusion = detail.value.conclusion || ''
     detailLoading.value = false
   })
 }
@@ -414,4 +430,16 @@ getList()
 .dialog-footer > * {
   margin-left: 12px;
 }
+.attachment-image-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.attachment-image {
+  width: 100px;
+  height: 100px;
+  border-radius: 4px;
+  border: 1px solid #ebeef5;
+  cursor: pointer;
+}
 </style>

+ 62 - 0
sql/ry_dm_staff.sql

@@ -0,0 +1,62 @@
+-- ============================================
+-- 教职工信息表(从东软 RYJCSJXXZL_4 视图同步)
+-- 单独存储7000+教职工数据,不放入 sys_user
+-- 匹配成功后再创建系统用户并分配学校管理员角色
+-- ============================================
+drop table if exists complaint_staff;
+create table complaint_staff (
+  id               bigint        not null IDENTITY(1,1),
+  u_number         varchar(64)   default '',
+  id_number        varchar(64)   default '',
+  user_uid         varchar(64)   default '',
+  user_name        varchar(50)   default '',
+  user_alias       varchar(50)   default '',
+  user_sex         varchar(4)    default '',
+  mobile           varchar(20)   default '',
+  email            varchar(100)  default '',
+  unit_uid         varchar(64)   default '',
+  unit_name        varchar(100)  default '',
+  id_type          varchar(10)   default '',
+  id_type_name     varchar(20)   default '',
+  is_active        varchar(4)    default '',
+  is_active_name   varchar(20)   default '',
+  belong_school    varchar(100)  default '',
+  belong_class     varchar(100)  default '',
+  begin_time       varchar(20)   default '',
+  end_time         varchar(20)   default '',
+  data_update_time timestamp,
+  data_update_flog varchar(4)    default '',
+  sync_time        timestamp,
+  create_by        varchar(64)   default '',
+  create_time      timestamp,
+  update_by        varchar(64)   default '',
+  update_time      timestamp,
+  remark           varchar(500)  default '',
+  primary key (id)
+);
+COMMENT ON TABLE complaint_staff IS '教职工信息表(东软同步,匹配后创建系统用户)';
+COMMENT ON COLUMN complaint_staff.u_number IS 'U_NUMBER(东软人员编号)';
+COMMENT ON COLUMN complaint_staff.id_number IS '学工号/ID_NUMBER(公众号推送匹配关键字段)';
+COMMENT ON COLUMN complaint_staff.user_uid IS '用户UID';
+COMMENT ON COLUMN complaint_staff.user_name IS '姓名';
+COMMENT ON COLUMN complaint_staff.user_alias IS '用户别名(身份证号)';
+COMMENT ON COLUMN complaint_staff.user_sex IS '性别(1男2女)';
+COMMENT ON COLUMN complaint_staff.mobile IS '手机号(匹配条件之一)';
+COMMENT ON COLUMN complaint_staff.email IS '邮箱';
+COMMENT ON COLUMN complaint_staff.unit_uid IS '学校机构UID';
+COMMENT ON COLUMN complaint_staff.unit_name IS '学校名称(匹配条件之一)';
+COMMENT ON COLUMN complaint_staff.id_type IS '身份类型编码';
+COMMENT ON COLUMN complaint_staff.id_type_name IS '身份类型名称(教职工/学生等)';
+COMMENT ON COLUMN complaint_staff.is_active IS '是否激活(1激活0未激活)';
+COMMENT ON COLUMN complaint_staff.is_active_name IS '激活状态名称';
+COMMENT ON COLUMN complaint_staff.belong_school IS '所属学校';
+COMMENT ON COLUMN complaint_staff.belong_class IS '所属班级';
+COMMENT ON COLUMN complaint_staff.begin_time IS '生效开始时间';
+COMMENT ON COLUMN complaint_staff.end_time IS '生效结束时间';
+COMMENT ON COLUMN complaint_staff.data_update_time IS '东软数据更新时间';
+COMMENT ON COLUMN complaint_staff.data_update_flog IS '增量标识(I新增U更新D删除)';
+COMMENT ON COLUMN complaint_staff.sync_time IS '本地同步时间';
+create index idx_staff_mobile on complaint_staff (mobile);
+create index idx_staff_unit on complaint_staff (unit_uid);
+create index idx_staff_name on complaint_staff (user_name);
+create index idx_staff_idno on complaint_staff (id_number);