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
@@ -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>