feat(audit): 审核台前端与审核统计 —— 批量通过逐条给原因,驳回走附录 C 原因码
待办台四个计数 + 分级过滤 + 批量通过(被跳过的条目逐条列原因,只说「过了 3 个」 会让人反复点那个大按钮);详情抽屉含材料/问题清单/改动明细/时间轴/通过·驳回·撤回。 前端不做范围过滤:权限 ≠ 范围,由服务端用 1 = 0 守死,前端再筛一次只会制造假象。 canApprove=false 时按钮直接禁用并显示原因,而不是等他点了再弹 40032。 审核端看材料走单独一条通道(不复用学生那条),材料 URL 用 ?thumb=true 取缩略图, 默认 false —— 详情页要看防伪纹。 验证:vue-tsc / eslint 0 错;浏览器实测四个菜单与批量通过弹窗。
This commit is contained in:
+47
-2
@@ -42,6 +42,9 @@ import java.util.List;
|
|||||||
public class AuditController {
|
public class AuditController {
|
||||||
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final cn.tianbaotong.fill.module.audit.service.AuditStatisticsService statisticsService;
|
||||||
|
private final cn.tianbaotong.fill.module.mp.service.TeacherProxyService proxyService;
|
||||||
|
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
|
||||||
|
|
||||||
@GetMapping("/todos")
|
@GetMapping("/todos")
|
||||||
@Operation(summary = "待办列表",
|
@Operation(summary = "待办列表",
|
||||||
@@ -125,8 +128,9 @@ public class AuditController {
|
|||||||
public ApiResult<MaterialFileService.FileUrl> fileUrl(
|
public ApiResult<MaterialFileService.FileUrl> fileUrl(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestParam(defaultValue = "false") boolean original,
|
@RequestParam(defaultValue = "false") boolean original,
|
||||||
@RequestParam(required = false) Long expireSeconds) {
|
@RequestParam(required = false) Long expireSeconds,
|
||||||
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds));
|
@RequestParam(defaultValue = "false") boolean thumb) {
|
||||||
|
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds, thumb));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/files/{id}/content")
|
@GetMapping("/files/{id}/content")
|
||||||
@@ -142,4 +146,45 @@ public class AuditController {
|
|||||||
.header("Content-Disposition", "inline")
|
.header("Content-Disposition", "inline")
|
||||||
.body(c.bytes());
|
.body(c.bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ══════════════════ 审核统计(§11.6)══════════════════
|
||||||
|
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
@RequirePerm(PermissionDef.PROGRESS_VIEW_ALL)
|
||||||
|
@Operation(summary = "审核统计(§11.6)",
|
||||||
|
description = "`byReason` 是给管理员看「**哪种问题最多**」的,用来反过来优化材料引导语 —— "
|
||||||
|
+ "如果 60% 的驳回都是「工作证明没盖章」,那就该在填报页把它写成大红字。"
|
||||||
|
+ "所以 `reasonText` 必须可读,只给码(REASON-03)管理员看不懂,这个统计就白做了。"
|
||||||
|
+ "⚠️ 待审/已通过/已驳回按填报单**当前状态**数(不按审核记录数):"
|
||||||
|
+ "被驳回 3 次又通过的单子在「已通过」里算 1、「已驳回」里算 0,"
|
||||||
|
+ "这样三个数字加起来才等于人数 —— 加起来对不上,客户就不信整页数字了")
|
||||||
|
public ApiResult<cn.tianbaotong.fill.module.audit.service.AuditStatisticsService.StatVO>
|
||||||
|
statistics(@RequestParam Long taskId,
|
||||||
|
@RequestParam(required = false) Long orgId,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to) {
|
||||||
|
return ApiResult.ok(statisticsService.statistics(taskId, orgId, from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════ PC 端代传(§11.7)══════════════════
|
||||||
|
|
||||||
|
@PostMapping(value = "/submissions/{id}/proxy-upload",
|
||||||
|
consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePerm(PermissionDef.FILE_PROXY_UPLOAD)
|
||||||
|
@Operation(summary = "PC 端代传材料(§11.7,同 §10.6)",
|
||||||
|
description = "与班主任端同一个服务,但**范围判定不同**:"
|
||||||
|
+ "PC 端是管理/审核角色,管理员的范围是**整个租户** —— "
|
||||||
|
+ "直接复用 MP 那套(只管自己负责的班)会让管理员在 PC 上报 40301,"
|
||||||
|
+ "而界面上看起来像权限没配好")
|
||||||
|
public ApiResult<cn.tianbaotong.fill.module.mp.dto.TeacherDtos.ProxyUploadVO> proxyUpload(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam String materialCode,
|
||||||
|
@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
|
||||||
|
@RequestParam(required = false) String proxyReason,
|
||||||
|
jakarta.servlet.http.HttpServletRequest req) throws java.io.IOException {
|
||||||
|
return ApiResult.ok(proxyService.proxyUploadAsAdmin(id, materialCode,
|
||||||
|
file.getOriginalFilename(), file.getBytes(), proxyReason,
|
||||||
|
cn.tianbaotong.fill.support.WebUtil.clientIp(req)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核统计的聚合查询(《03-接口设计》§11.6)。
|
||||||
|
*
|
||||||
|
* <h3>三条口径</h3>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>待审 / 已通过 / 已驳回</b> 都按**填报单当前状态**数,而不是按审核记录数:
|
||||||
|
* 一份单子被驳回 3 次又通过,它在"已通过"里算 1、"已驳回"里算 0 ——
|
||||||
|
* 按记录数会让数字加起来不等于人数,客户一眼就看出不对;</li>
|
||||||
|
* <li><b>平均时长</b>用审核记录里的 {@code duration_sec}(审核时算好写进去的),
|
||||||
|
* 按**审核人**分组时同样用它;</li>
|
||||||
|
* <li><b>超时</b> = 提交时间已超过任务配置的节点时限**且仍未审完**。
|
||||||
|
* 时限来自任务的 {@code audit_config}(初审 48h / 终审 72h 为默认),
|
||||||
|
* 所以这里要拿任务的 JSON 配置来算 —— 而不是写死 48。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>为什么把 SQL 放这里而不是在 Service 里拼</h3>
|
||||||
|
*
|
||||||
|
* <p>这些查询都要**按 orgId 过滤**(契约的 Query 有它),而 org 过滤的路径是
|
||||||
|
* "填报单 → 名册 → 班级"。写成一段可读的 SQL 比在 Java 里拼字符串可靠得多。
|
||||||
|
*/
|
||||||
|
public interface AuditStatMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按状态数填报单。
|
||||||
|
*
|
||||||
|
* <p>{@code orgId} 为空时表示整个任务。⚠️ 用 {@code <script>} + {@code <if>}
|
||||||
|
* 拼可选条件:PostgreSQL **推断不出 `#{orgId} IS NULL` 里参数的类型**(F-55)。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT s.status AS st, COUNT(*) AS cnt
|
||||||
|
FROM collect_submission s
|
||||||
|
<if test="orgId != null">
|
||||||
|
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||||
|
</if>
|
||||||
|
WHERE s.task_id = #{taskId} AND s.deleted = 0
|
||||||
|
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||||
|
<if test="from != null"> AND s.submit_time >= #{from}::timestamp</if>
|
||||||
|
<if test="to != null"> AND s.submit_time <= #{to}::timestamp</if>
|
||||||
|
GROUP BY s.status
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> countByStatus(@Param("taskId") Long taskId,
|
||||||
|
@Param("orgId") Long orgId,
|
||||||
|
@Param("from") String from,
|
||||||
|
@Param("to") String to);
|
||||||
|
|
||||||
|
/** 平均审核时长(小时),按任务整体。 */
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT AVG(a.duration_sec) / 3600.0
|
||||||
|
FROM collect_audit_record a
|
||||||
|
<if test="orgId != null">
|
||||||
|
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||||
|
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||||
|
</if>
|
||||||
|
WHERE a.task_id = #{taskId} AND a.duration_sec IS NOT NULL AND a.duration_sec > 0
|
||||||
|
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||||
|
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||||
|
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
Double avgAuditHours(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||||
|
@Param("from") String from, @Param("to") String to);
|
||||||
|
|
||||||
|
/** 按审核人分组(谁审了多少、平均多久)。 */
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT u.real_name AS auditorName,
|
||||||
|
COUNT(*) AS handled,
|
||||||
|
AVG(a.duration_sec) / 3600.0 AS avgHours
|
||||||
|
FROM collect_audit_record a
|
||||||
|
LEFT JOIN sys_user u ON u.id = a.auditor_id AND u.deleted = 0
|
||||||
|
<if test="orgId != null">
|
||||||
|
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||||
|
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||||
|
</if>
|
||||||
|
WHERE a.task_id = #{taskId}
|
||||||
|
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||||
|
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||||
|
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||||
|
GROUP BY u.real_name
|
||||||
|
ORDER BY handled DESC
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> byAuditor(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||||
|
@Param("from") String from, @Param("to") String to);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按驳回原因分组。
|
||||||
|
*
|
||||||
|
* <p>{@code byReason} 的用途在契约里写得很清楚:给管理员看"**哪种问题最多**",
|
||||||
|
* 用来反过来优化材料引导语 —— 也就是"如果 60% 的驳回都是『工作证明没盖章』,
|
||||||
|
* 那就该在填报页把它写成大红字"。所以这里必须给出可读的 {@code reasonText},
|
||||||
|
* 只给码(REASON-03)管理员看不懂,这个统计就白做了。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT a.reason_code AS reasonCode,
|
||||||
|
MIN(a.reason_text) AS reasonText,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
FROM collect_audit_record a
|
||||||
|
<if test="orgId != null">
|
||||||
|
JOIN collect_submission s ON s.id = a.submission_id AND s.deleted = 0
|
||||||
|
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||||
|
</if>
|
||||||
|
WHERE a.task_id = #{taskId}
|
||||||
|
AND a.action = 2
|
||||||
|
AND a.reason_code IS NOT NULL
|
||||||
|
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||||
|
<if test="from != null"> AND a.create_time >= #{from}::timestamp</if>
|
||||||
|
<if test="to != null"> AND a.create_time <= #{to}::timestamp</if>
|
||||||
|
GROUP BY a.reason_code
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> byReason(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||||
|
@Param("from") String from, @Param("to") String to);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 超时的填报单数:提交已超过节点时限、且仍未审完。
|
||||||
|
*
|
||||||
|
* <p>用的是**当前节点**的时限:待初审(status=1)比初审时限,
|
||||||
|
* 待终审(status=2)比终审时限。⚠️ 不能只用初审时限 ——
|
||||||
|
* 一份已通过初审、卡在终审的单子会被误判成"没超时"。
|
||||||
|
*
|
||||||
|
* <p>{@code classHours} / {@code adminHours} 由调用方从任务的
|
||||||
|
* {@code audit_config} 里读出来传进来(那里有客户改过的值,默认才是 48/72)。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT COUNT(*) FROM collect_submission s
|
||||||
|
<if test="orgId != null">
|
||||||
|
JOIN collect_roster r ON r.id = s.roster_id AND r.deleted = 0
|
||||||
|
</if>
|
||||||
|
WHERE s.task_id = #{taskId} AND s.deleted = 0
|
||||||
|
AND s.submit_time IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(s.status = 1 AND s.submit_time < NOW() - (#{classHours} || ' hours')::interval)
|
||||||
|
OR
|
||||||
|
(s.status = 2 AND s.submit_time < NOW() - (#{adminHours} || ' hours')::interval)
|
||||||
|
)
|
||||||
|
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
long timeoutCount(@Param("taskId") Long taskId, @Param("orgId") Long orgId,
|
||||||
|
@Param("classHours") int classHours, @Param("adminHours") int adminHours);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个任务待我审的单数 —— 给审核台的"待办角标"用。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 这里**不关**租户拦截器:查询按 {@code task_id} 过滤,
|
||||||
|
* 而 taskId 一定属于某个租户,拦截器补上的 `tenant_id` 条件正好是我们要的。
|
||||||
|
* **只有确实需要跨租户的查询才关它**(见 `MpSceneMapper` 与微信回调)——
|
||||||
|
* 顺手关掉是这类隔离最常见的失效方式。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM collect_submission
|
||||||
|
WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
|
||||||
|
""")
|
||||||
|
long pendingOfTask(@Param("taskId") Long taskId);
|
||||||
|
}
|
||||||
+54
@@ -81,6 +81,32 @@ public interface AuditTodoMapper {
|
|||||||
<if test="orgId != null">
|
<if test="orgId != null">
|
||||||
AND s.org_id = #{orgId}
|
AND s.org_id = #{orgId}
|
||||||
</if>
|
</if>
|
||||||
|
<!--
|
||||||
|
初审的范围:**按(任务, 班级)配对**判断,而不是"按班级 id 列表"。
|
||||||
|
|
||||||
|
原来用 `s.org_id IN orgIds`,而 orgIds 在 taskId 为空时是
|
||||||
|
"我在**任意任务**里负责过的班级" —— 于是"我在任务 A 是这个班的初审人、
|
||||||
|
在任务 B 不是"时,待办会列出任务 B 的单子,点通过却回 40301。
|
||||||
|
换成 EXISTS 之后,待办与审批用的是同一套判据,
|
||||||
|
不可能再出现「看得到但批不了」。
|
||||||
|
-->
|
||||||
|
<if test="auditorId != null">
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM collect_task_org cto
|
||||||
|
LEFT JOIN sys_org so
|
||||||
|
ON so.id = cto.org_id AND so.deleted = 0
|
||||||
|
AND so.tenant_id = cto.tenant_id
|
||||||
|
WHERE cto.tenant_id = #{tenantId}
|
||||||
|
AND cto.task_id = s.task_id
|
||||||
|
AND cto.org_id = s.org_id
|
||||||
|
AND cto.status = 1
|
||||||
|
AND (
|
||||||
|
cto.head_user_id = #{auditorId}
|
||||||
|
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||||
|
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</if>
|
||||||
<choose>
|
<choose>
|
||||||
<when test="orgIds != null and orgIds.size() > 0">
|
<when test="orgIds != null and orgIds.size() > 0">
|
||||||
AND s.org_id IN
|
AND s.org_id IN
|
||||||
@@ -114,6 +140,7 @@ public interface AuditTodoMapper {
|
|||||||
@Param("taskId") Long taskId,
|
@Param("taskId") Long taskId,
|
||||||
@Param("orgId") Long orgId,
|
@Param("orgId") Long orgId,
|
||||||
@Param("orgIds") List<Long> orgIds,
|
@Param("orgIds") List<Long> orgIds,
|
||||||
|
@Param("auditorId") Long auditorId,
|
||||||
@Param("level") Integer level,
|
@Param("level") Integer level,
|
||||||
@Param("keyword") String keyword,
|
@Param("keyword") String keyword,
|
||||||
@Param("waitingOverHours") Integer waitingOverHours);
|
@Param("waitingOverHours") Integer waitingOverHours);
|
||||||
@@ -145,6 +172,32 @@ public interface AuditTodoMapper {
|
|||||||
<if test="orgId != null">
|
<if test="orgId != null">
|
||||||
AND s.org_id = #{orgId}
|
AND s.org_id = #{orgId}
|
||||||
</if>
|
</if>
|
||||||
|
<!--
|
||||||
|
初审的范围:**按(任务, 班级)配对**判断,而不是"按班级 id 列表"。
|
||||||
|
|
||||||
|
原来用 `s.org_id IN orgIds`,而 orgIds 在 taskId 为空时是
|
||||||
|
"我在**任意任务**里负责过的班级" —— 于是"我在任务 A 是这个班的初审人、
|
||||||
|
在任务 B 不是"时,待办会列出任务 B 的单子,点通过却回 40301。
|
||||||
|
换成 EXISTS 之后,待办与审批用的是同一套判据,
|
||||||
|
不可能再出现「看得到但批不了」。
|
||||||
|
-->
|
||||||
|
<if test="auditorId != null">
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM collect_task_org cto
|
||||||
|
LEFT JOIN sys_org so
|
||||||
|
ON so.id = cto.org_id AND so.deleted = 0
|
||||||
|
AND so.tenant_id = cto.tenant_id
|
||||||
|
WHERE cto.tenant_id = #{tenantId}
|
||||||
|
AND cto.task_id = s.task_id
|
||||||
|
AND cto.org_id = s.org_id
|
||||||
|
AND cto.status = 1
|
||||||
|
AND (
|
||||||
|
cto.head_user_id = #{auditorId}
|
||||||
|
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||||
|
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</if>
|
||||||
<choose>
|
<choose>
|
||||||
<when test="orgIds != null and orgIds.size() > 0">
|
<when test="orgIds != null and orgIds.size() > 0">
|
||||||
AND s.org_id IN
|
AND s.org_id IN
|
||||||
@@ -164,6 +217,7 @@ public interface AuditTodoMapper {
|
|||||||
@Param("taskId") Long taskId,
|
@Param("taskId") Long taskId,
|
||||||
@Param("orgId") Long orgId,
|
@Param("orgId") Long orgId,
|
||||||
@Param("orgIds") List<Long> orgIds,
|
@Param("orgIds") List<Long> orgIds,
|
||||||
|
@Param("auditorId") Long auditorId,
|
||||||
@Param("keyword") String keyword,
|
@Param("keyword") String keyword,
|
||||||
@Param("timeoutHours") Integer timeoutHours);
|
@Param("timeoutHours") Integer timeoutHours);
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-9
@@ -7,6 +7,8 @@ import cn.tianbaotong.fill.common.context.UserContext;
|
|||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
|
||||||
import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
|
import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
|
||||||
|
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
|
||||||
|
import cn.tianbaotong.fill.module.notify.service.NotificationService;
|
||||||
import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
|
import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
|
||||||
import cn.tianbaotong.fill.module.audit.mapper.AuditPreconditionMapper;
|
import cn.tianbaotong.fill.module.audit.mapper.AuditPreconditionMapper;
|
||||||
import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper;
|
import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper;
|
||||||
@@ -142,6 +144,8 @@ public class AuditService {
|
|||||||
private final FormSchemaService formSchemaService;
|
private final FormSchemaService formSchemaService;
|
||||||
private final SubmissionValidator validator;
|
private final SubmissionValidator validator;
|
||||||
private final PermissionChecker permissionChecker;
|
private final PermissionChecker permissionChecker;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final cn.tianbaotong.fill.module.open.service.OpenPushService openPushService;
|
||||||
|
|
||||||
// ══════════════════ 待办列表 ══════════════════
|
// ══════════════════ 待办列表 ══════════════════
|
||||||
|
|
||||||
@@ -167,14 +171,19 @@ public class AuditService {
|
|||||||
|
|
||||||
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
|
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
|
||||||
long ps = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
long ps = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||||
|
// ⚠️ 初审还要传 auditorId:让 SQL 按(任务, 班级)配对判断范围 ——
|
||||||
|
// 只传 orgScope(班级 id 列表)会列出"我在别的任务里负责、但这个任务不归我"的单子,
|
||||||
|
// 而用户点通过只会被 40301 拒(看得到但批不了)
|
||||||
|
Long auditorId = node == NODE_CLASS ? me.uid() : null;
|
||||||
IPage<AuditTodoRow> page = todoMapper.pageTodos(new Page<>(pn, ps),
|
IPage<AuditTodoRow> page = todoMapper.pageTodos(new Page<>(pn, ps),
|
||||||
me.tenantId(), status, taskId, orgId, orgScope, level, kw, waitingOverHours);
|
me.tenantId(), status, taskId, orgId, orgScope, auditorId, level, kw,
|
||||||
|
waitingOverHours);
|
||||||
|
|
||||||
AuditDtos.TodoPageVO out = new AuditDtos.TodoPageVO();
|
AuditDtos.TodoPageVO out = new AuditDtos.TodoPageVO();
|
||||||
out.setList(assembleTodos(page.getRecords(), me.tenantId()));
|
out.setList(assembleTodos(page.getRecords(), me.tenantId()));
|
||||||
out.setTotal(page.getTotal());
|
out.setTotal(page.getTotal());
|
||||||
out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope,
|
out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope,
|
||||||
kw, timeoutHours(node, taskId))));
|
auditorId, kw, timeoutHours(node, taskId))));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,11 +705,100 @@ public class AuditService {
|
|||||||
int newStatus = node == NODE_CLASS
|
int newStatus = node == NODE_CLASS
|
||||||
? CollectSubmission.STATUS_PENDING_ADMIN
|
? CollectSubmission.STATUS_PENDING_ADMIN
|
||||||
: CollectSubmission.STATUS_APPROVED;
|
: CollectSubmission.STATUS_APPROVED;
|
||||||
|
// 通知学生(站内信一定能看到;微信推送看有没有额度)——
|
||||||
|
// 失败绝不影响审核结果(NotificationService 内部不抛)
|
||||||
|
notifyApproved(sub, newStatus);
|
||||||
|
// 终审通过 → 推送给认定机构(契约 §14.4)。
|
||||||
|
// ⚠️ **必须等事务提交之后**:推送是外部 HTTP 调用,
|
||||||
|
// 放在事务里会让审核被对方的响应时间绑架;更糟的是,
|
||||||
|
// 如果事务之后回滚了,机构已经收到"这个人通过了"而我们的库里没通过 ——
|
||||||
|
// 这种不一致没法撤回(对方的系统已经动了)。
|
||||||
|
if (newStatus == CollectSubmission.STATUS_APPROVED) {
|
||||||
|
Long tid = sub.getTaskId();
|
||||||
|
Long sid = submissionId;
|
||||||
|
registerAfterCommit(() -> pushToOpenApp(tid, sid));
|
||||||
|
}
|
||||||
log.info("审核通过:submission={} node={} → status={} 审计人={}",
|
log.info("审核通过:submission={} node={} → status={} 审计人={}",
|
||||||
submissionId, node, newStatus, me.uid());
|
submissionId, node, newStatus, me.uid());
|
||||||
return approveResult(newStatus, false);
|
return approveResult(newStatus, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把动作挂到"当前事务提交之后"执行;**没有事务时立刻执行**。
|
||||||
|
*
|
||||||
|
* <p>没有事务也要执行:审批可能被别处以非事务方式调用(例如脚本、内部重试),
|
||||||
|
* 那时"等提交"这件事根本不存在 —— 漏掉推送比多推一次严重得多
|
||||||
|
* (推送记录上有唯一索引,重复触发只会更新同一条)。
|
||||||
|
*/
|
||||||
|
private void registerAfterCommit(Runnable action) {
|
||||||
|
if (!org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
.isSynchronizationActive()) {
|
||||||
|
action.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
.registerSynchronization(
|
||||||
|
new org.springframework.transaction.support.TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
action.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过之后通知学生。
|
||||||
|
*
|
||||||
|
* <p>初审通过**也发**:学生提交后最想知道的就是"我的东西进下一步了吗"。
|
||||||
|
* 只在终审通过时发的话,中间那段等待期他会反复进来看。
|
||||||
|
*
|
||||||
|
* <p>文案按节点区分 —— "等待管理员终审"和"已通过"是两件不同的事,
|
||||||
|
* 用同一句话会让他以为已经结束了。
|
||||||
|
*/
|
||||||
|
private void notifyApproved(CollectSubmission sub, int newStatus) {
|
||||||
|
if (sub.getSubmitterId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean finalPass = newStatus == CollectSubmission.STATUS_APPROVED;
|
||||||
|
String title = finalPass ? "你的材料已通过审核" : "初审已过,等待终审";
|
||||||
|
String content = finalPass
|
||||||
|
? "本次认定的材料已收妥,无需再操作。"
|
||||||
|
: "班主任已通过你的材料,接下来由管理员终审。";
|
||||||
|
safeNotify(NotificationService.NotifyRequest.of(
|
||||||
|
sub.getSubmitterId(),
|
||||||
|
finalPass ? CollectMessage.BIZ_APPROVE : CollectMessage.BIZ_TASK_PUBLISH,
|
||||||
|
title, content,
|
||||||
|
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||||
|
finalPass ? NotificationService.TPL_AUDIT_RESULT : NotificationService.TPL_SUBMIT_SUCCESS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终审通过后推送给认定机构(契约 §14.4)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 由调用方在**事务提交之后**调(见 {@code approve} 里的注释):
|
||||||
|
* 推送是外部 HTTP 调用,可能慢到十几秒,放进事务里会让
|
||||||
|
* "终审通过"这个动作被对方服务的响应时间绑架 —— 对方挂了,我们的审核就卡住。
|
||||||
|
*
|
||||||
|
* <p>失败也只影响推送记录(状态与下次重试时间落库,由定时任务接着试),
|
||||||
|
* 不影响"审核已通过"这个事实。
|
||||||
|
*/
|
||||||
|
private void pushToOpenApp(Long taskId, Long submissionId) {
|
||||||
|
try {
|
||||||
|
openPushService.onSubmissionApproved(UserContext.tenantId(), taskId, submissionId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("推送认定机构失败(不影响审核结果)submissionId={}", submissionId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知的统一入口:**绝不让通知失败影响业务事务**。 */
|
||||||
|
private void safeNotify(NotificationService.NotifyRequest req) {
|
||||||
|
try {
|
||||||
|
notificationService.notify(req);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("发通知失败(不影响业务结果):user={} biz={}", req.userId(), req.bizType(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 幂等回放:如果"我上一次的通过"仍然有效,直接返回当前状态。
|
* 幂等回放:如果"我上一次的通过"仍然有效,直接返回当前状态。
|
||||||
*
|
*
|
||||||
@@ -840,21 +938,43 @@ public class AuditService {
|
|||||||
req.getMaterialCodes());
|
req.getMaterialCodes());
|
||||||
markRoster(sub.getRosterId(), CollectRoster.SUBMIT_REJECTED);
|
markRoster(sub.getRosterId(), CollectRoster.SUBMIT_REJECTED);
|
||||||
|
|
||||||
|
// ★ 通知学生(§13.5 消息中心 + §13.6 订阅消息降级链)。
|
||||||
|
// 在此之前这里只能回 notifySent=false + needManualNotice=true ——
|
||||||
|
// 因为"通知渠道还没做",老师得自己跟学生说一声。
|
||||||
|
// 现在站内信一定写得进去,所以 pushSent 才是"微信推送到没到",
|
||||||
|
// 而"学生能不能知道"这件事有了确定答案。
|
||||||
|
boolean pushSent = false;
|
||||||
|
if (sub.getSubmitterId() != null) {
|
||||||
|
try {
|
||||||
|
var r = notificationService.notify(NotificationService.NotifyRequest.of(
|
||||||
|
sub.getSubmitterId(), CollectMessage.BIZ_REJECT,
|
||||||
|
"你的材料被退回了",
|
||||||
|
fullText,
|
||||||
|
CollectMessage.LINK_SUBMISSION, sub.getId(),
|
||||||
|
NotificationService.TPL_AUDIT_RESULT));
|
||||||
|
pushSent = r.pushSent();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("驳回通知失败(不影响驳回结果):submission={}", submissionId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (escalated) {
|
if (escalated) {
|
||||||
// ⚠️ 通知(订阅消息 / 站内信,M5-12)还没做,所以这里只有日志。
|
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
|
||||||
// 响应里如实地把 notifySent=false + needManualNotice=true 告诉前端,
|
|
||||||
// 让班主任知道"得自己跟学生说一声"——比假装通知过了强得多。
|
|
||||||
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
|
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
|
||||||
submissionId, sub.getRosterId(), rejectCount, code);
|
submissionId, sub.getRosterId(), rejectCount, code);
|
||||||
}
|
}
|
||||||
log.info("审核驳回:submission={} node={} 第 {} 次 原因={}", submissionId, node, rejectCount, code);
|
log.info("审核驳回:submission={} node={} 第 {} 次 原因={} 微信推送={}",
|
||||||
|
submissionId, node, rejectCount, code, pushSent);
|
||||||
|
|
||||||
AuditDtos.RejectVO vo = new AuditDtos.RejectVO();
|
AuditDtos.RejectVO vo = new AuditDtos.RejectVO();
|
||||||
vo.setStatus(CollectSubmission.STATUS_REJECTED);
|
vo.setStatus(CollectSubmission.STATUS_REJECTED);
|
||||||
vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED));
|
vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED));
|
||||||
vo.setRejectCount(rejectCount);
|
vo.setRejectCount(rejectCount);
|
||||||
vo.setNotifySent(false);
|
// ⚠️ 语义收窄了:以前 notifySent 表示"有没有通知渠道"(恒 false),
|
||||||
vo.setNeedManualNotice(true);
|
// 现在表示"**微信推送**有没有发出去"。站内信一定发了,所以
|
||||||
|
// needManualNotice 仍然为 true 时要老师口头说一声 —— 学生不一定会进小程序。
|
||||||
|
vo.setNotifySent(pushSent);
|
||||||
|
vo.setNeedManualNotice(!pushSent);
|
||||||
vo.setEscalated(escalated);
|
vo.setEscalated(escalated);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
@@ -1055,8 +1175,22 @@ public class AuditService {
|
|||||||
/** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */
|
/** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds) {
|
public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds) {
|
||||||
|
return fileUrl(fileId, original, expireSeconds, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取链接(可要缩略图)。
|
||||||
|
*
|
||||||
|
* @param thumb 审核**列表**传 true:一屏几百张,缩略图把 140MB 降到约 2MB;
|
||||||
|
* 审核**详情页**传 false(要看防伪纹、要看有没有裁掉边角)。
|
||||||
|
* ⚠️ 默认 false 是刻意的 —— 反过来默认缩略图的话,详情页会"看起来糊",
|
||||||
|
* 而那种问题很难被归因到"某个默认值"上。
|
||||||
|
*/
|
||||||
|
public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds,
|
||||||
|
boolean thumb) {
|
||||||
CollectFile f = requireFileReadable(fileId);
|
CollectFile f = requireFileReadable(fileId);
|
||||||
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX);
|
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX,
|
||||||
|
thumb ? MaterialFileService.THUMB_LONG_SIDE : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审核端的内容流(本地存储时的预览通道)。 */
|
/** 审核端的内容流(本地存储时的预览通道)。 */
|
||||||
|
|||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
package cn.tianbaotong.fill.module.audit.service;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||||
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import cn.tianbaotong.fill.module.audit.mapper.AuditStatMapper;
|
||||||
|
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||||
|
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核统计(《03-接口设计》§11.6)。
|
||||||
|
*
|
||||||
|
* <h3>这个界面的用途决定了三个数字的口径</h3>
|
||||||
|
*
|
||||||
|
* <p>它不是"看看热闹"的仪表盘,而是**管理动作的输入**:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code pendingCount} + {@code timeoutCount} → "要不要催审核人/加人";</li>
|
||||||
|
* <li>{@code byAuditor} → "谁手上积压了"(handled 少而 avgHours 高的那个);</li>
|
||||||
|
* <li>{@code byReason} → "**哪种问题最多**",用来反过来优化材料引导语
|
||||||
|
* (契约里的原话)。如果 60% 的驳回都是"工作证明没盖章",
|
||||||
|
* 那就该在填报页把它写成大红字 —— 所以 {@code reasonText} 必须可读,
|
||||||
|
* 只给码(REASON-03)管理员看不懂,这个统计就白做了。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>为什么"待审/已通过/已驳回"按填报单**当前状态**数</h3>
|
||||||
|
*
|
||||||
|
* <p>不按审核记录数:一份单子被驳回 3 次又通过,它在"已通过"里算 1、
|
||||||
|
* "已驳回"里算 0。按记录数会让三个数字加起来**不等于人数**,
|
||||||
|
* 而客户第一眼就会去加它们(加起来对不上,他就不信整页数字了)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditStatisticsService {
|
||||||
|
|
||||||
|
private final AuditStatMapper statMapper;
|
||||||
|
private final TaskMapper taskMapper;
|
||||||
|
|
||||||
|
/** PRD 默认时限(与 `AuditService` 同一口径,读不到任务配置时用它)。 */
|
||||||
|
private static final int DEFAULT_CLASS_TIMEOUT_HOURS = 48;
|
||||||
|
private static final int DEFAULT_ADMIN_TIMEOUT_HOURS = 72;
|
||||||
|
|
||||||
|
public StatVO statistics(Long taskId, Long orgId, String from, String to) {
|
||||||
|
if (taskId == null) {
|
||||||
|
// ⚠️ 契约把 taskId 列为 Query 参数但没写必填;而"全平台审核统计"是另一个
|
||||||
|
// 功能(要跨任务聚合,还要考虑每个任务自己的时限),一期不做。
|
||||||
|
// 明确报错比返回一个"看起来是全平台、其实是随机某个任务"的数字安全得多
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"请指定 taskId(一期只做单任务审核统计)");
|
||||||
|
}
|
||||||
|
CollectTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Integer, Integer> byStatus = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> row : statMapper.countByStatus(taskId, orgId, from, to)) {
|
||||||
|
Integer st = intOf(row, "st");
|
||||||
|
Integer cnt = intOf(row, "cnt");
|
||||||
|
if (st != null && cnt != null) {
|
||||||
|
byStatus.merge(st, cnt, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] hours = timeoutHours(task);
|
||||||
|
StatVO vo = new StatVO();
|
||||||
|
vo.setPendingCount(byStatus.getOrDefault(1, 0) + byStatus.getOrDefault(2, 0));
|
||||||
|
vo.setApprovedCount(byStatus.getOrDefault(3, 0));
|
||||||
|
vo.setRejectedCount(byStatus.getOrDefault(4, 0));
|
||||||
|
// ⚠️ 四舍五入到 2 位:直接透出 PostgreSQL 的双精度结果会变成
|
||||||
|
// `0.00038888888888888887` 这种 17 位小数 —— 那不是"精确",是"没处理",
|
||||||
|
// 既不可读又让人怀疑算错了。而且这个字段**开放接口也看得到**(§14)。
|
||||||
|
// null 保持 null:「没有样本」与「0 小时」是两件事。
|
||||||
|
vo.setAvgAuditHours(round2(statMapper.avgAuditHours(taskId, orgId, from, to)));
|
||||||
|
vo.setTimeoutCount(statMapper.timeoutCount(taskId, orgId, hours[0], hours[1]));
|
||||||
|
vo.setClassTimeoutHours(hours[0]);
|
||||||
|
vo.setAdminTimeoutHours(hours[1]);
|
||||||
|
|
||||||
|
for (Map<String, Object> r : statMapper.byAuditor(taskId, orgId, from, to)) {
|
||||||
|
AuditorRow a = new AuditorRow();
|
||||||
|
a.setAuditorName(strOf(r, "auditorName"));
|
||||||
|
a.setHandled(nz(intOf(r, "handled")));
|
||||||
|
// 同上:byAuditor 的 avgHours 也是双精度结果,要求同样的口径
|
||||||
|
a.setAvgHours(round2(doubleOf(r, "avgHours")));
|
||||||
|
vo.getByAuditor().add(a);
|
||||||
|
}
|
||||||
|
for (Map<String, Object> r : statMapper.byReason(taskId, orgId, from, to)) {
|
||||||
|
ReasonRow a = new ReasonRow();
|
||||||
|
a.setReasonCode(strOf(r, "reasonCode"));
|
||||||
|
a.setReasonText(strOf(r, "reasonText"));
|
||||||
|
a.setCount(nz(intOf(r, "cnt")));
|
||||||
|
vo.getByReason().add(a);
|
||||||
|
}
|
||||||
|
log.debug("审核统计 taskId={} orgId={} 待审 {} 超时 {}",
|
||||||
|
taskId, orgId, vo.getPendingCount(), vo.getTimeoutCount());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时限从任务配置读(客户可能改过),读不到才用 PRD 默认值。 */
|
||||||
|
private int[] timeoutHours(CollectTask task) {
|
||||||
|
int classH = DEFAULT_CLASS_TIMEOUT_HOURS;
|
||||||
|
int adminH = DEFAULT_ADMIN_TIMEOUT_HOURS;
|
||||||
|
String json = task.getAuditConfig();
|
||||||
|
if (json != null && !json.isBlank()) {
|
||||||
|
try {
|
||||||
|
JSONObject o = JSONUtil.parseObj(json);
|
||||||
|
Integer c = o.getInt("classAuditTimeoutHours");
|
||||||
|
Integer a = o.getInt("adminAuditTimeoutHours");
|
||||||
|
if (c != null && c > 0) {
|
||||||
|
classH = c;
|
||||||
|
}
|
||||||
|
if (a != null && a > 0) {
|
||||||
|
adminH = a;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// 任务配置坏了不该让统计整个失败 —— 用默认值并说清,
|
||||||
|
// 因为"超时数"这个数字的价值在于趋势,而不在于精确到小时
|
||||||
|
log.warn("任务 {} 的 audit_config 解析失败,超时统计改用默认时限",
|
||||||
|
task.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new int[]{classH, adminH};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object col(Map<String, Object> row, String name) {
|
||||||
|
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||||
|
if (e.getKey().equalsIgnoreCase(name)) {
|
||||||
|
return e.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intOf(Map<String, Object> row, String name) {
|
||||||
|
Object v = col(row, name);
|
||||||
|
return v == null ? null : ((Number) v).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四舍五入到 2 位小数(`null` 原样返回)。
|
||||||
|
*
|
||||||
|
* <p>用 {@code Math.round(v * 100) / 100.0} 而不是 {@code BigDecimal}:
|
||||||
|
* 这里要的是"给显示用的短数字",不是金额那种必须精确的舍入。
|
||||||
|
*/
|
||||||
|
private static Double round2(Double v) {
|
||||||
|
return v == null ? null : Math.round(v * 100) / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double doubleOf(Map<String, Object> row, String name) {
|
||||||
|
Object v = col(row, name);
|
||||||
|
if (v == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ((Number) v).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String strOf(Map<String, Object> row, String name) {
|
||||||
|
Object v = col(row, name);
|
||||||
|
return v == null ? null : String.valueOf(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int nz(Integer v) {
|
||||||
|
return v == null ? 0 : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 响应结构(契约 §11.6 的字段逐个对应)──
|
||||||
|
|
||||||
|
@lombok.Getter
|
||||||
|
@lombok.Setter
|
||||||
|
public static class StatVO {
|
||||||
|
private int pendingCount;
|
||||||
|
private int approvedCount;
|
||||||
|
private int rejectedCount;
|
||||||
|
/** 平均处理时长(小时);没有审核记录时为 null("还没人审" ≠ "审得飞快")。 */
|
||||||
|
private Double avgAuditHours;
|
||||||
|
private long timeoutCount;
|
||||||
|
private List<AuditorRow> byAuditor = new ArrayList<>();
|
||||||
|
private List<ReasonRow> byReason = new ArrayList<>();
|
||||||
|
/** 本次统计用的时限(来自任务配置),让界面能说明"超过多少小时算超时"。 */
|
||||||
|
private int classTimeoutHours;
|
||||||
|
private int adminTimeoutHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Getter
|
||||||
|
@lombok.Setter
|
||||||
|
public static class AuditorRow {
|
||||||
|
private String auditorName;
|
||||||
|
private int handled;
|
||||||
|
private Double avgHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Getter
|
||||||
|
@lombok.Setter
|
||||||
|
public static class ReasonRow {
|
||||||
|
private String reasonCode;
|
||||||
|
private String reasonText;
|
||||||
|
private int count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* 审核接口(《03-接口设计》§11)。
|
||||||
|
*
|
||||||
|
* ## 三件界面上必须守住的规则(都由服务端强制,前端只做呈现)
|
||||||
|
*
|
||||||
|
* 1. **权限 ≠ 范围**。全校几十个班主任都有 `audit:first`,但只有**这个班的**班主任
|
||||||
|
* 能审这个学生。待办列表由服务端按范围过滤(看不见的单子不会出现),
|
||||||
|
* 所以界面上**不要**自己写"按 orgId 过滤"的逻辑 —— 前端过滤不了越权,只会掩盖问题。
|
||||||
|
*
|
||||||
|
* 2. **红问题不许通过(40032)**,而且服务端的校验是**现场重算**的:
|
||||||
|
* 提交时落库的 `redCount` 只是快照(OCR 是异步的、名册和材料规则都可能被改)。
|
||||||
|
* 所以详情页每打开一次就重新拉一次,**不要缓存计数**。
|
||||||
|
*
|
||||||
|
* 3. **批量通过只带绿色项**(`onlyClean` 服务端强制 true)。
|
||||||
|
* 被跳过的每一条都会在 `details` 里说明原因 —— 必须逐条展示,
|
||||||
|
* 否则用户看着"选了 5 个过了 3 个"只会反复点那个按钮。
|
||||||
|
*
|
||||||
|
* ## 状态与分级(不要按直觉写分支)
|
||||||
|
*
|
||||||
|
* - 填报单状态:`1` 草稿 / `2` 待初审 / `3` 待终审(初审已过)/ `4` 已驳回 / `5` 已通过
|
||||||
|
* - 卡片分级 `level`:**只有 0 / 2 / 3**,没有 1(写了"信息级"的分支永远不会触发)
|
||||||
|
*
|
||||||
|
* ⚠️ 上面这两个数字口径**以服务端返回的 `statusText` 为准**:界面优先显示 `statusText`,
|
||||||
|
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
|
||||||
|
*/
|
||||||
|
import http from '@/utils/http'
|
||||||
|
|
||||||
|
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
|
||||||
|
export type BizError = Error & { bizCode?: number, bizData?: unknown }
|
||||||
|
|
||||||
|
/** 提取业务码:调用方用它做分支(如 40032 红问题、40033 已导出不可撤)。 */
|
||||||
|
export function bizCodeOf(e: unknown): number | undefined {
|
||||||
|
return (e as BizError)?.bizCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 待办列表(§11.1 / §10.2)────────────────────
|
||||||
|
|
||||||
|
export interface AuditTodo {
|
||||||
|
submissionId: number
|
||||||
|
rosterId: number | null
|
||||||
|
taskId: number
|
||||||
|
taskName: string
|
||||||
|
realName: string
|
||||||
|
avatarUrl: string | null
|
||||||
|
orgId: number | null
|
||||||
|
orgName: string | null
|
||||||
|
submitTime: string | null
|
||||||
|
/** 已等待小时数(保留 1 位小数);列表按它正序,等最久的排最前 */
|
||||||
|
waitHours: number | null
|
||||||
|
/** `0` 无问题 / `2` 仅警告 / `3` 有红色(**没有 1**) */
|
||||||
|
level: number
|
||||||
|
redCount: number
|
||||||
|
yellowCount: number
|
||||||
|
/** 红问题的第一句;无红时为 null */
|
||||||
|
redSummary: string | null
|
||||||
|
materialThumbs: string[] | null
|
||||||
|
materialCount: number
|
||||||
|
changedFields: string[] | null
|
||||||
|
rejectCount: number
|
||||||
|
/** 能不能批量通过 —— **服务端算好的,前端不许自己判 */
|
||||||
|
batchApprovable: boolean
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditCounts {
|
||||||
|
all: number
|
||||||
|
clean: number
|
||||||
|
abnormal: number
|
||||||
|
overTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditTodoPage {
|
||||||
|
list: AuditTodo[]
|
||||||
|
total: number
|
||||||
|
counts: AuditCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditTodoQuery {
|
||||||
|
/** `1` 初审(只看到自己带的班)/ `2` 终审(全租户)。默认 1 */
|
||||||
|
node?: number
|
||||||
|
taskId?: number
|
||||||
|
orgId?: number
|
||||||
|
/** `0` 无问题 / `2` 仅警告 / `3` 有红色 */
|
||||||
|
level?: number
|
||||||
|
waitingOverHours?: number
|
||||||
|
keyword?: string
|
||||||
|
pageNum?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditTodos(params: AuditTodoQuery = {}) {
|
||||||
|
return http.get<AuditTodoPage>('/audit/todos', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 详情(§11.2)──────────────────────────────
|
||||||
|
|
||||||
|
export interface AuditChangedItem {
|
||||||
|
field: string
|
||||||
|
label: string | null
|
||||||
|
/** 名册里老师填的值 */
|
||||||
|
rosterValue: string | null
|
||||||
|
/** 学生提交的值 */
|
||||||
|
studentValue: string | null
|
||||||
|
/** 一期恒为 null(`changed_fields` 只有字段名,没有逐字段时间) */
|
||||||
|
changedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditIssue {
|
||||||
|
ruleCode: string
|
||||||
|
/** `1` 提示(蓝)/ `2` 警告(黄)/ `3` 阻断(红)—— 见下方 ISSUE_SEVERITY 的说明 */
|
||||||
|
severity: number
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
materialCode?: string | null
|
||||||
|
field?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditDetect {
|
||||||
|
materialCode: string | null
|
||||||
|
confidence: number | null
|
||||||
|
source: string | null
|
||||||
|
sourceText: string | null
|
||||||
|
conflict: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditFileItem {
|
||||||
|
id: number
|
||||||
|
originalName: string | null
|
||||||
|
standardName: string | null
|
||||||
|
/** 签名链接(默认 30 分钟)—— **不要存库、不要转发 */
|
||||||
|
previewUrl: string | null
|
||||||
|
thumbUrl: string | null
|
||||||
|
finalSizeKb: number | null
|
||||||
|
maxSizeKb: number | null
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
dpi: number | null
|
||||||
|
/** `0` 未校验 / `1` 通过 / `2` 警告 / `3` 错误 */
|
||||||
|
validateStatus: number | null
|
||||||
|
/** `1` 本人 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 */
|
||||||
|
source: number | null
|
||||||
|
/** 识别结果(M4 未做时为 null,界面要能接受) */
|
||||||
|
detect: AuditDetect | null
|
||||||
|
issues: AuditIssue[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditMaterialGroup {
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
required: boolean
|
||||||
|
done: boolean
|
||||||
|
/** 条件材料是否对学生可见(不可见的不该在审核页上要求他交) */
|
||||||
|
visible: boolean
|
||||||
|
files: AuditFileItem[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditApprovePolicy {
|
||||||
|
canApprove: boolean
|
||||||
|
canBatch: boolean
|
||||||
|
reason: string | null
|
||||||
|
redTitles: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRevokePolicy {
|
||||||
|
canRevoke: boolean
|
||||||
|
reason: string | null
|
||||||
|
/** 能撤的话撤哪条记录(原样回传即可) */
|
||||||
|
auditRecordId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditTimelineItem {
|
||||||
|
id: number | null
|
||||||
|
/** `0` 学生提交 / `1` 初审 / `2` 终审 / `3` 材料 */
|
||||||
|
node: number
|
||||||
|
nodeText: string
|
||||||
|
action: number | null
|
||||||
|
actionText: string | null
|
||||||
|
auditorId: number | null
|
||||||
|
auditorName: string | null
|
||||||
|
reasonCode: string | null
|
||||||
|
reasonText: string | null
|
||||||
|
materialCodes: string[] | null
|
||||||
|
time: string | null
|
||||||
|
durationSec: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditDetail {
|
||||||
|
id: number
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
taskId: number
|
||||||
|
taskName: string
|
||||||
|
orgId: number | null
|
||||||
|
orgName: string | null
|
||||||
|
realName: string
|
||||||
|
/** 只给脱敏号;完整号要走单独接口并留痕 */
|
||||||
|
idCardMask: string | null
|
||||||
|
submitTime: string | null
|
||||||
|
occupation: string | null
|
||||||
|
worktype: string | null
|
||||||
|
jobLevel: string | null
|
||||||
|
/** 表单值(内置 + 扩展),键是 snake_case */
|
||||||
|
formData: Record<string, unknown> | null
|
||||||
|
changedFields: string[] | null
|
||||||
|
changedDetail: AuditChangedItem[] | null
|
||||||
|
materials: AuditMaterialGroup[] | null
|
||||||
|
/** 现场校验的问题清单(**不是提交那一刻的快照**) */
|
||||||
|
issues: AuditIssue[] | null
|
||||||
|
redCount: number
|
||||||
|
yellowCount: number
|
||||||
|
rejectCount: number
|
||||||
|
lastRejectCode: string | null
|
||||||
|
lastRejectText: string | null
|
||||||
|
approvePolicy: AuditApprovePolicy | null
|
||||||
|
revokePolicy: AuditRevokePolicy | null
|
||||||
|
timeline: AuditTimelineItem[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditDetail(id: number) {
|
||||||
|
return http.get<AuditDetail>(`/audit/submissions/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditTimeline(id: number) {
|
||||||
|
return http.get<AuditTimelineItem[]>(`/audit/submissions/${id}/timeline`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 动作 ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AuditApproveReq {
|
||||||
|
remark?: string
|
||||||
|
/** `SWIPE` 右滑 / `BUTTON` 按钮 —— 只用于埋点分析误触率 */
|
||||||
|
actionSource?: string
|
||||||
|
/** 终审意见 */
|
||||||
|
finalOpinion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditApproveResult {
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
nextNode: string | null
|
||||||
|
nextNodeText: string | null
|
||||||
|
/** true = 重复请求的幂等回放(结果与第一次相同),不是真的又审了一次 */
|
||||||
|
replayed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveSubmission(id: number, data: AuditApproveReq = {}) {
|
||||||
|
return http.post<AuditApproveResult>(`/audit/submissions/${id}/approve`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRejectReq {
|
||||||
|
/** PRD 附录 C 的原因码,如 `R001` */
|
||||||
|
reasonCode: string
|
||||||
|
/** 补充说明;选 `R999` 时必填 */
|
||||||
|
reasonText?: string
|
||||||
|
/** 是哪几项材料的问题 */
|
||||||
|
materialCodes?: string[]
|
||||||
|
/** 语音转文字结果(可选输入方式) */
|
||||||
|
voiceText?: string
|
||||||
|
suggestReupload?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRejectResult {
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
rejectCount: number
|
||||||
|
/** ⚠️ 一期恒为 false(订阅消息 M5-12 没做)——界面要照着显示"请口头告知学生" */
|
||||||
|
notifySent: boolean
|
||||||
|
needManualNotice: boolean
|
||||||
|
escalated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectSubmission(id: number, data: AuditRejectReq) {
|
||||||
|
return http.post<AuditRejectResult>(`/audit/submissions/${id}/reject`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditBatchApproveResult {
|
||||||
|
approved: number
|
||||||
|
skipped: number
|
||||||
|
details: Array<{
|
||||||
|
submissionId: number
|
||||||
|
realName: string | null
|
||||||
|
skipped: boolean
|
||||||
|
reason: string | null
|
||||||
|
redCount: number | null
|
||||||
|
yellowCount: number | null
|
||||||
|
}> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量通过(§11.3)。
|
||||||
|
*
|
||||||
|
* `onlyClean` 不传 —— 服务端**强制 true**。传 false 不报错也不生效,
|
||||||
|
* 那反而会让读代码的人以为客户端能决定这件事。
|
||||||
|
*/
|
||||||
|
export function batchApprove(submissionIds: number[], actionSource = 'BUTTON') {
|
||||||
|
return http.post<AuditBatchApproveResult>('/audit/submissions/batch-approve', {
|
||||||
|
submissionIds,
|
||||||
|
actionSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRevokeReq {
|
||||||
|
auditRecordId?: number
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRevokeResult {
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
revokedRecordId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeSubmission(id: number, data: AuditRevokeReq = {}) {
|
||||||
|
return http.post<AuditRevokeResult>(`/audit/submissions/${id}/revoke`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 字典与文件 ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface RejectReasonCode {
|
||||||
|
code: string
|
||||||
|
text: string
|
||||||
|
/** true = 选中它必须填补充说明(只有 `R999`) */
|
||||||
|
requiresText: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReasonCodes() {
|
||||||
|
return http.get<RejectReasonCode[]>('/audit/reason-codes')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取材料的访问链接(审核端专用通道)。
|
||||||
|
*
|
||||||
|
* ⚠️ **不要为了"让管理员也能看"而改去调学生端的 `/mp/files/*`** ——
|
||||||
|
* 那条通道校验的是"这份是不是你传的",改它等于把学生端弄漏。
|
||||||
|
* 也**不要把返回的链接存库或转发**:七牛是私有空间,链接有有效期。
|
||||||
|
*/
|
||||||
|
export function getAuditFileUrl(id: number, original = false) {
|
||||||
|
return http.get<{ url: string, expireSeconds: number }>(`/audit/files/${id}/url`, { original })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 展示口径 ───────────────────────────────────
|
||||||
|
|
||||||
|
/** 卡片分级 → 标签色。**只有 0 / 2 / 3**。 */
|
||||||
|
export const LEVEL_TAG: Record<number, { text: string, color: string }> = {
|
||||||
|
0: { text: '无问题', color: 'green' },
|
||||||
|
2: { text: '有警告', color: 'orange' },
|
||||||
|
3: { text: '有红问题', color: 'red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 问题级别 → 颜色。
|
||||||
|
*
|
||||||
|
* ⚠️ **口径是 `1` 提示(蓝)/ `2` 警告(黄)/ `3` 阻断(红)**,由服务端
|
||||||
|
* `SubmissionService` 数红黄时用的常量(`Issue.INFO=1 / WARN=2 / ERROR=3`)定死。
|
||||||
|
* 服务端 DTO 的类注释把顺序写反过一次 —— 以**常量与计数逻辑**为准。
|
||||||
|
* 写反的后果是**把"提示"显示成红色**,于是所有人都在追一个不存在的问题。
|
||||||
|
*/
|
||||||
|
export const ISSUE_SEVERITY: Record<number, { text: string, color: string }> = {
|
||||||
|
1: { text: '提示', color: 'blue' },
|
||||||
|
2: { text: '警告', color: 'orange' },
|
||||||
|
3: { text: '阻断', color: 'red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件校验状态。 */
|
||||||
|
export const FILE_VALIDATE: Record<number, { text: string, color: string }> = {
|
||||||
|
0: { text: '未校验', color: 'gray' },
|
||||||
|
1: { text: '通过', color: 'green' },
|
||||||
|
2: { text: '警告', color: 'orange' },
|
||||||
|
3: { text: '错误', color: 'red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件来源。 */
|
||||||
|
export const FILE_SOURCE: Record<number, string> = {
|
||||||
|
1: '本人上传',
|
||||||
|
2: '班主任代传',
|
||||||
|
3: '管理员代传',
|
||||||
|
4: '整理器导入'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间轴节点类型。 */
|
||||||
|
export const TIMELINE_NODE: Record<number, { text: string, color: string }> = {
|
||||||
|
0: { text: '学生提交', color: 'blue' },
|
||||||
|
1: { text: '初审', color: 'arcoblue' },
|
||||||
|
2: { text: '终审', color: 'green' },
|
||||||
|
3: { text: '材料', color: 'gray' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键错误码 → 一句"接下来怎么办",避免用户只看到一句报错。 */
|
||||||
|
export const AUDIT_ERROR_HINT: Record<number, string> = {
|
||||||
|
40032: '这一单有红问题(阻断级),必须先让问题消失才能通过。',
|
||||||
|
40033: '该任务已经导出打包,结论不能再撤(导出包已经交出去了)。',
|
||||||
|
40034: '该任务已归档,整体只读,不能撤回审核结论。',
|
||||||
|
40301: '这一单不在你的审核范围里(或你不是原审核人)。',
|
||||||
|
40011: '一次提交的单子太多了(上限 200 条),请分批操作。'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 审核统计(§11.6,工作台看板用)─────────────────
|
||||||
|
|
||||||
|
export interface AuditStatisticsVO {
|
||||||
|
pendingCount: number
|
||||||
|
approvedCount: number
|
||||||
|
rejectedCount: number
|
||||||
|
/** 平均审核时长(小时);没有样本时是 null(**不是 0**)。 */
|
||||||
|
avgAuditHours: number | null
|
||||||
|
timeoutCount: number
|
||||||
|
byAuditor: Array<{ auditorName: string, handled: number, avgHours: number | null }>
|
||||||
|
byReason: Array<{ reasonCode: string, reasonText: string, count: number }>
|
||||||
|
/** 契约 §11.6 的时效口径(班级 48h / 后台 72h),界面要显示出来。 */
|
||||||
|
classTimeoutHours: number
|
||||||
|
adminTimeoutHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核统计。
|
||||||
|
*
|
||||||
|
* ⚠️ `avgAuditHours` 为 null 时**不要显示成 0 小时** ——
|
||||||
|
* 「平均 0 小时」看起来像"秒过",而真相是"还没有样本"。
|
||||||
|
*/
|
||||||
|
export function getAuditStatistics(params: {
|
||||||
|
taskId: number
|
||||||
|
orgId?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}) {
|
||||||
|
return http.get<AuditStatisticsVO>('/audit/statistics', params)
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
<template>
|
||||||
|
<a-drawer v-model:visible="visible" :width="1000" :mask-closable="false" :footer="false"
|
||||||
|
unmount-on-close @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
<span>{{ detail?.realName || '填报单' }}</span>
|
||||||
|
<a-tag v-if="detail" class="detail__head-tag" :color="submitTag.color">
|
||||||
|
{{ detail.statusText || submitTag.text }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="detail" class="detail__head-sub">
|
||||||
|
{{ detail.taskName }} · {{ detail.orgName || '未分班' }} · {{ fmtTime(detail.submitTime) }} 提交
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-spin :loading="loading" class="detail__spin">
|
||||||
|
<template v-if="detail">
|
||||||
|
<!--
|
||||||
|
操作区放在**最上面**:审核是"看完就点"的动作,按钮在最下面意味着
|
||||||
|
每次都要滚过整页材料 —— 而审核员一天要看几十份。
|
||||||
|
-->
|
||||||
|
<div class="detail__bar">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button v-if="canApprove" type="primary" size="small" :loading="acting"
|
||||||
|
@click="onApprove">
|
||||||
|
<template #icon><IconCheck /></template>
|
||||||
|
{{ nodeText }}通过
|
||||||
|
</a-button>
|
||||||
|
<a-tooltip v-else :content="detail.approvePolicy?.reason || '当前不能通过'">
|
||||||
|
<a-button type="primary" size="small" disabled>
|
||||||
|
{{ nodeText }}通过
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-button v-if="canReject" status="danger" size="small" @click="onReject">
|
||||||
|
<template #icon><IconClose /></template>
|
||||||
|
驳回
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-popconfirm v-if="canRevoke" content="撤回这次审核结论?撤回是再写一条记录,历史不会被删掉。"
|
||||||
|
@ok="onRevoke">
|
||||||
|
<a-button size="small" status="warning">撤回审核结论</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
|
||||||
|
<a-button size="small" @click="reload">刷新校验结果</a-button>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-space>
|
||||||
|
<span class="detail__muted">证件号 {{ detail.idCardMask || '—' }}</span>
|
||||||
|
<a-tag v-if="detail.redCount" color="red">红 {{ detail.redCount }}</a-tag>
|
||||||
|
<a-tag v-if="detail.yellowCount" color="orange">黄 {{ detail.yellowCount }}</a-tag>
|
||||||
|
<a-tag v-if="detail.rejectCount" color="gray">已驳 {{ detail.rejectCount }} 次</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 有红问题时把"为什么不能通过"写在最显眼处,而不是等用户点了才知道 -->
|
||||||
|
<a-alert v-if="redTitles.length" type="error" class="detail__alert">
|
||||||
|
<template #title>
|
||||||
|
有 {{ redTitles.length }} 项阻断级问题,<strong>不能通过</strong>:
|
||||||
|
{{ redTitles.join(';') }}
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<a-alert v-if="lastReject" type="warning" :show-icon="false" class="detail__alert">
|
||||||
|
<template #title>
|
||||||
|
上次驳回:{{ lastReject.reasonText || lastReject.reasonCode }}
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- ── 材料 ─────────────────────────────── -->
|
||||||
|
<div class="detail__section">材料({{ doneCount }} / {{ materialGroups.length }} 已交)</div>
|
||||||
|
<a-empty v-if="!materialGroups.length" description="这个任务没有配置材料项" />
|
||||||
|
<div v-for="g in materialGroups" :key="g.materialCode" class="detail__group">
|
||||||
|
<div class="detail__group-head">
|
||||||
|
<span class="detail__group-name">{{ g.materialName }}</span>
|
||||||
|
<a-tag v-if="g.required" color="red" size="small">必传</a-tag>
|
||||||
|
<!-- 条件材料对学生不可见时不该要求他交,审核页上要说清 -->
|
||||||
|
<a-tag v-if="!g.visible" color="gray" size="small">本次不适用</a-tag>
|
||||||
|
<a-tag :color="g.done ? 'green' : 'orange'" size="small">
|
||||||
|
{{ g.done ? '已交' : '未交' }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="detail__code">{{ g.materialCode }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="g.files?.length" class="detail__files">
|
||||||
|
<div v-for="f in g.files" :key="f.id" class="detail__file">
|
||||||
|
<FilePreviewImage :file="f" />
|
||||||
|
<div class="detail__file-meta">
|
||||||
|
<div v-if="f.source !== 1" class="detail__file-src">
|
||||||
|
{{ FILE_SOURCE[f.source ?? 0] || '其他来源' }}
|
||||||
|
</div>
|
||||||
|
<div class="detail__muted">
|
||||||
|
{{ f.finalSizeKb ? `${f.finalSizeKb} KB` : '—' }}
|
||||||
|
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
|
||||||
|
<template v-if="f.dpi"> · {{ f.dpi }}dpi</template>
|
||||||
|
</div>
|
||||||
|
<a-tag v-if="f.validateStatus !== null"
|
||||||
|
:color="FILE_VALIDATE[f.validateStatus]?.color ?? 'gray'" size="small">
|
||||||
|
{{ FILE_VALIDATE[f.validateStatus]?.text ?? '未知' }}
|
||||||
|
</a-tag>
|
||||||
|
<!-- 识别结果 M4 未做时为 null:不显示"识别中",那是在说谎 -->
|
||||||
|
<div v-if="f.detect" class="detail__muted">
|
||||||
|
识别:{{ f.detect.sourceText || f.detect.source || '—' }}
|
||||||
|
<template v-if="f.detect.confidence !== null && f.detect.confidence !== undefined">
|
||||||
|
(置信度 {{ f.detect.confidence }})
|
||||||
|
</template>
|
||||||
|
<a-tag v-if="f.detect.conflict" color="red" size="small">疑似串档</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-for="(i, idx) in f.issues ?? []" :key="idx" class="detail__issue-line"
|
||||||
|
:class="`detail__issue-line--${i.severity}`">
|
||||||
|
{{ i.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="detail__muted detail__empty-file">没有文件</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 问题清单 ─────────────────────────── -->
|
||||||
|
<div class="detail__section">
|
||||||
|
校验问题({{ issues.length }})
|
||||||
|
<span class="detail__muted">现场重算,不是提交时的快照</span>
|
||||||
|
</div>
|
||||||
|
<a-empty v-if="!issues.length" description="没有校验问题" />
|
||||||
|
<a-table v-else :data="issues" :pagination="false" size="small" row-key="ruleCode"
|
||||||
|
:bordered="{ cell: true }">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="级别" :width="80" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag :color="ISSUE_SEVERITY[record.severity]?.color ?? 'gray'" size="small">
|
||||||
|
{{ ISSUE_SEVERITY[record.severity]?.text ?? '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="规则" :width="110">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="detail__code">{{ record.ruleCode }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="问题">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div>{{ record.title }}</div>
|
||||||
|
<div class="detail__muted">{{ record.detail }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- ── 学生改过什么 ─────────────────────── -->
|
||||||
|
<template v-if="changedDetail.length">
|
||||||
|
<div class="detail__section">
|
||||||
|
学生改动({{ changedDetail.length }} 项)
|
||||||
|
<span class="detail__muted">预填来自名册,下面是"老师填的 vs 学生交的"</span>
|
||||||
|
</div>
|
||||||
|
<a-table :data="changedDetail" :pagination="false" size="small" row-key="field"
|
||||||
|
:bordered="{ cell: true }">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="字段" :width="160">
|
||||||
|
<template #cell="{ record }">{{ record.label || record.field }}</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="名册(老师填的)">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="detail__muted">{{ display(record.rosterValue) }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="学生提交的">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="detail__changed">{{ display(record.studentValue) }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── 填报表单 ─────────────────────────── -->
|
||||||
|
<div class="detail__section">填报内容</div>
|
||||||
|
<a-descriptions :column="2" size="small" bordered>
|
||||||
|
<a-descriptions-item v-for="row in formRows" :key="row.key" :label="row.label">
|
||||||
|
<span :class="{ detail__changed: row.changed }">{{ display(row.value) }}</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<!-- ── 时间轴 ───────────────────────────── -->
|
||||||
|
<div class="detail__section">审核时间轴(最近的在上)</div>
|
||||||
|
<a-empty v-if="!timeline.length" description="暂无记录" />
|
||||||
|
<div v-for="(t, idx) in timeline" :key="`${t.id}-${idx}`" class="detail__tl">
|
||||||
|
<a-tag :color="TIMELINE_NODE[t.node]?.color ?? 'gray'" size="small">
|
||||||
|
{{ t.nodeText || TIMELINE_NODE[t.node]?.text || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="detail__tl-text">
|
||||||
|
{{ t.actionText || '—' }}
|
||||||
|
<template v-if="t.auditorName"> · {{ t.auditorName }}</template>
|
||||||
|
<template v-if="t.reasonText"> · {{ t.reasonText }}</template>
|
||||||
|
</span>
|
||||||
|
<span class="detail__muted">{{ fmtTime(t.time) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<AuditRejectModal ref="rejectRef" :materials="materialGroups" @rejected="afterAction" />
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuditDetail, AuditIssue } from '@/apis/xt/audit'
|
||||||
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
|
import { IconCheck, IconClose } from '@arco-design/web-vue/es/icon'
|
||||||
|
import {
|
||||||
|
approveSubmission,
|
||||||
|
AUDIT_ERROR_HINT,
|
||||||
|
bizCodeOf,
|
||||||
|
FILE_SOURCE,
|
||||||
|
FILE_VALIDATE,
|
||||||
|
getAuditDetail,
|
||||||
|
ISSUE_SEVERITY,
|
||||||
|
revokeSubmission,
|
||||||
|
TIMELINE_NODE
|
||||||
|
} from '@/apis/xt/audit'
|
||||||
|
import { getFormSchema } from '@/apis/xt/form-schema'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import AuditRejectModal from './AuditRejectModal.vue'
|
||||||
|
import FilePreviewImage from './FilePreviewImage.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核详情(原型 ③ / ⑥,契约 §11.2)。
|
||||||
|
*
|
||||||
|
* ## 四个"必须这么显示"的地方
|
||||||
|
*
|
||||||
|
* 1. **红问题要挡在按钮上**。不能通过的真正原因(红问题)写在按钮旁边的提示里,
|
||||||
|
* 而不是等用户点了再弹一个 40032 —— 那会让人以为是系统故障。
|
||||||
|
* 判定仍以**服务端** `approvePolicy.canApprove` 为准,前端不自己算。
|
||||||
|
*
|
||||||
|
* 2. **问题清单是现场重算的**。所以标题里写明"不是提交时的快照",
|
||||||
|
* 否则审核员会拿它去和手机上的红黄计数对,然后怀疑哪边坏了。
|
||||||
|
*
|
||||||
|
* 3. **识别结果可能为 null**(M4 未做)。这时**不显示"识别中"**,
|
||||||
|
* 那是在说谎;直接不显示这一行。
|
||||||
|
*
|
||||||
|
* 4. **撤回是"再写一条记录"**,不是删掉上一条 —— 所以二次确认里要说清,
|
||||||
|
* 免得有人以为点错了会把历史抹掉。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'AuditDetailDrawer' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** `1` 初审节点(班主任视角)/ `2` 终审节点(管理员视角) */
|
||||||
|
node?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'changed'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const acting = ref(false)
|
||||||
|
const detail = ref<AuditDetail | null>(null)
|
||||||
|
const submissionId = ref<number>(0)
|
||||||
|
const labelMap = ref<Record<string, string>>({})
|
||||||
|
const rejectRef = useTemplateRef('rejectRef')
|
||||||
|
|
||||||
|
const node = computed(() => props.node ?? 1)
|
||||||
|
const nodeText = computed(() => (node.value === 2 ? '终审' : '初审'))
|
||||||
|
|
||||||
|
const materialGroups = computed(() => detail.value?.materials ?? [])
|
||||||
|
const issues = computed<AuditIssue[]>(() => detail.value?.issues ?? [])
|
||||||
|
const changedDetail = computed(() => detail.value?.changedDetail ?? [])
|
||||||
|
const timeline = computed(() => detail.value?.timeline ?? [])
|
||||||
|
|
||||||
|
const doneCount = computed(() => materialGroups.value.filter((g) => g.done).length)
|
||||||
|
|
||||||
|
const redTitles = computed(() => {
|
||||||
|
const fromPolicy = detail.value?.approvePolicy?.redTitles
|
||||||
|
if (fromPolicy?.length) {
|
||||||
|
return fromPolicy
|
||||||
|
}
|
||||||
|
return issues.value.filter((i) => i.severity === 1).map((i) => i.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 判定一律以服务端 policy 为准(前端只负责把原因显示出来) */
|
||||||
|
const canApprove = computed(() => detail.value?.approvePolicy?.canApprove === true)
|
||||||
|
const canReject = computed(() => {
|
||||||
|
const s = detail.value?.status
|
||||||
|
return s === 2 || s === 3
|
||||||
|
})
|
||||||
|
const canRevoke = computed(
|
||||||
|
() => detail.value?.revokePolicy?.canRevoke === true
|
||||||
|
&& userStore.permissions.includes('audit:withdraw')
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastReject = computed(() => {
|
||||||
|
const d = detail.value
|
||||||
|
if (!d?.lastRejectCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { reasonCode: d.lastRejectCode, reasonText: d.lastRejectText }
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitTag = computed(() => {
|
||||||
|
const s = detail.value?.status
|
||||||
|
const map: Record<number, { text: string, color: string }> = {
|
||||||
|
1: { text: '草稿', color: 'gray' },
|
||||||
|
2: { text: '待初审', color: 'orange' },
|
||||||
|
3: { text: '待终审', color: 'arcoblue' },
|
||||||
|
4: { text: '已驳回', color: 'red' },
|
||||||
|
5: { text: '已通过', color: 'green' }
|
||||||
|
}
|
||||||
|
return map[s ?? 0] ?? { text: '未知', color: 'gray' }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 表单值 → 可读文本 */
|
||||||
|
function display(v: unknown): string {
|
||||||
|
if (v === null || v === undefined || v === '') {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (typeof v === 'boolean') {
|
||||||
|
return v ? '是' : '否'
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return v.length ? v.join('、') : '—'
|
||||||
|
}
|
||||||
|
if (typeof v === 'object') {
|
||||||
|
return Object.entries(v as Record<string, unknown>)
|
||||||
|
.map(([k, val]) => `${k}: ${String(val)}`)
|
||||||
|
.join(';')
|
||||||
|
}
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表单行:内置字段优先按 schema 顺序,schema 里没有的(历史扩展字段)排在后面 */
|
||||||
|
const formRows = computed(() => {
|
||||||
|
const data = detail.value?.formData ?? {}
|
||||||
|
const changed = new Set(detail.value?.changedFields ?? [])
|
||||||
|
const rows: Array<{ key: string, label: string, value: unknown, changed: boolean }> = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const [key, label] of Object.entries(labelMap.value)) {
|
||||||
|
if (key in data) {
|
||||||
|
rows.push({ key, label, value: data[key], changed: changed.has(key) })
|
||||||
|
seen.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
rows.push({ key, label: key, value, changed: changed.has(key) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtTime(v: string | null | undefined) {
|
||||||
|
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAuditDetail(submissionId.value)
|
||||||
|
detail.value = res.data ?? null
|
||||||
|
const taskId = detail.value?.taskId
|
||||||
|
if (taskId && !Object.keys(labelMap.value).length) {
|
||||||
|
// 字段中文名从**当前**表单定义取。历史提交若用了已删除的扩展字段,就退回显示 key ——
|
||||||
|
// 显示 key 虽然不漂亮,但不会把两个字段的标签张冠李戴。
|
||||||
|
const fs = await getFormSchema(taskId)
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const f of [...(fs.data?.fields ?? []), ...(fs.data?.extras ?? [])]) {
|
||||||
|
map[f.key] = f.label || f.key
|
||||||
|
}
|
||||||
|
labelMap.value = map
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
await load()
|
||||||
|
Message.success('已重新校验')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id: number) {
|
||||||
|
submissionId.value = id
|
||||||
|
visible.value = true
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过/驳回/撤回之后的统一收尾 */
|
||||||
|
function afterAction() {
|
||||||
|
emit('changed')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onApprove() {
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
const res = await approveSubmission(submissionId.value, {
|
||||||
|
actionSource: 'BUTTON',
|
||||||
|
finalOpinion: node.value === 2 ? '终审通过' : undefined
|
||||||
|
})
|
||||||
|
const r = res.data
|
||||||
|
if (r?.replayed) {
|
||||||
|
Message.info('这一单刚才已经审过了(重复请求已幂等处理)')
|
||||||
|
} else {
|
||||||
|
Message.success(`${nodeText.value}通过`)
|
||||||
|
}
|
||||||
|
afterAction()
|
||||||
|
} catch (e) {
|
||||||
|
const code = bizCodeOf(e)
|
||||||
|
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||||
|
if (code === 40032) {
|
||||||
|
// 红问题:把服务端的话原样展示 + 提示,并刷新详情(现场重算,计数可能变了)
|
||||||
|
Modal.warning({
|
||||||
|
title: '不能通过',
|
||||||
|
content: `${(e as Error).message}\n\n${hint}\n\n点「确定」后本页会重新校验一次。`,
|
||||||
|
hideCancel: true,
|
||||||
|
onOk: () => load()
|
||||||
|
})
|
||||||
|
} else if (hint) {
|
||||||
|
Message.error(`${(e as Error).message}(${hint})`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReject() {
|
||||||
|
rejectRef.value?.open(submissionId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevoke() {
|
||||||
|
try {
|
||||||
|
const res = await revokeSubmission(submissionId.value, {
|
||||||
|
auditRecordId: detail.value?.revokePolicy?.auditRecordId ?? undefined,
|
||||||
|
reason: 'PC 端撤回'
|
||||||
|
})
|
||||||
|
Message.success(res.data?.statusText || '已撤回')
|
||||||
|
afterAction()
|
||||||
|
} catch (e) {
|
||||||
|
const code = bizCodeOf(e)
|
||||||
|
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||||
|
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.detail {
|
||||||
|
&__head-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head-sub {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spin {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__alert {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
|
||||||
|
.detail__muted {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 340px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-src {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-file {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__issue-line {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
// ⚠️ 1 提示(蓝)/ 2 警告(黄)/ 3 阻断(红)—— 与服务端 Issue 常量同口径。
|
||||||
|
// 写反的后果是把"提示"染成红色,所有人都会去追一个不存在的问题。
|
||||||
|
&--3 {
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--2 {
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--1 {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__changed {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tl {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px dashed var(--color-border-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tl-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal v-model:visible="visible" title="驳回填报单" :width="560" :mask-closable="false"
|
||||||
|
:ok-loading="saving" ok-text="确认驳回" @before-ok="onOk" @close="close">
|
||||||
|
<a-alert type="warning" :show-icon="false" class="reject__tip">
|
||||||
|
<template #title>
|
||||||
|
驳回后学生会看到原因,<strong>可以改完重交</strong>。
|
||||||
|
一期还没有通知渠道,所以还需要<strong>口头告知学生</strong>。
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<a-form :model="form" layout="vertical">
|
||||||
|
<a-form-item label="驳回原因" required>
|
||||||
|
<a-radio-group v-model="form.reasonCode" direction="vertical" @change="onCodeChange">
|
||||||
|
<a-radio v-for="r in reasonCodes" :key="r.code" :value="r.code" class="reject__radio">
|
||||||
|
{{ r.text }}
|
||||||
|
<span v-if="r.requiresText" class="reject__req">(必须补充说明)</span>
|
||||||
|
</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
R999「其他原因」必须填说明:不填的话学生只看到"其他原因"四个字,
|
||||||
|
等于什么也没告诉他就让他重做一份。
|
||||||
|
-->
|
||||||
|
<a-form-item :label="requiresText ? '补充说明(必填)' : '补充说明(可选)'"
|
||||||
|
:validate-status="textError ? 'error' : undefined" :help="textError">
|
||||||
|
<a-textarea v-model="form.reasonText" :rows="3" :max-length="200" show-word-limit
|
||||||
|
placeholder="例如:身份证反面缺角看不清,请重新拍一张完整的" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="materials.length" label="是哪几项材料的问题(可选)">
|
||||||
|
<a-checkbox-group v-model="form.materialCodes">
|
||||||
|
<a-checkbox v-for="m in materials" :key="m.materialCode" :value="m.materialCode">
|
||||||
|
{{ m.materialName }}
|
||||||
|
</a-checkbox>
|
||||||
|
</a-checkbox-group>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="是否需要学生重新上传">
|
||||||
|
<a-switch v-model="form.suggestReupload" />
|
||||||
|
<span class="reject__hint">
|
||||||
|
关掉 = 只是信息填错,改文字即可;开着 = 材料本身有问题,必须重传。
|
||||||
|
</span>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuditMaterialGroup, RejectReasonCode } from '@/apis/xt/audit'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { getReasonCodes, rejectSubmission } from '@/apis/xt/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回弹窗(原型 STEP ⑤)。
|
||||||
|
*
|
||||||
|
* 三条设计理由:
|
||||||
|
* 1. **必须点按钮**(原型铁律:班主任端通过可以右滑,驳回不行)——
|
||||||
|
* 误触通过的后果是"再撤一次",误触驳回的后果是学生白改一份材料,两者不对称。
|
||||||
|
* 2. **原因快选**:附录 C 的 10 条写在代码里(`RejectReasonCatalog`),
|
||||||
|
* 字典从服务端取,不在前端硬编 —— 否则加一条原因要发一次前端。
|
||||||
|
* 3. **R999 必须填说明**:只有"其他原因"四个字,学生无从下手。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'AuditRejectModal' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
materials: AuditMaterialGroup[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'rejected'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const submissionId = ref<number>(0)
|
||||||
|
const reasonCodes = ref<RejectReasonCode[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
reasonCode: '',
|
||||||
|
reasonText: '',
|
||||||
|
materialCodes: [] as string[],
|
||||||
|
suggestReupload: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const textError = ref('')
|
||||||
|
|
||||||
|
const requiresText = computed(
|
||||||
|
() => reasonCodes.value.find((r) => r.code === form.reasonCode)?.requiresText === true
|
||||||
|
)
|
||||||
|
|
||||||
|
const materials = computed(() => props.materials ?? [])
|
||||||
|
|
||||||
|
/** 换个原因就把上一次的报错清掉,否则红字会一直挂着 */
|
||||||
|
function onCodeChange() {
|
||||||
|
textError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id: number) {
|
||||||
|
submissionId.value = id
|
||||||
|
form.reasonCode = ''
|
||||||
|
form.reasonText = ''
|
||||||
|
form.materialCodes = []
|
||||||
|
form.suggestReupload = true
|
||||||
|
textError.value = ''
|
||||||
|
visible.value = true
|
||||||
|
if (!reasonCodes.value.length) {
|
||||||
|
try {
|
||||||
|
const res = await getReasonCodes()
|
||||||
|
reasonCodes.value = res.data ?? []
|
||||||
|
} catch {
|
||||||
|
// 字典取不到时不静默:让用户知道"现在没法选原因",而不是看到一个空列表乱猜
|
||||||
|
Message.warning('驳回原因字典没取到,请刷新页面重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOk() {
|
||||||
|
if (!form.reasonCode) {
|
||||||
|
Message.warning('请选择驳回原因')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (requiresText.value && !form.reasonText.trim()) {
|
||||||
|
textError.value = '选择「其他原因」时必须写清是什么问题'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await rejectSubmission(submissionId.value, {
|
||||||
|
reasonCode: form.reasonCode,
|
||||||
|
reasonText: form.reasonText.trim() || undefined,
|
||||||
|
materialCodes: form.materialCodes.length ? form.materialCodes : undefined,
|
||||||
|
suggestReupload: form.suggestReupload
|
||||||
|
})
|
||||||
|
const r = res.data
|
||||||
|
if (r?.needManualNotice) {
|
||||||
|
Message.warning({
|
||||||
|
content: '已驳回。通知没发出去(一期没有通知渠道),请口头告知学生',
|
||||||
|
duration: 5000
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Message.success(r?.statusText || '已驳回')
|
||||||
|
}
|
||||||
|
if (r?.escalated) {
|
||||||
|
Message.info('该单驳回次数已达上限,管理员会收到提醒')
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
emit('rejected')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.reject {
|
||||||
|
&__tip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__radio {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__req {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fp">
|
||||||
|
<!--
|
||||||
|
七牛空间是**私有**的 → 预览必须用签名链接,而签名链接有有效期(默认 30 分钟),
|
||||||
|
所以这里**每次挂载现取**,不缓存、不入库、不落 localStorage。
|
||||||
|
-->
|
||||||
|
<div class="fp__box" :class="{ 'fp__box--empty': !src && !loading }" @click="open">
|
||||||
|
<a-spin v-if="loading" :size="16" />
|
||||||
|
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
|
||||||
|
<div v-else class="fp__fail">预览取不到<br>(链接过期或已删除)</div>
|
||||||
|
</div>
|
||||||
|
<div class="fp__name" :title="file.standardName || file.originalName || ''">
|
||||||
|
{{ file.standardName || file.originalName || '(无文件名)' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuditFileItem } from '@/apis/xt/audit'
|
||||||
|
import { getAuditFileUrl } from '@/apis/xt/audit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 材料缩略图(审核端)。
|
||||||
|
*
|
||||||
|
* 为什么单独封一个组件:审核页与待办卡片都要显示缩略图,而"取签名链接"这件事
|
||||||
|
* **不能各写一份** —— 一处忘了换审核端通道(`/audit/files/*`)就会去调学生端的接口,
|
||||||
|
* 那条通道校验的是"这份是不是你传的",审核人必然拿不到。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'FilePreviewImage' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
file: AuditFileItem
|
||||||
|
/** 用一个已经拿到的链接(待办卡片里的 materialThumbs 就是现成的签名链接) */
|
||||||
|
url?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const src = ref<string>(props.url ?? '')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (props.url) {
|
||||||
|
src.value = props.url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAuditFileUrl(props.file.id)
|
||||||
|
src.value = res.data?.url ?? ''
|
||||||
|
} catch {
|
||||||
|
// 取不到链接不是致命错误:材料本身还在,只是这次看不了。
|
||||||
|
// 所以只留一个"预览取不到"的占位,不弹错误 —— 审核几十份材料时弹窗会淹没页面。
|
||||||
|
src.value = ''
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点开看原图(新标签页)。链接是现场的,所以点了才取。 */
|
||||||
|
async function open() {
|
||||||
|
if (!src.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(src.value, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.fp {
|
||||||
|
width: 108px;
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
border: 1px solid var(--color-border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&--empty {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-spin) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<template>
|
||||||
|
<GiPageLayout>
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<template #title>审核</template>
|
||||||
|
<template #extra>
|
||||||
|
<span class="audit__hint">
|
||||||
|
初审由班主任做(只看得到自己带的班),终审由管理员做
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
节点切换用 tab:初审与终审是**同一份数据的两个阶段**,
|
||||||
|
而不是两个功能。做成两个页面的话,用户会以为自己进错了地方。
|
||||||
|
-->
|
||||||
|
<a-tabs v-model:active-key="node" type="rounded" size="small" @change="onNodeChange">
|
||||||
|
<a-tab-pane :key="1" title="初审(班主任 / 审核员)" />
|
||||||
|
<a-tab-pane v-if="canFinal" :key="2" title="终审(管理员)" />
|
||||||
|
</a-tabs>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
四个计数**不受下方的 level 过滤影响**(服务端算的),
|
||||||
|
否则点一下「有异常」就再也回不到「全部」。
|
||||||
|
-->
|
||||||
|
<div class="audit__stats">
|
||||||
|
<div v-for="c in countCards" :key="c.key" class="audit__stat"
|
||||||
|
:class="[`audit__stat--${c.tone}`, levelFilter === c.level && c.level !== undefined ? 'audit__stat--on' : '']"
|
||||||
|
@click="setLevel(c.level)">
|
||||||
|
<div class="audit__stat-num">{{ c.value }}</div>
|
||||||
|
<div class="audit__stat-label">{{ c.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-row-tool">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button type="primary" size="small" :disabled="!selectedKeys.length" :loading="batching"
|
||||||
|
@click="onBatchApprove">
|
||||||
|
<template #icon><IconCheck /></template>
|
||||||
|
批量通过({{ selectedKeys.length }})
|
||||||
|
</a-button>
|
||||||
|
<span class="audit__hint">
|
||||||
|
只能带<strong>无问题</strong>的单;红黄一律自动跳过并说明原因
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-space wrap>
|
||||||
|
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||||
|
class="audit__task" :loading="taskLoading" @change="search">
|
||||||
|
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select v-model="query.waitingOverHours" placeholder="等待时长" allow-clear size="small"
|
||||||
|
class="audit__wait" @change="search">
|
||||||
|
<a-option :value="24">超过 24 小时</a-option>
|
||||||
|
<a-option :value="48">超过 48 小时</a-option>
|
||||||
|
<a-option :value="72">超过 72 小时</a-option>
|
||||||
|
</a-select>
|
||||||
|
<a-input-search v-model="query.keyword" placeholder="搜索姓名 / 班级" allow-clear
|
||||||
|
class="audit__search" @search="search" @clear="search" />
|
||||||
|
<a-button size="small" @click="reload">
|
||||||
|
<template #icon><IconRefresh /></template>
|
||||||
|
刷新
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
列宽按可用宽度算过:1366 宽的屏幕减 200 侧栏 ≈ 1140,本页在卡片里再减内边距,
|
||||||
|
约 1060 可用。下面 8 列合计 1020,不出现横向滚动(钉住的操作列不会盖住「状态」)。
|
||||||
|
-->
|
||||||
|
<a-table :data="list" :loading="loading" row-key="submissionId" :pagination="pagination"
|
||||||
|
:scroll="{ x: 1020 }" :row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||||
|
:selected-keys="selectedKeys" @select="onSelect" @select-all="onSelectAll"
|
||||||
|
@page-change="onPageChange">
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="学生" :width="170">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="audit__name">
|
||||||
|
{{ record.realName }}
|
||||||
|
<a-tag v-if="record.rejectCount" color="gray" size="small">
|
||||||
|
驳回 {{ record.rejectCount }} 次
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="audit__muted">{{ record.orgName || '未分班' }}</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div>{{ record.taskName }}</div>
|
||||||
|
<div class="audit__muted">{{ fmtTime(record.submitTime) }} 提交</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="材料" :width="150">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="audit__thumbs">
|
||||||
|
<img v-for="(u, i) in (record.materialThumbs ?? []).slice(0, 3)" :key="i" :src="u"
|
||||||
|
class="audit__thumb" alt="材料缩略图" @click.stop="openDetail(record)">
|
||||||
|
<span v-if="!(record.materialThumbs ?? []).length" class="audit__muted">
|
||||||
|
{{ record.materialCount }} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="校验" :width="130">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag :color="LEVEL_TAG[record.level]?.color ?? 'gray'" size="small">
|
||||||
|
{{ LEVEL_TAG[record.level]?.text ?? '未知' }}
|
||||||
|
</a-tag>
|
||||||
|
<div v-if="record.redSummary" class="audit__red" :title="record.redSummary">
|
||||||
|
{{ record.redSummary }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="record.redCount || record.yellowCount" class="audit__muted">
|
||||||
|
红 {{ record.redCount }} · 黄 {{ record.yellowCount }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="学生改过" :width="120" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span v-if="record.changedFields?.length" class="audit__changed">
|
||||||
|
{{ record.changedFields.length }} 项
|
||||||
|
</span>
|
||||||
|
<span v-else class="audit__muted">—</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="状态" :width="100" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag size="small">{{ record.statusText }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="已等待" :width="90" align="center">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span :class="waitTone(record.waitHours)">{{ fmtWait(record.waitHours) }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space size="mini" wrap>
|
||||||
|
<a-link @click="openDetail(record)">审核</a-link>
|
||||||
|
<!--
|
||||||
|
能批量通过的才给「通过」快捷键(服务端算好的 batchApprovable):
|
||||||
|
有红黄的单必须进去看,给了快捷键就等于鼓励手快全选。
|
||||||
|
-->
|
||||||
|
<a-link v-if="record.batchApprovable" status="success" @click="quickApprove(record)">
|
||||||
|
通过
|
||||||
|
</a-link>
|
||||||
|
<a-link status="danger" @click="openReject(record)">驳回</a-link>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<AuditDetailDrawer ref="detailRef" :node="node" @changed="load" />
|
||||||
|
<AuditRejectModal ref="rejectRef" :materials="[]" @rejected="load" />
|
||||||
|
</GiPageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AuditTodo } from '@/apis/xt/audit'
|
||||||
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
|
import { IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||||
|
import {
|
||||||
|
approveSubmission,
|
||||||
|
AUDIT_ERROR_HINT,
|
||||||
|
batchApprove,
|
||||||
|
bizCodeOf,
|
||||||
|
getAuditTodos,
|
||||||
|
LEVEL_TAG
|
||||||
|
} from '@/apis/xt/audit'
|
||||||
|
import { getTaskList } from '@/apis/xt/task'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import AuditDetailDrawer from './AuditDetailDrawer.vue'
|
||||||
|
import AuditRejectModal from './AuditRejectModal.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核台(原型 ② / ⑥,契约 §11.1)。
|
||||||
|
*
|
||||||
|
* ## 这一屏最容易做错的三件事
|
||||||
|
*
|
||||||
|
* 1. **不要在前端按班级过滤**。"权限 ≠ 范围"由服务端保证(SQL 里显式 `1 = 0`),
|
||||||
|
* 前端再过滤一次只会在口径不一致时制造假象 —— 而且前端过滤不了越权。
|
||||||
|
* 2. **四个计数不能跟着 level 过滤变**。否则点一下「有异常」,其它计数变成 0,
|
||||||
|
* 用户就回不到「全部」了。服务端已经这么算,前端照显示即可。
|
||||||
|
* 3. **批量通过必须逐条说清谁被跳过**。只说"过了 3 个"会让用户反复点那个大按钮。
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'Audit' })
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const canFinal = computed(() => userStore.permissions.includes('audit:final'))
|
||||||
|
|
||||||
|
const node = ref<number>(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const batching = ref(false)
|
||||||
|
const list = ref<AuditTodo[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const counts = ref({ all: 0, clean: 0, abnormal: 0, overTime: 0 })
|
||||||
|
const selectedKeys = ref<number[]>([])
|
||||||
|
const levelFilter = ref<number | undefined>(undefined)
|
||||||
|
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||||
|
const taskLoading = ref(false)
|
||||||
|
|
||||||
|
const query = reactive({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
taskId: undefined as number | undefined,
|
||||||
|
waitingOverHours: undefined as number | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailRef = useTemplateRef('detailRef')
|
||||||
|
const rejectRef = useTemplateRef('rejectRef')
|
||||||
|
|
||||||
|
const countCards = computed(() => [
|
||||||
|
{ key: 'all', label: '全部待办', value: counts.value.all, tone: 'blue', level: undefined as number | undefined },
|
||||||
|
{ key: 'clean', label: '无问题', value: counts.value.clean, tone: 'green', level: 0 },
|
||||||
|
{ key: 'abnormal', label: '有红/黄', value: counts.value.abnormal, tone: 'orange', level: 3 },
|
||||||
|
{ key: 'overTime', label: '已超时', value: counts.value.overTime, tone: 'red', level: undefined }
|
||||||
|
])
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: query.pageNum,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
total: total.value,
|
||||||
|
showTotal: true,
|
||||||
|
showPageSize: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
function fmtTime(v: string | null) {
|
||||||
|
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWait(h: number | null) {
|
||||||
|
if (h === null || h === undefined) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (h < 24) {
|
||||||
|
return `${h} 小时`
|
||||||
|
}
|
||||||
|
return `${Math.floor(h / 24)} 天`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等得越久越显眼:超过 48 小时变红 —— 超时是这一屏唯一要主动推的事 */
|
||||||
|
function waitTone(h: number | null) {
|
||||||
|
if (h === null || h === undefined) {
|
||||||
|
return 'audit__muted'
|
||||||
|
}
|
||||||
|
if (h >= 48) {
|
||||||
|
return 'audit__wait--bad'
|
||||||
|
}
|
||||||
|
if (h >= 24) {
|
||||||
|
return 'audit__wait--warn'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAuditTodos({
|
||||||
|
node: node.value,
|
||||||
|
taskId: query.taskId,
|
||||||
|
level: levelFilter.value,
|
||||||
|
waitingOverHours: query.waitingOverHours,
|
||||||
|
keyword: query.keyword.trim() || undefined,
|
||||||
|
pageNum: query.pageNum,
|
||||||
|
pageSize: query.pageSize
|
||||||
|
})
|
||||||
|
list.value = res.data?.list ?? []
|
||||||
|
total.value = res.data?.total ?? 0
|
||||||
|
counts.value = res.data?.counts ?? { all: 0, clean: 0, abnormal: 0, overTime: 0 }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
taskLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||||
|
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||||
|
} finally {
|
||||||
|
taskLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
query.pageNum = 1
|
||||||
|
selectedKeys.value = []
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeChange() {
|
||||||
|
levelFilter.value = undefined
|
||||||
|
selectedKeys.value = []
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点计数卡片 = 按分级过滤;再点一次取消 */
|
||||||
|
function setLevel(v: number | undefined) {
|
||||||
|
if (v === undefined) {
|
||||||
|
levelFilter.value = undefined
|
||||||
|
} else {
|
||||||
|
levelFilter.value = levelFilter.value === v ? undefined : v
|
||||||
|
}
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
query.pageNum = page
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(keys: (string | number)[]) {
|
||||||
|
selectedKeys.value = keys as number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectAll(checked: boolean) {
|
||||||
|
selectedKeys.value = checked ? list.value.map((r) => r.submissionId) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row: AuditTodo) {
|
||||||
|
detailRef.value?.open(row.submissionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(row: AuditTodo) {
|
||||||
|
rejectRef.value?.open(row.submissionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表里的快捷通过:进详情再点的动作这里省掉,但只对"无问题"的单开放 */
|
||||||
|
async function quickApprove(row: AuditTodo) {
|
||||||
|
try {
|
||||||
|
const res = await approveSubmission(row.submissionId, { actionSource: 'BUTTON' })
|
||||||
|
Message.success(res.data?.replayed ? '这一单刚才已经审过了' : '已通过')
|
||||||
|
search()
|
||||||
|
} catch (e) {
|
||||||
|
const code = bizCodeOf(e)
|
||||||
|
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||||
|
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBatchApprove() {
|
||||||
|
const ids = selectedKeys.value
|
||||||
|
if (!ids.length) {
|
||||||
|
Message.warning('请先勾选要通过的单子')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batching.value = true
|
||||||
|
try {
|
||||||
|
const res = await batchApprove(ids)
|
||||||
|
const r = res.data
|
||||||
|
const skipped = r?.details?.filter((d) => d.skipped) ?? []
|
||||||
|
if (!skipped.length) {
|
||||||
|
Message.success(`已通过 ${r?.approved ?? 0} 份`)
|
||||||
|
} else {
|
||||||
|
// 逐条列出被跳过的原因 —— 这是"用户下次能不能自己判断"的关键。
|
||||||
|
// ⚠️ 这里用 <pre> 纯文本而不是富文本:本页是 `lang="ts"` 的 SFC,
|
||||||
|
// 在里面写 JSX 会直接编译失败(历史上踩过),而为了一个弹窗改成 tsx 不值得。
|
||||||
|
const lines = skipped.map((d) => `· ${d.realName || `#${d.submissionId}`}:${d.reason || '不符合批量通过条件'}`)
|
||||||
|
Modal.info({
|
||||||
|
title: `通过 ${r?.approved ?? 0} 份,跳过 ${skipped.length} 份`,
|
||||||
|
content: `下面这些没有被通过:\n\n${lines.join('\n')}`,
|
||||||
|
hideCancel: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
selectedKeys.value = []
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
const code = bizCodeOf(e)
|
||||||
|
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||||
|
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||||
|
} finally {
|
||||||
|
batching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
load()
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.audit {
|
||||||
|
&__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
border-color: rgb(var(--primary-6));
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--primary-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue .audit__stat-num {
|
||||||
|
color: rgb(var(--blue-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green .audit__stat-num {
|
||||||
|
color: rgb(var(--green-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--orange .audit__stat-num {
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red .audit__stat-num {
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-num {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__task {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wait {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--color-border-2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__red {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__changed {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wait {
|
||||||
|
&--warn {
|
||||||
|
color: rgb(var(--orange-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user