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
+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)
}