2026-07-30 17:59:43 +08:00
< script setup lang = "ts" >
import { computed , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Eye , FileCheck2 , RefreshCw , Search , ShieldCheck , ShieldX } from '@lucide/vue'
import {
approveEnterpriseReview ,
downloadTenantQualification ,
listEnterpriseReviews ,
listTenantQualifications ,
qualificationFileName ,
rejectEnterpriseReview ,
type EnterpriseReview ,
type PlatformTenantQualification ,
} from '@/api/platformIam'
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const access = useAccessPreviewStore ()
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const reviews = ref < EnterpriseReview [] >([])
const keyword = ref ( '' )
const detailOpen = ref ( false )
const selectedReview = ref < EnterpriseReview | null >( null )
const qualifications = ref < PlatformTenantQualification [] >([])
const qualificationsLoading = ref ( false )
const qualificationsError = ref < ApiErrorDetails | null >( null )
const previewLoadingId = ref < string >()
const previewOpen = ref ( false )
const previewUrl = ref ( '' )
const previewName = ref ( '' )
const decisionOpen = ref ( false )
const submitting = ref ( false )
const decision = reactive ({ type : 'approve' as 'approve' | 'reject' , comment : '' })
const filteredReviews = computed (() => {
const query = keyword . value . trim (). toLowerCase ()
if ( ! query ) return reviews . value
return reviews . value . filter (( review ) => [
review . tenantName ,
review . tenantCode ,
review . certificateNo ,
String ( review . tenantId ),
String ( review . id ),
]. some (( value ) => value ? . toLowerCase (). includes ( query )))
})
const metrics = computed (() => [
{ label : '待审核企业' , value : reviews . value . length , note : '当前需要平台处理' },
{ label : '营业执照' , value : reviews . value . filter (( item ) => item . qualificationType === 'BUSINESS_LICENSE' ). length , note : '本期待核验资质' },
{ label : '可通过' , value : access . can ( 'platform:customer:approve' ) ? reviews . value . length : 0 , note : '按当前账号权限' },
{ label : '当前结果' , value : filteredReviews . value . length , note : keyword . value ? '符合搜索条件' : '全部待审记录' },
])
function displayText ( value : unknown ) {
return typeof value === 'string' && value . trim () ? value : '—'
}
function formatDate ( value ?: string | null ) {
if ( ! value ) return '—'
const date = new Date ( value )
return Number . isNaN ( date . getTime ()) ? value : date . toLocaleString ( 'zh-CN' , { hour12 : false })
}
function qualificationName ( value ?: string | null ) {
return value === 'BUSINESS_LICENSE' ? '营业执照' : displayText ( value )
}
function qualificationStatus ( status : PlatformTenantQualification [ 'status' ]) {
if ( status === 'APPROVED' ) return { label : '已通过' , tone : 'is-success' }
if ( status === 'REJECTED' ) return { label : '已驳回' , tone : 'is-danger' }
return { label : '待审核' , tone : 'is-warning' }
}
async function loadReviews () {
loading . value = true
loadError . value = null
try {
reviews . value = await listEnterpriseReviews ()
} catch ( error ) {
loadError . value = getApiErrorDetails ( error , '审核列表加载失败,请稍后重试' )
} finally {
loading . value = false
}
}
async function loadSelectedQualifications () {
const tenantId = selectedReview . value ? . tenantId
if ( ! tenantId ) return
qualificationsLoading . value = true
qualificationsError . value = null
qualifications . value = []
try {
const result = await listTenantQualifications ( tenantId )
if ( selectedReview . value ? . tenantId === tenantId ) qualifications . value = result
} catch ( error ) {
if ( selectedReview . value ? . tenantId === tenantId ) {
qualificationsError . value = getApiErrorDetails ( error , '资质列表加载失败,请稍后重试' )
}
} finally {
if ( selectedReview . value ? . tenantId === tenantId ) qualificationsLoading . value = false
}
}
function openDetail ( review : EnterpriseReview ) {
selectedReview . value = review
detailOpen . value = true
void loadSelectedQualifications ()
}
function clearPreviewUrl () {
if ( previewUrl . value ) URL . revokeObjectURL ( previewUrl . value )
previewUrl . value = ''
previewName . value = ''
}
async function previewQualification ( qualification : PlatformTenantQualification ) {
const tenantId = selectedReview . value ? . tenantId
if ( ! tenantId || ! qualification . documentUrl ) return
previewLoadingId . value = String ( qualification . id )
try {
const blob = await downloadTenantQualification ( tenantId , qualification . documentUrl )
if ( ! blob . size ) throw new Error ( '资质文件内容为空' )
clearPreviewUrl ()
previewUrl . value = URL . createObjectURL ( blob )
previewName . value = qualificationFileName ( qualification . documentUrl )
previewOpen . value = true
} catch ( error ) {
ElMessage . error ( getApiErrorMessage ( error , '资质文件加载失败,请稍后重试' ))
} finally {
previewLoadingId . value = undefined
}
}
function openDecision ( review : EnterpriseReview , type : 'approve' | 'reject' ) {
selectedReview . value = review
decision . type = type
decision . comment = type === 'approve' ? '营业执照清晰,企业资料审核通过' : ''
decisionOpen . value = true
}
async function submitDecision () {
const review = selectedReview . value
if ( ! review || submitting . value ) return
if ( ! decision . comment . trim ()) {
ElMessage . warning ( decision . type === 'approve' ? '请填写审核说明' : '请填写驳回原因' )
return
}
submitting . value = true
try {
if ( decision . type === 'approve' ) {
await approveEnterpriseReview ( review . id , decision . comment . trim ())
} else {
await rejectEnterpriseReview ( review . id , decision . comment . trim ())
}
reviews . value = reviews . value . filter (( item ) => item . id !== review . id )
decisionOpen . value = false
detailOpen . value = false
ElMessage . success ( decision . type === 'approve' ? '企业资料已审核通过' : '企业资料已驳回' )
} catch ( error ) {
ElMessage . error ( getApiErrorMessage ( error , '审核提交失败,请稍后重试' ))
} finally {
submitting . value = false
}
}
watch (
() => [ access . can ( 'platform:customer:approve' ), access . can ( 'platform:customer:reject' )],
([ canApprove , canReject ]) => {
if (( decision . type === 'approve' && ! canApprove ) || ( decision . type === 'reject' && ! canReject )) {
decisionOpen . value = false
}
},
)
watch ( previewOpen , ( open ) => {
if ( ! open ) clearPreviewUrl ()
})
watch ( detailOpen , ( open ) => {
if ( ! open ) previewOpen . value = false
})
onMounted ( loadReviews )
onBeforeUnmount ( clearPreviewUrl )
</ script >
< template >
<!-- THESIS : 把企业审核组织成一条清晰的待办队列 , 而不是混在客户档案的模拟状态里 。
OWN - WORLD : 延续 Admin 的海军蓝框架 、 白色数据面板 、 紧凑表格和克制状态色 。
STORY : 审核员先识别待审企业 , 再核对营业执照与联系人 , 最后留下明确的通过或驳回意见 。
FIRST VIEWPORT : 摘要 、 搜索和真实待审列表连续呈现 , 详情从右侧展开 , 决策在受保护的确认对话框中完成 。
FORM : 现有 Operate 页面体系的直接扩展 , 不改变全局导航与品牌语言 。
FINISH : unreviewed and undocumented is unfinished ; this build ends with the finish review , the verdict , and DESIGN . md -->
< div class = "business-page enterprise-review-page" >
< MetricStrip :items = "metrics" />
< section class = "surface-panel review-toolbar" aria -label =" 企业审核筛选 " >
< el-input v-model = "keyword" clearable placeholder="企业名称、企业编号、信用代码或审核单号" aria-label="企业审核关键词" >
< template # prefix >< Search :size = "16" /></ template >
</ el-input >
< el-button :loading = "loading" @click ="loadReviews" >< RefreshCw :size = "16" /> 刷新 </ el-button >
</ section >
< section v-if = "loadError" class="surface-panel review-error" role="alert" >
< ShieldX :size = "22" />
< div >< strong > 审核列表暂时无法加载 </ strong >< p >{{ loadError . message }}</ p >< ApiErrorMeta :details = "loadError" /></ div >
< el-button type = "primary" @click ="loadReviews" > 重新加载 </ el -button >
</ section >
< section v-else class = "surface-panel table-panel" :aria-busy = "loading" >
< header class = "panel-heading" >
< div >< h2 > 企业资料审核 </ h2 >< p > 仅展示后端返回的待审核记录 , 提交审核时使用审核单 ID 。 </ p ></ div >
</ header >
< el-table v-loading = "loading" :data="filteredReviews" row-key="id" empty-text="暂无待审核企业" >
2026-07-30 21:23:39 +08:00
< el-table-column label = "操作" width = "250" fixed = "left" >
< template # default = "{ row }" >
< div class = "table-actions" >
< el-button link type = "primary" @click ="openDetail(row as EnterpriseReview)" >< Eye :size = "15" /> 详情 </ el-button >
< el-button v-if = "access.can('platform:customer:approve')" link type="success" @click="openDecision(row as EnterpriseReview, 'approve')"><ShieldCheck :size="15" /> 通过 </ el-button >
< el-button v-if = "access.can('platform:customer:reject')" link type="danger" @click="openDecision(row as EnterpriseReview, 'reject')"><ShieldX :size="15" /> 驳回 </ el-button >
</ div >
</ template >
</ el-table-column >
2026-07-30 17:59:43 +08:00
< el-table-column label = "企业" min -width =" 230 " >
< template # default = "{ row }" >
< div class = "primary-cell" >< strong >{{ displayText (( row as EnterpriseReview ). tenantName ) }}</ strong >< span >{{ displayText (( row as EnterpriseReview ). tenantCode ) }} · 租户 {{ ( row as EnterpriseReview ). tenantId }}</ span ></ div >
</ template >
</ el-table-column >
< el-table-column label = "资质" min -width =" 210 " >
< template # default = "{ row }" >
< div class = "primary-cell" >< strong >{{ qualificationName (( row as EnterpriseReview ). qualificationType ) }}</ strong >< span >{{ displayText (( row as EnterpriseReview ). certificateNo ) }}</ span ></ div >
</ template >
</ el-table-column >
< el-table-column label = "联系人" min -width =" 180 " >
< template # default = "{ row }" >
< div class = "primary-cell" >< strong >{{ displayText (( row as EnterpriseReview ). contactName ) }}</ strong >< span >{{ displayText (( row as EnterpriseReview ). contactPhone ) }}</ span ></ div >
</ template >
</ el-table-column >
< el-table-column label = "提交时间" min -width =" 175 " >< template # default = "{ row }" >{{ formatDate (( row as EnterpriseReview ). submittedAt ?? ( row as EnterpriseReview ). createdAt ) }}</ template ></ el-table-column >
< el-table-column label = "状态" width = "110" >< template # default >< span class = "status-badge is-warning" >< i /> 待审核 </ span ></ template ></ el-table-column >
</ el-table >
< footer class = "table-footer" >< span > 共 {{ filteredReviews . length }} 条待审记录 </ span >< span v-if = "keyword" > 已按关键词筛选 </ span ></ footer >
</ section >
< el-drawer v-model = "detailOpen" title="企业审核详情" size="620px" class="business-drawer" >
< template v-if = "selectedReview" >
< div class = "detail-identity" >
< span >< FileCheck2 :size = "20" /></ span >
< div >< h2 >{{ displayText ( selectedReview . tenantName ) }}</ h2 >< p > 审核单 {{ selectedReview . id }} · 租户 {{ selectedReview . tenantId }}</ p ></ div >
< span class = "status-badge is-warning" >< i /> 待审核 </ span >
</ div >
< section class = "detail-section" >
< h3 > 企业与联系人 </ h3 >
< el-descriptions :column = "1" border >
< el-descriptions-item label = "企业编号" >{{ displayText ( selectedReview . tenantCode ) }}</ el-descriptions-item >
< el-descriptions-item label = "联系人" >{{ displayText ( selectedReview . contactName ) }}</ el-descriptions-item >
< el-descriptions-item label = "联系电话" >{{ displayText ( selectedReview . contactPhone ) }}</ el-descriptions-item >
< el-descriptions-item label = "联系邮箱" >{{ displayText ( selectedReview . contactEmail ) }}</ el-descriptions-item >
</ el-descriptions >
</ section >
< section class = "detail-section" >
< h3 > 资质文件 </ h3 >
< div class = "qualification-state" :aria-busy = "qualificationsLoading" >
< div v-if = "qualificationsLoading" v-loading="true" class="qualification-loading" > 正在读取该企业资质 … </ div >
< div v-else-if = "qualificationsError" class="qualification-error" role="alert" >
< div >< strong > 资质暂时无法加载 </ strong >< p >{{ qualificationsError . message }}</ p >< ApiErrorMeta :details = "qualificationsError" /></ div >
< el-button type = "primary" plain @click ="loadSelectedQualifications" > 重新加载 </ el -button >
</ div >
< el-empty v-else-if = "qualifications.length === 0" :image-size="64" description="该企业暂未提交资质" />
< div v-else class = "qualification-list" >
< article v-for = "qualification in qualifications" :key="qualification.id" class="qualification-item" >
< div class = "qualification-copy" >
< div >< strong >{{ qualificationName ( qualification . qualificationType ) }}</ strong >< span class = "status-badge" :class = "qualificationStatus(qualification.status).tone" >< i />{{ qualificationStatus ( qualification . status ). label }}</ span ></ div >
< p > 信用代码 : {{ displayText ( qualification . certificateNo ) }}</ p >
< p v-if = "qualification.reviewComment" > 审核意见 : {{ qualification.reviewComment }}</ p >
</ div >
< el-button
type = "primary"
plain
:disabled = "!qualification.documentUrl"
: loading = "previewLoadingId === String(qualification.id)"
@click ="previewQualification(qualification)"
>< Eye :size = "15" /> 查看文件 </ el-button >
</ article >
</ div >
</ div >
< p class = "detail-note" > 请核对企业名称 、 统一社会信用代码和营业执照内容 。 通过后企业获得下单资格 ; 驳回时必须说明原因 。 </ p >
</ section >
</ template >
< template # footer >
< div class = "drawer-footer" >
< el-button @click ="detailOpen = false" > 关闭 </ el -button >
< el-button v-if = "selectedReview && access.can('platform:customer:reject')" type="danger" plain @click="openDecision(selectedReview, 'reject')" > 驳回 </ el -button >
< el-button v-if = "selectedReview && access.can('platform:customer:approve')" type="success" @click="openDecision(selectedReview, 'approve')" > 审核通过 </ el -button >
</ div >
</ template >
</ el-drawer >
< el-dialog v-model = "previewOpen" :title="previewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy -on-close >
< div class = "qualification-preview" >
< img v-if = "previewUrl" :src="previewUrl" :alt="`${previewName}预览`" />
</ div >
< template # footer >< el-button @click ="previewOpen = false" > 关闭预览 </ el -button ></ template >
</ el-dialog >
< el-dialog v-model = "decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false" >
< div v-if = "selectedReview" class="decision-summary" >
< strong >{{ displayText ( selectedReview . tenantName ) }}</ strong >
< span > 审核单 {{ selectedReview . id }}</ span >
</ div >
< el-form label -position =" top " >
< el-form-item : label = "decision.type === 'approve' ? '审核说明' : '驳回原因'" required >
< el-input v-model = "decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" />
</ el-form-item >
</ el-form >
< template # footer >
< div class = "drawer-footer" >
< el-button :disabled = "submitting" @click ="decisionOpen = false" > 取消 </ el -button >
< el-button : type = "decision.type === 'approve' ? 'success' : 'danger'" :loading = "submitting" @click ="submitDecision" >{{ decision.type === ' approve ' ? ' 确认通过 ' : ' 确认驳回 ' }} </ el -button >
</ div >
</ template >
</ el-dialog >
</ div >
</ template >
< style scoped >
. review - toolbar { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; padding : 14 px 16 px ; }
. review - toolbar . el - input { width : min ( 460 px , 100 % ); }
. review - error { display : grid ; grid - template - columns : auto minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 14 px ; padding : 18 px 20 px ; border - color : # fecdca ; background : # fffafa ; color : # b42318 ; }
. review - error strong { color : # 7 a271a ; font - size : 13 px ; }
. review - error p { margin : 4 px 0 0 ; color : # b54708 ; font - size : 12 px ; }
. qualification - state { min - height : 112 px ; }
. qualification - loading { display : grid ; min - height : 112 px ; place - items : center ; color : var ( -- cc - muted ); font - size : 12 px ; }
. qualification - error { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 14 px ; border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; }
. qualification - error strong { color : # 7 a271a ; font - size : 13 px ; }
. qualification - error p { margin : 4 px 0 0 ; color : # b54708 ; font - size : 12 px ; overflow - wrap : anywhere ; }
. qualification - list { display : flex ; flex - direction : column ; gap : 10 px ; }
. qualification - item { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 14 px ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # fff ; }
. qualification - copy { min - width : 0 ; }
. qualification - copy > div { display : flex ; min - width : 0 ; align - items : center ; gap : 8 px ; }
. qualification - copy strong { overflow - wrap : anywhere ; font - size : 13 px ; }
. qualification - copy p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 11 px ; line - height : 1.55 ; overflow - wrap : anywhere ; }
. qualification - preview { display : grid ; max - height : min ( 70 vh , 760 px ); min - height : 240 px ; place - items : center ; overflow : auto ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # f8fafc ; }
. qualification - preview img { display : block ; max - width : 100 % ; height : auto ; }
. decision - summary { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 18 px ; padding : 12 px 14 px ; border - radius : 8 px ; background : # f8fafc ; }
. decision - summary strong { color : # 172033 ; font - size : 13 px ; }
. decision - summary span { color : var ( -- cc - muted ); font - size : 11 px ; }
@ media ( max - width : 720 px ) {
. review - toolbar { align - items : stretch ; flex - direction : column ; }
. review - toolbar . el - input { width : 100 % ; }
. review - error { grid - template - columns : auto minmax ( 0 , 1 fr ); }
. review - error . el - button { grid - column : 1 / - 1 ; min - height : 44 px ; }
. qualification - error , . qualification - item { align - items : stretch ; flex - direction : column ; }
. qualification - error . el - button , . qualification - item . el - button { min - height : 44 px ; }
}
</ style >