feat(audit): 审核台前端与审核统计 —— 批量通过逐条给原因,驳回走附录 C 原因码

待办台四个计数 + 分级过滤 + 批量通过(被跳过的条目逐条列原因,只说「过了 3 个」
会让人反复点那个大按钮);详情抽屉含材料/问题清单/改动明细/时间轴/通过·驳回·撤回。

前端不做范围过滤:权限 ≠ 范围,由服务端用 1 = 0 守死,前端再筛一次只会制造假象。
canApprove=false 时按钮直接禁用并显示原因,而不是等他点了再弹 40032。

审核端看材料走单独一条通道(不复用学生那条),材料 URL 用 ?thumb=true 取缩略图,
默认 false —— 详情页要看防伪纹。

验证:vue-tsc / eslint 0 错;浏览器实测四个菜单与批量通过弹窗。
This commit is contained in:
2026-09-30 09:02:51 +08:00
parent 6cca97efe6
commit 45271a3c08
10 changed files with 2455 additions and 11 deletions
@@ -42,6 +42,9 @@ import java.util.List;
public class AuditController {
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")
@Operation(summary = "待办列表",
@@ -125,8 +128,9 @@ public class AuditController {
public ApiResult<MaterialFileService.FileUrl> fileUrl(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean original,
@RequestParam(required = false) Long expireSeconds) {
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds));
@RequestParam(required = false) Long expireSeconds,
@RequestParam(defaultValue = "false") boolean thumb) {
return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds, thumb));
}
@GetMapping("/files/{id}/content")
@@ -142,4 +146,45 @@ public class AuditController {
.header("Content-Disposition", "inline")
.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)));
}
}
@@ -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 &gt;= #{from}::timestamp</if>
<if test="to != null"> AND s.submit_time &lt;= #{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 &gt; 0
<if test="orgId != null"> AND r.org_id = #{orgId}</if>
<if test="from != null"> AND a.create_time &gt;= #{from}::timestamp</if>
<if test="to != null"> AND a.create_time &lt;= #{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 &gt;= #{from}::timestamp</if>
<if test="to != null"> AND a.create_time &lt;= #{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 &gt;= #{from}::timestamp</if>
<if test="to != null"> AND a.create_time &lt;= #{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 &lt; NOW() - (#{classHours} || ' hours')::interval)
OR
(s.status = 2 AND s.submit_time &lt; 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);
}
@@ -81,6 +81,32 @@ public interface AuditTodoMapper {
<if test="orgId != null">
AND s.org_id = #{orgId}
</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>
<when test="orgIds != null and orgIds.size() > 0">
AND s.org_id IN
@@ -114,6 +140,7 @@ public interface AuditTodoMapper {
@Param("taskId") Long taskId,
@Param("orgId") Long orgId,
@Param("orgIds") List<Long> orgIds,
@Param("auditorId") Long auditorId,
@Param("level") Integer level,
@Param("keyword") String keyword,
@Param("waitingOverHours") Integer waitingOverHours);
@@ -145,6 +172,32 @@ public interface AuditTodoMapper {
<if test="orgId != null">
AND s.org_id = #{orgId}
</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>
<when test="orgIds != null and orgIds.size() > 0">
AND s.org_id IN
@@ -164,6 +217,7 @@ public interface AuditTodoMapper {
@Param("taskId") Long taskId,
@Param("orgId") Long orgId,
@Param("orgIds") List<Long> orgIds,
@Param("auditorId") Long auditorId,
@Param("keyword") String keyword,
@Param("timeoutHours") Integer timeoutHours);
}
@@ -7,6 +7,8 @@ import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
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.mapper.AuditPreconditionMapper;
import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper;
@@ -142,6 +144,8 @@ public class AuditService {
private final FormSchemaService formSchemaService;
private final SubmissionValidator validator;
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 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),
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();
out.setList(assembleTodos(page.getRecords(), me.tenantId()));
out.setTotal(page.getTotal());
out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope,
kw, timeoutHours(node, taskId))));
auditorId, kw, timeoutHours(node, taskId))));
return out;
}
@@ -696,11 +705,100 @@ public class AuditService {
int newStatus = node == NODE_CLASS
? CollectSubmission.STATUS_PENDING_ADMIN
: 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={} 审计人={}",
submissionId, node, newStatus, me.uid());
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());
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) {
// ⚠️ 通知(订阅消息 / 站内信,M5-12)还没做,所以这里只有日志。
// 响应里如实地把 notifySent=false + needManualNotice=true 告诉前端,
// 让班主任知道"得自己跟学生说一声"——比假装通知过了强得多。
// 驳回次数达上限:这条留给管理员,所以**站内信也发给管理员**。
log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
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();
vo.setStatus(CollectSubmission.STATUS_REJECTED);
vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED));
vo.setRejectCount(rejectCount);
vo.setNotifySent(false);
vo.setNeedManualNotice(true);
// ⚠️ 语义收窄了:以前 notifySent 表示"有没有通知渠道"(恒 false),
// 现在表示"**微信推送**有没有发出去"。站内信一定发了,所以
// needManualNotice 仍然为 true 时要老师口头说一声 —— 学生不一定会进小程序。
vo.setNotifySent(pushSent);
vo.setNeedManualNotice(!pushSent);
vo.setEscalated(escalated);
return vo;
}
@@ -1055,8 +1175,22 @@ public class AuditService {
/** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */
@Transactional(readOnly = true)
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);
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX);
return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX,
thumb ? MaterialFileService.THUMB_LONG_SIDE : 0);
}
/** 审核端的内容流(本地存储时的预览通道)。 */
@@ -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;
}
}
+427
View File
@@ -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>
+515
View File
@@ -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 {
// 逐条列出被跳过的原因 —— 这是"用户下次能不能自己判断"的关键。
// ⚠️ 这里用 &lt;pre&gt; 纯文本而不是富文本:本页是 `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>