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)
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="1000" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>{{ detail?.realName || '填报单' }}</span>
|
||||
<a-tag v-if="detail" class="detail__head-tag" :color="submitTag.color">
|
||||
{{ detail.statusText || submitTag.text }}
|
||||
</a-tag>
|
||||
<span v-if="detail" class="detail__head-sub">
|
||||
{{ detail.taskName }} · {{ detail.orgName || '未分班' }} · {{ fmtTime(detail.submitTime) }} 提交
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-spin :loading="loading" class="detail__spin">
|
||||
<template v-if="detail">
|
||||
<!--
|
||||
操作区放在**最上面**:审核是"看完就点"的动作,按钮在最下面意味着
|
||||
每次都要滚过整页材料 —— 而审核员一天要看几十份。
|
||||
-->
|
||||
<div class="detail__bar">
|
||||
<a-space wrap>
|
||||
<a-button v-if="canApprove" type="primary" size="small" :loading="acting"
|
||||
@click="onApprove">
|
||||
<template #icon><IconCheck /></template>
|
||||
{{ nodeText }}通过
|
||||
</a-button>
|
||||
<a-tooltip v-else :content="detail.approvePolicy?.reason || '当前不能通过'">
|
||||
<a-button type="primary" size="small" disabled>
|
||||
{{ nodeText }}通过
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-button v-if="canReject" status="danger" size="small" @click="onReject">
|
||||
<template #icon><IconClose /></template>
|
||||
驳回
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm v-if="canRevoke" content="撤回这次审核结论?撤回是再写一条记录,历史不会被删掉。"
|
||||
@ok="onRevoke">
|
||||
<a-button size="small" status="warning">撤回审核结论</a-button>
|
||||
</a-popconfirm>
|
||||
|
||||
<a-button size="small" @click="reload">刷新校验结果</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-space>
|
||||
<span class="detail__muted">证件号 {{ detail.idCardMask || '—' }}</span>
|
||||
<a-tag v-if="detail.redCount" color="red">红 {{ detail.redCount }}</a-tag>
|
||||
<a-tag v-if="detail.yellowCount" color="orange">黄 {{ detail.yellowCount }}</a-tag>
|
||||
<a-tag v-if="detail.rejectCount" color="gray">已驳 {{ detail.rejectCount }} 次</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 有红问题时把"为什么不能通过"写在最显眼处,而不是等用户点了才知道 -->
|
||||
<a-alert v-if="redTitles.length" type="error" class="detail__alert">
|
||||
<template #title>
|
||||
有 {{ redTitles.length }} 项阻断级问题,<strong>不能通过</strong>:
|
||||
{{ redTitles.join(';') }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-alert v-if="lastReject" type="warning" :show-icon="false" class="detail__alert">
|
||||
<template #title>
|
||||
上次驳回:{{ lastReject.reasonText || lastReject.reasonCode }}
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 材料 ─────────────────────────────── -->
|
||||
<div class="detail__section">材料({{ doneCount }} / {{ materialGroups.length }} 已交)</div>
|
||||
<a-empty v-if="!materialGroups.length" description="这个任务没有配置材料项" />
|
||||
<div v-for="g in materialGroups" :key="g.materialCode" class="detail__group">
|
||||
<div class="detail__group-head">
|
||||
<span class="detail__group-name">{{ g.materialName }}</span>
|
||||
<a-tag v-if="g.required" color="red" size="small">必传</a-tag>
|
||||
<!-- 条件材料对学生不可见时不该要求他交,审核页上要说清 -->
|
||||
<a-tag v-if="!g.visible" color="gray" size="small">本次不适用</a-tag>
|
||||
<a-tag :color="g.done ? 'green' : 'orange'" size="small">
|
||||
{{ g.done ? '已交' : '未交' }}
|
||||
</a-tag>
|
||||
<span class="detail__code">{{ g.materialCode }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="g.files?.length" class="detail__files">
|
||||
<div v-for="f in g.files" :key="f.id" class="detail__file">
|
||||
<FilePreviewImage :file="f" />
|
||||
<div class="detail__file-meta">
|
||||
<div v-if="f.source !== 1" class="detail__file-src">
|
||||
{{ FILE_SOURCE[f.source ?? 0] || '其他来源' }}
|
||||
</div>
|
||||
<div class="detail__muted">
|
||||
{{ f.finalSizeKb ? `${f.finalSizeKb} KB` : '—' }}
|
||||
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
|
||||
<template v-if="f.dpi"> · {{ f.dpi }}dpi</template>
|
||||
</div>
|
||||
<a-tag v-if="f.validateStatus !== null"
|
||||
:color="FILE_VALIDATE[f.validateStatus]?.color ?? 'gray'" size="small">
|
||||
{{ FILE_VALIDATE[f.validateStatus]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<!-- 识别结果 M4 未做时为 null:不显示"识别中",那是在说谎 -->
|
||||
<div v-if="f.detect" class="detail__muted">
|
||||
识别:{{ f.detect.sourceText || f.detect.source || '—' }}
|
||||
<template v-if="f.detect.confidence !== null && f.detect.confidence !== undefined">
|
||||
(置信度 {{ f.detect.confidence }})
|
||||
</template>
|
||||
<a-tag v-if="f.detect.conflict" color="red" size="small">疑似串档</a-tag>
|
||||
</div>
|
||||
<div v-for="(i, idx) in f.issues ?? []" :key="idx" class="detail__issue-line"
|
||||
:class="`detail__issue-line--${i.severity}`">
|
||||
{{ i.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail__muted detail__empty-file">没有文件</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 问题清单 ─────────────────────────── -->
|
||||
<div class="detail__section">
|
||||
校验问题({{ issues.length }})
|
||||
<span class="detail__muted">现场重算,不是提交时的快照</span>
|
||||
</div>
|
||||
<a-empty v-if="!issues.length" description="没有校验问题" />
|
||||
<a-table v-else :data="issues" :pagination="false" size="small" row-key="ruleCode"
|
||||
:bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="级别" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="ISSUE_SEVERITY[record.severity]?.color ?? 'gray'" size="small">
|
||||
{{ ISSUE_SEVERITY[record.severity]?.text ?? '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="规则" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__code">{{ record.ruleCode }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="问题">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.title }}</div>
|
||||
<div class="detail__muted">{{ record.detail }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- ── 学生改过什么 ─────────────────────── -->
|
||||
<template v-if="changedDetail.length">
|
||||
<div class="detail__section">
|
||||
学生改动({{ changedDetail.length }} 项)
|
||||
<span class="detail__muted">预填来自名册,下面是"老师填的 vs 学生交的"</span>
|
||||
</div>
|
||||
<a-table :data="changedDetail" :pagination="false" size="small" row-key="field"
|
||||
:bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="字段" :width="160">
|
||||
<template #cell="{ record }">{{ record.label || record.field }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="名册(老师填的)">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__muted">{{ display(record.rosterValue) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="学生提交的">
|
||||
<template #cell="{ record }">
|
||||
<span class="detail__changed">{{ display(record.studentValue) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<!-- ── 填报表单 ─────────────────────────── -->
|
||||
<div class="detail__section">填报内容</div>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="row in formRows" :key="row.key" :label="row.label">
|
||||
<span :class="{ detail__changed: row.changed }">{{ display(row.value) }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- ── 时间轴 ───────────────────────────── -->
|
||||
<div class="detail__section">审核时间轴(最近的在上)</div>
|
||||
<a-empty v-if="!timeline.length" description="暂无记录" />
|
||||
<div v-for="(t, idx) in timeline" :key="`${t.id}-${idx}`" class="detail__tl">
|
||||
<a-tag :color="TIMELINE_NODE[t.node]?.color ?? 'gray'" size="small">
|
||||
{{ t.nodeText || TIMELINE_NODE[t.node]?.text || '—' }}
|
||||
</a-tag>
|
||||
<span class="detail__tl-text">
|
||||
{{ t.actionText || '—' }}
|
||||
<template v-if="t.auditorName"> · {{ t.auditorName }}</template>
|
||||
<template v-if="t.reasonText"> · {{ t.reasonText }}</template>
|
||||
</span>
|
||||
<span class="detail__muted">{{ fmtTime(t.time) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<AuditRejectModal ref="rejectRef" :materials="materialGroups" @rejected="afterAction" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditDetail, AuditIssue } from '@/apis/xt/audit'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconCheck, IconClose } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
approveSubmission,
|
||||
AUDIT_ERROR_HINT,
|
||||
bizCodeOf,
|
||||
FILE_SOURCE,
|
||||
FILE_VALIDATE,
|
||||
getAuditDetail,
|
||||
ISSUE_SEVERITY,
|
||||
revokeSubmission,
|
||||
TIMELINE_NODE
|
||||
} from '@/apis/xt/audit'
|
||||
import { getFormSchema } from '@/apis/xt/form-schema'
|
||||
import { useUserStore } from '@/stores'
|
||||
import AuditRejectModal from './AuditRejectModal.vue'
|
||||
import FilePreviewImage from './FilePreviewImage.vue'
|
||||
|
||||
/**
|
||||
* 审核详情(原型 ③ / ⑥,契约 §11.2)。
|
||||
*
|
||||
* ## 四个"必须这么显示"的地方
|
||||
*
|
||||
* 1. **红问题要挡在按钮上**。不能通过的真正原因(红问题)写在按钮旁边的提示里,
|
||||
* 而不是等用户点了再弹一个 40032 —— 那会让人以为是系统故障。
|
||||
* 判定仍以**服务端** `approvePolicy.canApprove` 为准,前端不自己算。
|
||||
*
|
||||
* 2. **问题清单是现场重算的**。所以标题里写明"不是提交时的快照",
|
||||
* 否则审核员会拿它去和手机上的红黄计数对,然后怀疑哪边坏了。
|
||||
*
|
||||
* 3. **识别结果可能为 null**(M4 未做)。这时**不显示"识别中"**,
|
||||
* 那是在说谎;直接不显示这一行。
|
||||
*
|
||||
* 4. **撤回是"再写一条记录"**,不是删掉上一条 —— 所以二次确认里要说清,
|
||||
* 免得有人以为点错了会把历史抹掉。
|
||||
*/
|
||||
defineOptions({ name: 'AuditDetailDrawer' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** `1` 初审节点(班主任视角)/ `2` 终审节点(管理员视角) */
|
||||
node?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const acting = ref(false)
|
||||
const detail = ref<AuditDetail | null>(null)
|
||||
const submissionId = ref<number>(0)
|
||||
const labelMap = ref<Record<string, string>>({})
|
||||
const rejectRef = useTemplateRef('rejectRef')
|
||||
|
||||
const node = computed(() => props.node ?? 1)
|
||||
const nodeText = computed(() => (node.value === 2 ? '终审' : '初审'))
|
||||
|
||||
const materialGroups = computed(() => detail.value?.materials ?? [])
|
||||
const issues = computed<AuditIssue[]>(() => detail.value?.issues ?? [])
|
||||
const changedDetail = computed(() => detail.value?.changedDetail ?? [])
|
||||
const timeline = computed(() => detail.value?.timeline ?? [])
|
||||
|
||||
const doneCount = computed(() => materialGroups.value.filter((g) => g.done).length)
|
||||
|
||||
const redTitles = computed(() => {
|
||||
const fromPolicy = detail.value?.approvePolicy?.redTitles
|
||||
if (fromPolicy?.length) {
|
||||
return fromPolicy
|
||||
}
|
||||
return issues.value.filter((i) => i.severity === 1).map((i) => i.title)
|
||||
})
|
||||
|
||||
/** 判定一律以服务端 policy 为准(前端只负责把原因显示出来) */
|
||||
const canApprove = computed(() => detail.value?.approvePolicy?.canApprove === true)
|
||||
const canReject = computed(() => {
|
||||
const s = detail.value?.status
|
||||
return s === 2 || s === 3
|
||||
})
|
||||
const canRevoke = computed(
|
||||
() => detail.value?.revokePolicy?.canRevoke === true
|
||||
&& userStore.permissions.includes('audit:withdraw')
|
||||
)
|
||||
|
||||
const lastReject = computed(() => {
|
||||
const d = detail.value
|
||||
if (!d?.lastRejectCode) {
|
||||
return null
|
||||
}
|
||||
return { reasonCode: d.lastRejectCode, reasonText: d.lastRejectText }
|
||||
})
|
||||
|
||||
const submitTag = computed(() => {
|
||||
const s = detail.value?.status
|
||||
const map: Record<number, { text: string, color: string }> = {
|
||||
1: { text: '草稿', color: 'gray' },
|
||||
2: { text: '待初审', color: 'orange' },
|
||||
3: { text: '待终审', color: 'arcoblue' },
|
||||
4: { text: '已驳回', color: 'red' },
|
||||
5: { text: '已通过', color: 'green' }
|
||||
}
|
||||
return map[s ?? 0] ?? { text: '未知', color: 'gray' }
|
||||
})
|
||||
|
||||
/** 表单值 → 可读文本 */
|
||||
function display(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') {
|
||||
return '—'
|
||||
}
|
||||
if (typeof v === 'boolean') {
|
||||
return v ? '是' : '否'
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.length ? v.join('、') : '—'
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
return Object.entries(v as Record<string, unknown>)
|
||||
.map(([k, val]) => `${k}: ${String(val)}`)
|
||||
.join(';')
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** 表单行:内置字段优先按 schema 顺序,schema 里没有的(历史扩展字段)排在后面 */
|
||||
const formRows = computed(() => {
|
||||
const data = detail.value?.formData ?? {}
|
||||
const changed = new Set(detail.value?.changedFields ?? [])
|
||||
const rows: Array<{ key: string, label: string, value: unknown, changed: boolean }> = []
|
||||
const seen = new Set<string>()
|
||||
for (const [key, label] of Object.entries(labelMap.value)) {
|
||||
if (key in data) {
|
||||
rows.push({ key, label, value: data[key], changed: changed.has(key) })
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (!seen.has(key)) {
|
||||
rows.push({ key, label: key, value, changed: changed.has(key) })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditDetail(submissionId.value)
|
||||
detail.value = res.data ?? null
|
||||
const taskId = detail.value?.taskId
|
||||
if (taskId && !Object.keys(labelMap.value).length) {
|
||||
// 字段中文名从**当前**表单定义取。历史提交若用了已删除的扩展字段,就退回显示 key ——
|
||||
// 显示 key 虽然不漂亮,但不会把两个字段的标签张冠李戴。
|
||||
const fs = await getFormSchema(taskId)
|
||||
const map: Record<string, string> = {}
|
||||
for (const f of [...(fs.data?.fields ?? []), ...(fs.data?.extras ?? [])]) {
|
||||
map[f.key] = f.label || f.key
|
||||
}
|
||||
labelMap.value = map
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
Message.success('已重新校验')
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
submissionId.value = id
|
||||
visible.value = true
|
||||
await load()
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 通过/驳回/撤回之后的统一收尾 */
|
||||
function afterAction() {
|
||||
emit('changed')
|
||||
close()
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
acting.value = true
|
||||
try {
|
||||
const res = await approveSubmission(submissionId.value, {
|
||||
actionSource: 'BUTTON',
|
||||
finalOpinion: node.value === 2 ? '终审通过' : undefined
|
||||
})
|
||||
const r = res.data
|
||||
if (r?.replayed) {
|
||||
Message.info('这一单刚才已经审过了(重复请求已幂等处理)')
|
||||
} else {
|
||||
Message.success(`${nodeText.value}通过`)
|
||||
}
|
||||
afterAction()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
if (code === 40032) {
|
||||
// 红问题:把服务端的话原样展示 + 提示,并刷新详情(现场重算,计数可能变了)
|
||||
Modal.warning({
|
||||
title: '不能通过',
|
||||
content: `${(e as Error).message}\n\n${hint}\n\n点「确定」后本页会重新校验一次。`,
|
||||
hideCancel: true,
|
||||
onOk: () => load()
|
||||
})
|
||||
} else if (hint) {
|
||||
Message.error(`${(e as Error).message}(${hint})`)
|
||||
}
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onReject() {
|
||||
rejectRef.value?.open(submissionId.value)
|
||||
}
|
||||
|
||||
async function onRevoke() {
|
||||
try {
|
||||
const res = await revokeSubmission(submissionId.value, {
|
||||
auditRecordId: detail.value?.revokePolicy?.auditRecordId ?? undefined,
|
||||
reason: 'PC 端撤回'
|
||||
})
|
||||
Message.success(res.data?.statusText || '已撤回')
|
||||
afterAction()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail {
|
||||
&__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
|
||||
.detail__muted {
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
&__group {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&__file {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__file-src {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__empty-file {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__issue-line {
|
||||
font-size: 12px;
|
||||
|
||||
// ⚠️ 1 提示(蓝)/ 2 警告(黄)/ 3 阻断(红)—— 与服务端 Issue 常量同口径。
|
||||
// 写反的后果是把"提示"染成红色,所有人都会去追一个不存在的问题。
|
||||
&--3 {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
|
||||
&--2 {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
&--1 {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__changed {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
&__tl {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--color-border-1);
|
||||
}
|
||||
|
||||
&__tl-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" title="驳回填报单" :width="560" :mask-closable="false"
|
||||
:ok-loading="saving" ok-text="确认驳回" @before-ok="onOk" @close="close">
|
||||
<a-alert type="warning" :show-icon="false" class="reject__tip">
|
||||
<template #title>
|
||||
驳回后学生会看到原因,<strong>可以改完重交</strong>。
|
||||
一期还没有通知渠道,所以还需要<strong>口头告知学生</strong>。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="驳回原因" required>
|
||||
<a-radio-group v-model="form.reasonCode" direction="vertical" @change="onCodeChange">
|
||||
<a-radio v-for="r in reasonCodes" :key="r.code" :value="r.code" class="reject__radio">
|
||||
{{ r.text }}
|
||||
<span v-if="r.requiresText" class="reject__req">(必须补充说明)</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<!--
|
||||
R999「其他原因」必须填说明:不填的话学生只看到"其他原因"四个字,
|
||||
等于什么也没告诉他就让他重做一份。
|
||||
-->
|
||||
<a-form-item :label="requiresText ? '补充说明(必填)' : '补充说明(可选)'"
|
||||
:validate-status="textError ? 'error' : undefined" :help="textError">
|
||||
<a-textarea v-model="form.reasonText" :rows="3" :max-length="200" show-word-limit
|
||||
placeholder="例如:身份证反面缺角看不清,请重新拍一张完整的" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="materials.length" label="是哪几项材料的问题(可选)">
|
||||
<a-checkbox-group v-model="form.materialCodes">
|
||||
<a-checkbox v-for="m in materials" :key="m.materialCode" :value="m.materialCode">
|
||||
{{ m.materialName }}
|
||||
</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否需要学生重新上传">
|
||||
<a-switch v-model="form.suggestReupload" />
|
||||
<span class="reject__hint">
|
||||
关掉 = 只是信息填错,改文字即可;开着 = 材料本身有问题,必须重传。
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditMaterialGroup, RejectReasonCode } from '@/apis/xt/audit'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { getReasonCodes, rejectSubmission } from '@/apis/xt/audit'
|
||||
|
||||
/**
|
||||
* 驳回弹窗(原型 STEP ⑤)。
|
||||
*
|
||||
* 三条设计理由:
|
||||
* 1. **必须点按钮**(原型铁律:班主任端通过可以右滑,驳回不行)——
|
||||
* 误触通过的后果是"再撤一次",误触驳回的后果是学生白改一份材料,两者不对称。
|
||||
* 2. **原因快选**:附录 C 的 10 条写在代码里(`RejectReasonCatalog`),
|
||||
* 字典从服务端取,不在前端硬编 —— 否则加一条原因要发一次前端。
|
||||
* 3. **R999 必须填说明**:只有"其他原因"四个字,学生无从下手。
|
||||
*/
|
||||
defineOptions({ name: 'AuditRejectModal' })
|
||||
|
||||
const props = defineProps<{
|
||||
materials: AuditMaterialGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rejected'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const submissionId = ref<number>(0)
|
||||
const reasonCodes = ref<RejectReasonCode[]>([])
|
||||
|
||||
const form = reactive({
|
||||
reasonCode: '',
|
||||
reasonText: '',
|
||||
materialCodes: [] as string[],
|
||||
suggestReupload: true
|
||||
})
|
||||
|
||||
const textError = ref('')
|
||||
|
||||
const requiresText = computed(
|
||||
() => reasonCodes.value.find((r) => r.code === form.reasonCode)?.requiresText === true
|
||||
)
|
||||
|
||||
const materials = computed(() => props.materials ?? [])
|
||||
|
||||
/** 换个原因就把上一次的报错清掉,否则红字会一直挂着 */
|
||||
function onCodeChange() {
|
||||
textError.value = ''
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
submissionId.value = id
|
||||
form.reasonCode = ''
|
||||
form.reasonText = ''
|
||||
form.materialCodes = []
|
||||
form.suggestReupload = true
|
||||
textError.value = ''
|
||||
visible.value = true
|
||||
if (!reasonCodes.value.length) {
|
||||
try {
|
||||
const res = await getReasonCodes()
|
||||
reasonCodes.value = res.data ?? []
|
||||
} catch {
|
||||
// 字典取不到时不静默:让用户知道"现在没法选原因",而不是看到一个空列表乱猜
|
||||
Message.warning('驳回原因字典没取到,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (!form.reasonCode) {
|
||||
Message.warning('请选择驳回原因')
|
||||
return false
|
||||
}
|
||||
if (requiresText.value && !form.reasonText.trim()) {
|
||||
textError.value = '选择「其他原因」时必须写清是什么问题'
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await rejectSubmission(submissionId.value, {
|
||||
reasonCode: form.reasonCode,
|
||||
reasonText: form.reasonText.trim() || undefined,
|
||||
materialCodes: form.materialCodes.length ? form.materialCodes : undefined,
|
||||
suggestReupload: form.suggestReupload
|
||||
})
|
||||
const r = res.data
|
||||
if (r?.needManualNotice) {
|
||||
Message.warning({
|
||||
content: '已驳回。通知没发出去(一期没有通知渠道),请口头告知学生',
|
||||
duration: 5000
|
||||
})
|
||||
} else {
|
||||
Message.success(r?.statusText || '已驳回')
|
||||
}
|
||||
if (r?.escalated) {
|
||||
Message.info('该单驳回次数已达上限,管理员会收到提醒')
|
||||
}
|
||||
visible.value = false
|
||||
emit('rejected')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reject {
|
||||
&__tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__radio {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__req {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="fp">
|
||||
<!--
|
||||
七牛空间是**私有**的 → 预览必须用签名链接,而签名链接有有效期(默认 30 分钟),
|
||||
所以这里**每次挂载现取**,不缓存、不入库、不落 localStorage。
|
||||
-->
|
||||
<div class="fp__box" :class="{ 'fp__box--empty': !src && !loading }" @click="open">
|
||||
<a-spin v-if="loading" :size="16" />
|
||||
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
|
||||
<div v-else class="fp__fail">预览取不到<br>(链接过期或已删除)</div>
|
||||
</div>
|
||||
<div class="fp__name" :title="file.standardName || file.originalName || ''">
|
||||
{{ file.standardName || file.originalName || '(无文件名)' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditFileItem } from '@/apis/xt/audit'
|
||||
import { getAuditFileUrl } from '@/apis/xt/audit'
|
||||
|
||||
/**
|
||||
* 材料缩略图(审核端)。
|
||||
*
|
||||
* 为什么单独封一个组件:审核页与待办卡片都要显示缩略图,而"取签名链接"这件事
|
||||
* **不能各写一份** —— 一处忘了换审核端通道(`/audit/files/*`)就会去调学生端的接口,
|
||||
* 那条通道校验的是"这份是不是你传的",审核人必然拿不到。
|
||||
*/
|
||||
defineOptions({ name: 'FilePreviewImage' })
|
||||
|
||||
const props = defineProps<{
|
||||
file: AuditFileItem
|
||||
/** 用一个已经拿到的链接(待办卡片里的 materialThumbs 就是现成的签名链接) */
|
||||
url?: string
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const src = ref<string>(props.url ?? '')
|
||||
|
||||
async function load() {
|
||||
if (props.url) {
|
||||
src.value = props.url
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditFileUrl(props.file.id)
|
||||
src.value = res.data?.url ?? ''
|
||||
} catch {
|
||||
// 取不到链接不是致命错误:材料本身还在,只是这次看不了。
|
||||
// 所以只留一个"预览取不到"的占位,不弹错误 —— 审核几十份材料时弹窗会淹没页面。
|
||||
src.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点开看原图(新标签页)。链接是现场的,所以点了才取。 */
|
||||
async function open() {
|
||||
if (!src.value) {
|
||||
return
|
||||
}
|
||||
window.open(src.value, '_blank')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fp {
|
||||
width: 108px;
|
||||
|
||||
&__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
|
||||
&--empty {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__fail {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-spin) {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>审核</template>
|
||||
<template #extra>
|
||||
<span class="audit__hint">
|
||||
初审由班主任做(只看得到自己带的班),终审由管理员做
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
节点切换用 tab:初审与终审是**同一份数据的两个阶段**,
|
||||
而不是两个功能。做成两个页面的话,用户会以为自己进错了地方。
|
||||
-->
|
||||
<a-tabs v-model:active-key="node" type="rounded" size="small" @change="onNodeChange">
|
||||
<a-tab-pane :key="1" title="初审(班主任 / 审核员)" />
|
||||
<a-tab-pane v-if="canFinal" :key="2" title="终审(管理员)" />
|
||||
</a-tabs>
|
||||
|
||||
<!--
|
||||
四个计数**不受下方的 level 过滤影响**(服务端算的),
|
||||
否则点一下「有异常」就再也回不到「全部」。
|
||||
-->
|
||||
<div class="audit__stats">
|
||||
<div v-for="c in countCards" :key="c.key" class="audit__stat"
|
||||
:class="[`audit__stat--${c.tone}`, levelFilter === c.level && c.level !== undefined ? 'audit__stat--on' : '']"
|
||||
@click="setLevel(c.level)">
|
||||
<div class="audit__stat-num">{{ c.value }}</div>
|
||||
<div class="audit__stat-label">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-button type="primary" size="small" :disabled="!selectedKeys.length" :loading="batching"
|
||||
@click="onBatchApprove">
|
||||
<template #icon><IconCheck /></template>
|
||||
批量通过({{ selectedKeys.length }})
|
||||
</a-button>
|
||||
<span class="audit__hint">
|
||||
只能带<strong>无问题</strong>的单;红黄一律自动跳过并说明原因
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="audit__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.waitingOverHours" placeholder="等待时长" allow-clear size="small"
|
||||
class="audit__wait" @change="search">
|
||||
<a-option :value="24">超过 24 小时</a-option>
|
||||
<a-option :value="48">超过 48 小时</a-option>
|
||||
<a-option :value="72">超过 72 小时</a-option>
|
||||
</a-select>
|
||||
<a-input-search v-model="query.keyword" placeholder="搜索姓名 / 班级" allow-clear
|
||||
class="audit__search" @search="search" @clear="search" />
|
||||
<a-button size="small" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
列宽按可用宽度算过:1366 宽的屏幕减 200 侧栏 ≈ 1140,本页在卡片里再减内边距,
|
||||
约 1060 可用。下面 8 列合计 1020,不出现横向滚动(钉住的操作列不会盖住「状态」)。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="submissionId" :pagination="pagination"
|
||||
:scroll="{ x: 1020 }" :row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||
:selected-keys="selectedKeys" @select="onSelect" @select-all="onSelectAll"
|
||||
@page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="学生" :width="170">
|
||||
<template #cell="{ record }">
|
||||
<div class="audit__name">
|
||||
{{ record.realName }}
|
||||
<a-tag v-if="record.rejectCount" color="gray" size="small">
|
||||
驳回 {{ record.rejectCount }} 次
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="audit__muted">{{ record.orgName || '未分班' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.taskName }}</div>
|
||||
<div class="audit__muted">{{ fmtTime(record.submitTime) }} 提交</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="材料" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<div class="audit__thumbs">
|
||||
<img v-for="(u, i) in (record.materialThumbs ?? []).slice(0, 3)" :key="i" :src="u"
|
||||
class="audit__thumb" alt="材料缩略图" @click.stop="openDetail(record)">
|
||||
<span v-if="!(record.materialThumbs ?? []).length" class="audit__muted">
|
||||
{{ record.materialCount }} 项
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="校验" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="LEVEL_TAG[record.level]?.color ?? 'gray'" size="small">
|
||||
{{ LEVEL_TAG[record.level]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<div v-if="record.redSummary" class="audit__red" :title="record.redSummary">
|
||||
{{ record.redSummary }}
|
||||
</div>
|
||||
<div v-else-if="record.redCount || record.yellowCount" class="audit__muted">
|
||||
红 {{ record.redCount }} · 黄 {{ record.yellowCount }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="学生改过" :width="120" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.changedFields?.length" class="audit__changed">
|
||||
{{ record.changedFields.length }} 项
|
||||
</span>
|
||||
<span v-else class="audit__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small">{{ record.statusText }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="已等待" :width="90" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span :class="waitTone(record.waitHours)">{{ fmtWait(record.waitHours) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space size="mini" wrap>
|
||||
<a-link @click="openDetail(record)">审核</a-link>
|
||||
<!--
|
||||
能批量通过的才给「通过」快捷键(服务端算好的 batchApprovable):
|
||||
有红黄的单必须进去看,给了快捷键就等于鼓励手快全选。
|
||||
-->
|
||||
<a-link v-if="record.batchApprovable" status="success" @click="quickApprove(record)">
|
||||
通过
|
||||
</a-link>
|
||||
<a-link status="danger" @click="openReject(record)">驳回</a-link>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<AuditDetailDrawer ref="detailRef" :node="node" @changed="load" />
|
||||
<AuditRejectModal ref="rejectRef" :materials="[]" @rejected="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditTodo } from '@/apis/xt/audit'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
approveSubmission,
|
||||
AUDIT_ERROR_HINT,
|
||||
batchApprove,
|
||||
bizCodeOf,
|
||||
getAuditTodos,
|
||||
LEVEL_TAG
|
||||
} from '@/apis/xt/audit'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { useUserStore } from '@/stores'
|
||||
import AuditDetailDrawer from './AuditDetailDrawer.vue'
|
||||
import AuditRejectModal from './AuditRejectModal.vue'
|
||||
|
||||
/**
|
||||
* 审核台(原型 ② / ⑥,契约 §11.1)。
|
||||
*
|
||||
* ## 这一屏最容易做错的三件事
|
||||
*
|
||||
* 1. **不要在前端按班级过滤**。"权限 ≠ 范围"由服务端保证(SQL 里显式 `1 = 0`),
|
||||
* 前端再过滤一次只会在口径不一致时制造假象 —— 而且前端过滤不了越权。
|
||||
* 2. **四个计数不能跟着 level 过滤变**。否则点一下「有异常」,其它计数变成 0,
|
||||
* 用户就回不到「全部」了。服务端已经这么算,前端照显示即可。
|
||||
* 3. **批量通过必须逐条说清谁被跳过**。只说"过了 3 个"会让用户反复点那个大按钮。
|
||||
*/
|
||||
defineOptions({ name: 'Audit' })
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const canFinal = computed(() => userStore.permissions.includes('audit:final'))
|
||||
|
||||
const node = ref<number>(1)
|
||||
const loading = ref(false)
|
||||
const batching = ref(false)
|
||||
const list = ref<AuditTodo[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref({ all: 0, clean: 0, abnormal: 0, overTime: 0 })
|
||||
const selectedKeys = ref<number[]>([])
|
||||
const levelFilter = ref<number | undefined>(undefined)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
const taskLoading = ref(false)
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
taskId: undefined as number | undefined,
|
||||
waitingOverHours: undefined as number | undefined
|
||||
})
|
||||
|
||||
const detailRef = useTemplateRef('detailRef')
|
||||
const rejectRef = useTemplateRef('rejectRef')
|
||||
|
||||
const countCards = computed(() => [
|
||||
{ key: 'all', label: '全部待办', value: counts.value.all, tone: 'blue', level: undefined as number | undefined },
|
||||
{ key: 'clean', label: '无问题', value: counts.value.clean, tone: 'green', level: 0 },
|
||||
{ key: 'abnormal', label: '有红/黄', value: counts.value.abnormal, tone: 'orange', level: 3 },
|
||||
{ key: 'overTime', label: '已超时', value: counts.value.overTime, tone: 'red', level: undefined }
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmtTime(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function fmtWait(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (h < 24) {
|
||||
return `${h} 小时`
|
||||
}
|
||||
return `${Math.floor(h / 24)} 天`
|
||||
}
|
||||
|
||||
/** 等得越久越显眼:超过 48 小时变红 —— 超时是这一屏唯一要主动推的事 */
|
||||
function waitTone(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
return 'audit__muted'
|
||||
}
|
||||
if (h >= 48) {
|
||||
return 'audit__wait--bad'
|
||||
}
|
||||
if (h >= 24) {
|
||||
return 'audit__wait--warn'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAuditTodos({
|
||||
node: node.value,
|
||||
taskId: query.taskId,
|
||||
level: levelFilter.value,
|
||||
waitingOverHours: query.waitingOverHours,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
counts.value = res.data?.counts ?? { all: 0, clean: 0, abnormal: 0, overTime: 0 }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
selectedKeys.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
function onNodeChange() {
|
||||
levelFilter.value = undefined
|
||||
selectedKeys.value = []
|
||||
search()
|
||||
}
|
||||
|
||||
/** 点计数卡片 = 按分级过滤;再点一次取消 */
|
||||
function setLevel(v: number | undefined) {
|
||||
if (v === undefined) {
|
||||
levelFilter.value = undefined
|
||||
} else {
|
||||
levelFilter.value = levelFilter.value === v ? undefined : v
|
||||
}
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(keys: (string | number)[]) {
|
||||
selectedKeys.value = keys as number[]
|
||||
}
|
||||
|
||||
function onSelectAll(checked: boolean) {
|
||||
selectedKeys.value = checked ? list.value.map((r) => r.submissionId) : []
|
||||
}
|
||||
|
||||
function openDetail(row: AuditTodo) {
|
||||
detailRef.value?.open(row.submissionId)
|
||||
}
|
||||
|
||||
function openReject(row: AuditTodo) {
|
||||
rejectRef.value?.open(row.submissionId)
|
||||
}
|
||||
|
||||
/** 列表里的快捷通过:进详情再点的动作这里省掉,但只对"无问题"的单开放 */
|
||||
async function quickApprove(row: AuditTodo) {
|
||||
try {
|
||||
const res = await approveSubmission(row.submissionId, { actionSource: 'BUTTON' })
|
||||
Message.success(res.data?.replayed ? '这一单刚才已经审过了' : '已通过')
|
||||
search()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchApprove() {
|
||||
const ids = selectedKeys.value
|
||||
if (!ids.length) {
|
||||
Message.warning('请先勾选要通过的单子')
|
||||
return
|
||||
}
|
||||
batching.value = true
|
||||
try {
|
||||
const res = await batchApprove(ids)
|
||||
const r = res.data
|
||||
const skipped = r?.details?.filter((d) => d.skipped) ?? []
|
||||
if (!skipped.length) {
|
||||
Message.success(`已通过 ${r?.approved ?? 0} 份`)
|
||||
} else {
|
||||
// 逐条列出被跳过的原因 —— 这是"用户下次能不能自己判断"的关键。
|
||||
// ⚠️ 这里用 <pre> 纯文本而不是富文本:本页是 `lang="ts"` 的 SFC,
|
||||
// 在里面写 JSX 会直接编译失败(历史上踩过),而为了一个弹窗改成 tsx 不值得。
|
||||
const lines = skipped.map((d) => `· ${d.realName || `#${d.submissionId}`}:${d.reason || '不符合批量通过条件'}`)
|
||||
Modal.info({
|
||||
title: `通过 ${r?.approved ?? 0} 份,跳过 ${skipped.length} 份`,
|
||||
content: `下面这些没有被通过:\n\n${lines.join('\n')}`,
|
||||
hideCancel: true
|
||||
})
|
||||
}
|
||||
selectedKeys.value = []
|
||||
await load()
|
||||
} catch (e) {
|
||||
const code = bizCodeOf(e)
|
||||
const hint = code ? AUDIT_ERROR_HINT[code] : ''
|
||||
Message.error(hint ? `${(e as Error).message}(${hint})` : (e as Error).message)
|
||||
} finally {
|
||||
batching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
load()
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.audit {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
&--on {
|
||||
border-color: rgb(var(--primary-6));
|
||||
box-shadow: 0 0 0 2px rgb(var(--primary-1));
|
||||
}
|
||||
|
||||
&--blue .audit__stat-num {
|
||||
color: rgb(var(--blue-6));
|
||||
}
|
||||
|
||||
&--green .audit__stat-num {
|
||||
color: rgb(var(--green-6));
|
||||
}
|
||||
|
||||
&--orange .audit__stat-num {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
&--red .audit__stat-num {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__task {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__wait {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
&__search {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__thumbs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: zoom-in;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__red {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--red-6));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__changed {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
&__wait {
|
||||
&--warn {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
&--bad {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user