feat(audit): 审核台前端与审核统计 —— 批量通过逐条给原因,驳回走附录 C 原因码
待办台四个计数 + 分级过滤 + 批量通过(被跳过的条目逐条列原因,只说「过了 3 个」 会让人反复点那个大按钮);详情抽屉含材料/问题清单/改动明细/时间轴/通过·驳回·撤回。 前端不做范围过滤:权限 ≠ 范围,由服务端用 1 = 0 守死,前端再筛一次只会制造假象。 canApprove=false 时按钮直接禁用并显示原因,而不是等他点了再弹 40032。 审核端看材料走单独一条通道(不复用学生那条),材料 URL 用 ?thumb=true 取缩略图, 默认 false —— 详情页要看防伪纹。 验证:vue-tsc / eslint 0 错;浏览器实测四个菜单与批量通过弹窗。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user