## 一、设计系统收敛 - theme/index.css 重写为 token 底座:品牌三级、语义五组、文字四级、 边框/背景/圆角/阴影/动效/热区/排版刻度 - 修复 Element Plus 派生色阶残留:--el-color-*-light-N 是 SCSS 编译期静态生成, 只覆盖基色不会带动它们 —— 数据统计页实测 133 处元素仍在用 EP 默认调色板 (其中主色蓝 8 处)。已按 mix(#fff,base,N*10%) / mix(#000,base,20%) 补全全部档位 - 新增 src/theme/tokens.ts:模板里 :style 需拼 8 位 hex(color + '15'),用不了 var(), 故以 TS 常量镜像色值;守卫会双向校验它与 index.css 同名取值一致 - 24 个 .vue 收敛:硬编码色值 97 处/67 种 → 0;越界字号 88 处(最低 9px)→ 0; 非法字重 650×6 → 0;触摸目标 17 处 <44px → 全部 ≥44px - 对比度:text-tertiary 3.54:1、warning 2.33:1、coral 作文字 3.14:1 → 全部 ≥4.5:1 - 新增守卫 ui-tests/admin-design-tokens.mjs(含 WCAG 对比度实算) ## 二、修复既有功能性缺陷:数据统计页漏斗卡与安全卡恒显示 0 - 现象:同一张卡片里,上方漏斗数字全是 0,下方汇总表却是 12846/9217/8462/3812, 界面自相矛盾 - 根因:funnelStages / matchStages / safetyCards 是普通数组(const x = [...]), loadFunnel() 直接改元素属性,模板读的却是同一个数组 —— 非响应式写入不触发重渲染。 同页凡走 ref 的值(汇总表、V11 区块)全部正常,规律清晰 - 修法(按根因):三个集合改为 computed() 从既有 ref 派生,卡片与汇总表因此同源, 结构上不可能再出现「同一份数据的两处展示对不上」;另把 loadFunnel() 内遮蔽 颜色助手的局部变量 const token 改名 authToken - 该缺陷无 TS/lint/构建报错,前 18 个守卫全绿也抓不到 —— 只有真跑起来看渲染结果才暴露 - 新增守卫 ui-tests/admin-reactivity-contracts.mjs: ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里;未被写过的静态常量清单合法) ②数据统计页同源契约 ③颜色助手遮蔽 ## 验证 - UI 契约门禁 20/20 PASS(MIN_GUARDS 18→20) - 负向注入 3 例全部 FAIL(exit 1),还原后复跑 exit 0(证明守卫有判别力) - vue-tsc --noEmit exit 0 - vite build 成功(17.74s) - 渲染态实测(Edge 无头 + CDP 读渲染后 DOM,同一 mock、同一路由,只差代码): 修复前漏斗卡/安全卡全 0、比率全 '-' → 修复后 12846/9217/8462/3812/ 48210/51236/7420/4380/1926/1284,安全卡 17/214/23/9 交付物:artifacts/admin-design-v2-preview.html
289 lines
14 KiB
Vue
289 lines
14 KiB
Vue
<template>
|
||
<div class="page-container page-shell review-page">
|
||
<header class="page-heading">
|
||
<div>
|
||
<span class="page-kicker">用户与资料审核</span>
|
||
<h1>资料审核</h1>
|
||
<p>按提交顺序核验必要资料,审核操作保留清晰意见和审计记录。</p>
|
||
</div>
|
||
<div class="stat-tags">
|
||
<el-tag type="warning">待审核 {{ stats.pending }}</el-tag>
|
||
<el-tag type="info">审核中 {{ stats.processing }}</el-tag>
|
||
</div>
|
||
</header>
|
||
|
||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
|
||
<template #default><el-button link type="primary" @click="loadPage">重新加载</el-button></template>
|
||
</el-alert>
|
||
|
||
<section v-else class="surface-card review-panel">
|
||
<el-tabs v-model="activeTab">
|
||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
|
||
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="canClaim(row)" type="primary" link @click="handleClaim(row)">领取审核</el-button>
|
||
<span v-else class="muted">只读</span>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty><el-empty description="暂无待审核任务" /></template>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="status" label="状态" width="110">
|
||
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
|
||
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }"><el-button link type="primary" @click="handleView(row)">查看</el-button></template>
|
||
</el-table-column>
|
||
<template #empty><el-empty description="暂无已领取的审核任务" /></template>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</section>
|
||
|
||
<el-drawer v-model="detailVisible" title="审核详情" size="min(560px, 92vw)">
|
||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||
<template v-else-if="currentTask">
|
||
<el-descriptions :column="1" border>
|
||
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
|
||
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||
<el-descriptions-item label="当前状态">
|
||
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<h3 class="drawer-heading">用户资料</h3>
|
||
<el-descriptions v-if="currentTask.userProfile" :column="2" border>
|
||
<el-descriptions-item label="显示名称">{{ currentTask.userProfile.displayName || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="性别">{{ genderText(currentTask.userProfile.gender) }}</el-descriptions-item>
|
||
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm ? `${currentTask.userProfile.heightCm} cm` : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="区域">{{ currentTask.userProfile.regionName || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="自我介绍" :span="2">{{ currentTask.userProfile.selfIntro || '未填写' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-empty v-else description="暂无资料快照" />
|
||
|
||
<template v-if="currentTask.reviewType === 'local_eligibility'">
|
||
<h3 class="drawer-heading">本地资格佐证</h3>
|
||
<el-alert title="佐证属于敏感数据,仅在审核必要时查看;每次访问都会记录目的和结果。" type="warning" :closable="false" show-icon />
|
||
<el-table :data="currentTask.localEligibilityEvidences || []" class="evidence-table">
|
||
<el-table-column prop="evidenceType" label="佐证类型" min-width="140" />
|
||
<el-table-column prop="createdAt" label="上传时间" min-width="170">
|
||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100">
|
||
<template #default="{ row }">
|
||
<el-button v-if="canViewEvidence" link type="primary" @click="viewEvidence(row)">查看</el-button>
|
||
<span v-else class="muted">无权限</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
|
||
<template v-if="canProcessCurrent">
|
||
<h3 class="drawer-heading">审核操作</h3>
|
||
<el-form label-position="top">
|
||
<el-form-item label="审核意见">
|
||
<el-input v-model.trim="reviewForm.comment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请写明判断依据或需要补充的内容" />
|
||
</el-form-item>
|
||
<el-form-item label="原因标签">
|
||
<el-select v-model="reviewForm.reasonCodes" multiple class="full-width" placeholder="按需选择">
|
||
<el-option v-for="reason in reasonOptions" :key="reason.code" :label="reason.name" :value="reason.code" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="review-actions">
|
||
<el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button>
|
||
<el-button type="warning" :loading="submitting" @click="runDecision('need-edit', currentTask.reviewType === 'local_eligibility' ? '要求补充佐证' : '退回修改')">{{ currentTask.reviewType === 'local_eligibility' ? '补充佐证' : '需修改' }}</el-button>
|
||
<el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
|
||
</div>
|
||
</el-form>
|
||
</template>
|
||
</template>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { get, post } from '@/api/client'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const authStore = useAuthStore()
|
||
const activeTab = ref('pending')
|
||
const loading = ref(false)
|
||
const detailLoading = ref(false)
|
||
const submitting = ref(false)
|
||
const detailVisible = ref(false)
|
||
const errorMessage = ref('')
|
||
const stats = ref({ pending: 0, processing: 0 })
|
||
const tasks = ref<any[]>([])
|
||
const currentTask = ref<any>(null)
|
||
const reasonOptions = ref<any[]>([])
|
||
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
|
||
const canProcess = computed(() => authStore.permissions.includes('review.process'))
|
||
const canViewEvidence = computed(() => authStore.permissions.includes('local_eligibility.evidence.view'))
|
||
const canProcessCurrent = computed(() => currentTask.value?.reviewType === 'local_eligibility'
|
||
? authStore.permissions.includes('local_eligibility.review')
|
||
: canProcess.value)
|
||
|
||
function canClaim(task: any) {
|
||
return task.reviewType === 'local_eligibility'
|
||
? authStore.permissions.includes('local_eligibility.review')
|
||
: canProcess.value
|
||
}
|
||
const pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
|
||
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId))
|
||
|
||
async function loadPage() {
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
try {
|
||
const [statsResponse, tasksResponse, reasonsResponse]: any[] = await Promise.all([
|
||
get('/operator/reviews/stats'),
|
||
get('/operator/reviews/pending', { page: 1, pageSize: 20 }),
|
||
get('/operator/reviews/reasons'),
|
||
])
|
||
stats.value = statsResponse.data || { pending: 0, processing: 0 }
|
||
tasks.value = tasksResponse.data || []
|
||
reasonOptions.value = reasonsResponse.data || []
|
||
} catch (error: any) {
|
||
tasks.value = []
|
||
errorMessage.value = error.response?.status === 403
|
||
? '您没有查看资料审核的权限'
|
||
: '审核任务加载失败,请检查服务状态后重试'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleClaim(row: any) {
|
||
try {
|
||
const response: any = await post(`/operator/reviews/${row.taskId || row.id}/claim`)
|
||
if (!response.success) throw new Error(response.message || '领取失败')
|
||
ElMessage.success('审核任务领取成功')
|
||
await loadPage()
|
||
await openDetail(row.taskId || row.id)
|
||
} catch (error: any) {
|
||
ElMessage.error(error.message || '领取失败,任务可能已被其他审核员领取')
|
||
}
|
||
}
|
||
|
||
async function openDetail(taskId: string) {
|
||
detailVisible.value = true
|
||
detailLoading.value = true
|
||
currentTask.value = null
|
||
reviewForm.value = { comment: '', reasonCodes: [] }
|
||
try {
|
||
const response: any = await get(`/operator/reviews/${taskId}`)
|
||
currentTask.value = response.data
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleView(row: any) {
|
||
await openDetail(row.taskId || row.id)
|
||
}
|
||
|
||
async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: string) {
|
||
if (!currentTask.value || submitting.value) return
|
||
if (action !== 'approve' && !reviewForm.value.comment) {
|
||
ElMessage.warning('请填写审核意见,说明需要修改或拒绝的原因')
|
||
return
|
||
}
|
||
if (action === 'reject') {
|
||
await ElMessageBox.confirm('拒绝后用户需要重新处理资料,确认继续吗?', '确认拒绝资料', {
|
||
type: 'warning',
|
||
confirmButtonText: '确认拒绝',
|
||
cancelButtonText: '取消',
|
||
})
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const localActions = {
|
||
approve: 'approve-local-eligibility',
|
||
'need-edit': 'need-evidence',
|
||
reject: 'reject-local-eligibility',
|
||
}
|
||
const endpointAction = currentTask.value.reviewType === 'local_eligibility' ? localActions[action] : action
|
||
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${endpointAction}`, {
|
||
comment: reviewForm.value.comment,
|
||
reasonCodes: reviewForm.value.reasonCodes,
|
||
})
|
||
if (!response.success) throw new Error(response.message || `${label}失败`)
|
||
ElMessage.success(label)
|
||
detailVisible.value = false
|
||
await loadPage()
|
||
} catch (error: any) {
|
||
ElMessage.error(error.message || `${label}失败,请重试`)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function viewEvidence(row: any) {
|
||
if (!currentTask.value) return
|
||
try {
|
||
const { value: purpose } = await ElMessageBox.prompt('请填写本次查看佐证的业务目的', '敏感数据访问确认', {
|
||
confirmButtonText: '记录并查看',
|
||
cancelButtonText: '取消',
|
||
inputPattern: /\S+/,
|
||
inputErrorMessage: '访问目的不能为空',
|
||
})
|
||
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/evidences/${row.evidenceId}/access`, { purpose })
|
||
window.open(response.data.url, '_blank', 'noopener,noreferrer')
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '佐证访问失败')
|
||
}
|
||
}
|
||
|
||
function reviewTypeText(value?: string) {
|
||
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
|
||
}
|
||
|
||
function genderText(value?: string) {
|
||
return value === 'male' ? '男' : value === 'female' ? '女' : '-'
|
||
}
|
||
|
||
function statusType(status: string) {
|
||
return ({ pending: 'warning', processing: 'info', approved: 'success', need_edit: 'warning', rejected: 'danger' } as Record<string, string>)[status] || 'info'
|
||
}
|
||
|
||
function statusText(status: string) {
|
||
return ({ pending: '待审核', processing: '审核中', approved: '已通过', need_edit: '需修改', rejected: '已拒绝' } as Record<string, string>)[status] || status || '-'
|
||
}
|
||
|
||
function formatTime(time?: string) {
|
||
return time ? new Date(time).toLocaleString('zh-CN') : '-'
|
||
}
|
||
|
||
onMounted(loadPage)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
|
||
</style>
|