2026-07-30 17:59:43 +08:00
< script setup lang = "ts" >
2026-08-01 12:17:33 +08:00
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , type FormInstance , type FormRules } from 'element-plus'
import { Eye , FileCheck2 , ShieldCheck , ShieldX } from '@lucide/vue'
2026-07-30 17:59:43 +08:00
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'
2026-08-01 12:17:33 +08:00
import DataTable , { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/components/common/QueryToolbar.vue'
2026-07-30 17:59:43 +08:00
import { useAccessPreviewStore } from '@/stores/accessPreview'
const access = useAccessPreviewStore ()
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const reviews = ref < EnterpriseReview [] >([])
2026-08-01 12:17:33 +08:00
const page = ref ( 1 )
const pageSize = ref ( 10 )
type ReviewFilters = QueryModel & { keyword : string }
const filters = ref < ReviewFilters >({ keyword : '' })
const appliedFilters = ref < ReviewFilters >({ keyword : '' })
const queryFields : QueryField [] = [
{ key : 'keyword' , type : 'input' , placeholder : '企业名称、企业编号、信用代码或审核单号' },
]
2026-07-30 17:59:43 +08:00
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 : '' })
2026-08-01 12:17:33 +08:00
const decisionFormRef = ref < FormInstance >()
const decisionRules : FormRules = {
comment : [{
validator : ( _rule , value , callback ) => {
if ( typeof value === 'string' && value . trim ()) {
callback ()
return
}
callback ( new Error ( '请填写审核说明或驳回原因' ))
},
trigger : [ 'blur' , 'change' ],
}],
}
2026-07-30 17:59:43 +08:00
const filteredReviews = computed (() => {
2026-08-01 12:17:33 +08:00
const query = appliedFilters . value . keyword . trim (). toLowerCase ()
2026-07-30 17:59:43 +08:00
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 )))
})
2026-08-01 12:17:33 +08:00
const pagedReviews = computed (() => {
const start = ( page . value - 1 ) * pageSize . value
return filteredReviews . value . slice ( start , start + pageSize . value )
})
const reviewColumns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 250 , fixed : 'left' , slotName : 'actions' },
{ key : 'company' , label : '企业' , minWidth : 230 , slotName : 'company' },
{ key : 'qualification' , label : '资质' , minWidth : 210 , slotName : 'qualification' },
{ key : 'contact' , label : '联系人' , minWidth : 180 , slotName : 'contact' },
{ key : 'submittedAt' , label : '提交时间' , minWidth : 175 , slotName : 'submittedAt' },
{ key : 'status' , label : '状态' , width : 110 , slotName : 'status' },
]
2026-07-30 17:59:43 +08:00
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
}
}
2026-08-01 12:17:33 +08:00
function searchReviews () {
appliedFilters . value = { ... filters . value }
page . value = 1
void loadReviews ()
}
2026-07-30 17:59:43 +08:00
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
2026-08-01 12:17:33 +08:00
void nextTick (() => decisionFormRef . value ? . clearValidate ( 'comment' ))
2026-07-30 17:59:43 +08:00
}
async function submitDecision () {
const review = selectedReview . value
2026-08-01 12:17:33 +08:00
const form = decisionFormRef . value
if ( ! review || ! form || submitting . value ) return
const valid = await form . validate (). then (() => true ). catch (() => false )
if ( ! valid ) return
2026-07-30 17:59:43 +08:00
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 >
< div class = "business-page enterprise-review-page" >
2026-08-01 12:17:33 +08:00
< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="企业审核筛选" :fields="queryFields" @search="searchReviews" />
2026-07-30 17:59:43 +08:00
< 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 >
2026-08-01 12:17:33 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "pagedReviews"
:columns = "reviewColumns"
:loading = "loading"
:total = "filteredReviews.length"
aria -label =" 企业审核列表 "
row -key =" id "
empty -text =" 暂无待审核企业 "
>
< template # actions = "{ row }" >
2026-07-30 21:23:39 +08:00
< 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 >
2026-08-01 12:17:33 +08:00
</ template >
< template # company = "{ 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 >
< template # qualification = "{ row }" >< div class = "primary-cell" >< strong >{{ qualificationName (( row as EnterpriseReview ). qualificationType ) }}</ strong >< span >{{ displayText (( row as EnterpriseReview ). certificateNo ) }}</ span ></ div ></ template >
< template # contact = "{ row }" >< div class = "primary-cell" >< strong >{{ displayText (( row as EnterpriseReview ). contactName ) }}</ strong >< span >{{ displayText (( row as EnterpriseReview ). contactPhone ) }}</ span ></ div ></ template >
< template # submittedAt = "{ row }" >{{ formatDate (( row as EnterpriseReview ). submittedAt ?? ( row as EnterpriseReview ). createdAt ) }}</ template >
< template # status >< span class = "status-badge is-warning" >< i /> 待审核 </ span ></ template >
</ DataTable >
2026-07-30 17:59:43 +08:00
</ 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 >
2026-08-01 12:17:33 +08:00
< el-form ref = "decisionFormRef" :model = "decision" :rules = "decisionRules" label -position =" top " >
< el-form-item prop = "comment" : label = "decision.type === 'approve' ? '审核说明' : '驳回原因'" required >
2026-07-30 17:59:43 +08:00
< 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 - 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 ; }
2026-08-01 12:17:33 +08:00
. decision - summary > * { min - width : 0 ; overflow - wrap : anywhere ; }
2026-07-30 17:59:43 +08:00
. decision - summary strong { color : # 172033 ; font - size : 13 px ; }
. decision - summary span { color : var ( -- cc - muted ); font - size : 11 px ; }
2026-08-01 12:17:33 +08:00
: deep (. review - decision - dialog . el - form - item__error ) { line - height : 1.45 ; overflow - wrap : anywhere ; }
2026-07-30 17:59:43 +08:00
@ media ( max - width : 720 px ) {
. 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 ; }
2026-08-01 12:17:33 +08:00
. decision - summary { align - items : flex - start ; flex - direction : column ; }
2026-07-30 17:59:43 +08:00
}
</ style >