Files
admin-web/src/views/EnterpriseReviewsView.vue
T

376 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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'
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 DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const reviews = ref<EnterpriseReview[]>([])
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: '企业名称、企业编号、信用代码或审核单号' },
]
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 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'],
}],
}
const filteredReviews = computed(() => {
const query = appliedFilters.value.keyword.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 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' },
]
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
}
}
function searchReviews() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadReviews()
}
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
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
}
async function submitDecision() {
const review = selectedReview.value
const form = decisionFormRef.value
if (!review || !form || submitting.value) return
const valid = await form.validate().then(() => true).catch(() => false)
if (!valid) 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>
<div class="business-page enterprise-review-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="企业审核筛选" :fields="queryFields" @search="searchReviews" />
<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>
<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 }">
<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>
<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>
</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 ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
<el-form-item prop="comment" :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-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; color: #b42318; }
.review-error strong { color: #7a271a; font-size: 13px; }
.review-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; }
.qualification-state { min-height: 112px; }
.qualification-loading { display: grid; min-height: 112px; place-items: center; color: var(--cc-muted); font-size: 12px; }
.qualification-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid #fecdca; border-radius: 6px; background: #fffafa; }
.qualification-error strong { color: #7a271a; font-size: 13px; }
.qualification-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
.qualification-list { display: flex; flex-direction: column; gap: 10px; }
.qualification-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.qualification-copy { min-width: 0; }
.qualification-copy > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.qualification-copy strong { overflow-wrap: anywhere; font-size: 13px; }
.qualification-copy p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.55; overflow-wrap: anywhere; }
.qualification-preview { display: grid; max-height: min(70vh, 760px); min-height: 240px; place-items: center; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.qualification-preview img { display: block; max-width: 100%; height: auto; }
.decision-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; }
.decision-summary > * { min-width: 0; overflow-wrap: anywhere; }
.decision-summary strong { color: #172033; font-size: 13px; }
.decision-summary span { color: var(--cc-muted); font-size: 11px; }
:deep(.review-decision-dialog .el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.review-error { grid-template-columns: auto minmax(0, 1fr); }
.review-error .el-button { grid-column: 1 / -1; min-height: 44px; }
.qualification-error, .qualification-item { align-items: stretch; flex-direction: column; }
.qualification-error .el-button, .qualification-item .el-button { min-height: 44px; }
.decision-summary { align-items: flex-start; flex-direction: column; }
}
</style>