Files
tyapi-frontend/src/pages/certification/index.vue
2026-03-20 13:08:22 +08:00

795 lines
21 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.

<template>
<div class="certification-page">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-content">
<div class="loading-spinner"></div>
<p class="loading-text">正在加载认证信息...</p>
</div>
</div>
<!-- 认证步骤条 -->
<div v-if="!loading && currentStep !== 'completed'" class="steps-container">
<div class="steps-header">
<h3 class="steps-title">企业入驻流程</h3>
<p class="steps-subtitle">完成企业入驻开启更多API服务功能</p>
</div>
<!-- 开发模式步骤控制器 -->
<div v-if="isDevelopment" class="dev-controller">
<div class="dev-controller-content">
<div class="dev-controller-label">
<el-icon class="dev-icon"><CodeBracketIcon /></el-icon>
<span>开发模式 - 步骤控制器</span>
</div>
<div class="dev-controller-controls">
<el-select
v-model="devCurrentStep"
placeholder="选择步骤"
size="small"
class="dev-step-select"
@change="handleDevStepChange"
>
<el-option
v-for="step in certificationSteps"
:key="step.key"
:label="step.title"
:value="step.key"
/>
</el-select>
<el-button
type="primary"
size="small"
@click="switchToStep(devCurrentStep)"
:disabled="!devCurrentStep"
class="dev-switch-btn"
>
切换到该步骤
</el-button>
<el-button type="info" size="small" @click="resetToFirstStep" class="dev-reset-btn">
重置到第一步
</el-button>
</div>
</div>
</div>
<CustomSteps
:steps="certificationSteps"
:current-step="currentStep"
class="certification-steps"
/>
</div>
<!-- 认证内容区域 -->
<div v-if="!loading" class="certification-content">
<EnterpriseInfo
v-if="currentStep === 'enterprise_info'"
:form-data="enterpriseForm"
@submit="handleEnterpriseSubmit"
/>
<!-- 暂时隐藏第二步人工审核 -->
<!-- <ManualReviewPending
v-if="currentStep === 'manual_review'"
:certification-data="certificationData"
:submit-time="manualReviewSubmitTime"
:company-name="enterpriseForm.companyName"
@refresh="getCertificationDetails"
/> -->
<EnterpriseVerify
v-if="currentStep === 'enterprise_verify'"
:enterprise-data="enterpriseForm"
:auth-url="stepMeta.auth_url"
@submit="handleEnterpriseVerifySubmit"
/>
<div v-if="currentStep === 'contract_sign'">
<ContractPreview
v-if="certificationData?.status === 'enterprise_verified'"
:contract-url="stepMeta.ContractURL"
:loading="contractSignLoading"
@start-sign="handleStartContractSign"
/>
<ContractSign
v-else-if="certificationData?.status === 'contract_applied'"
:company-name="enterpriseForm.companyName"
:iframe-url="stepMeta.contract_sign_url"
/>
<ContractRejected
v-else-if="certificationData?.status === 'contract_rejected'"
:failure-message="certificationData?.failure_message"
@contact-service="handleContactService"
@go-back="handleGoBackFromRejected"
/>
<ContractExpired
v-else-if="certificationData?.status === 'contract_expired'"
:contract-created-at="
certificationData?.contract_info?.generated_at || certificationData?.contract_applied_at
"
@reapply-contract="handleReapplyContract"
@go-back="handleGoBackFromExpired"
/>
</div>
<CertificationComplete
v-if="currentStep === 'completed'"
:certification-data="certificationData"
@go-dashboard="goToDashboard"
@go-profile="goToProfile"
/>
</div>
</div>
</template>
<script setup>
import { certificationApi } from '@/api/index.js'
import CustomSteps from '@/components/common/CustomSteps.vue'
import { useUserStore } from '@/stores/user'
import {
BuildingOfficeIcon,
CheckCircleIcon,
ClockIcon,
CodeBracketIcon,
DocumentTextIcon,
UserIcon,
} from '@heroicons/vue/24/outline'
import { ElMessage } from 'element-plus'
import CertificationComplete from './components/CertificationComplete.vue'
import ContractExpired from './components/ContractExpired.vue'
import ContractPreview from './components/ContractPreview.vue'
import ContractRejected from './components/ContractRejected.vue'
import ContractSign from './components/ContractSign.vue'
import EnterpriseInfo from './components/EnterpriseInfo.vue'
import EnterpriseVerify from './components/EnterpriseVerify.vue'
import ManualReviewPending from './components/ManualReviewPending.vue'
const router = useRouter()
const userStore = useUserStore()
// 认证步骤配置(暂时隐藏第二步「人工审核」,恢复时取消注释 manual_review 并还原 setCurrentStepByStatus 中 info_pending_review 分支)
const certificationSteps = [
{
key: 'enterprise_info',
title: '填写企业信息',
description: '填写企业基本信息和法人信息',
icon: BuildingOfficeIcon,
},
// {
// key: 'manual_review',
// title: '人工审核',
// description: '等待管理员审核企业信息',
// icon: ClockIcon,
// },
{
key: 'enterprise_verify',
title: '企业认证',
description: '完成企业认证流程',
icon: UserIcon,
},
{
key: 'contract_sign',
title: '签署合同',
description: '签署认证合同',
icon: DocumentTextIcon,
},
{
key: 'completed',
title: '完成',
description: '认证流程完成',
icon: CheckCircleIcon,
},
]
// 认证状态数据
const certificationData = ref(null)
const loading = ref(false)
// 当前步骤状态
const currentStep = ref('enterprise_info')
const currentStepIndex = computed(() => {
return certificationSteps.findIndex((step) => step.key === currentStep.value)
})
// 步骤特定元数据
const stepMeta = ref({}) // 用于存储当前步骤的metadata
// 人工审核步骤的提交时间展示
const manualReviewSubmitTime = computed(() => {
const at = certificationData.value?.metadata?.enterprise_info?.submit_at ?? certificationData.value?.info_submitted_at
if (!at) return ''
try {
const d = new Date(at)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('zh-CN')
} catch {
return ''
}
})
// 表单数据
const enterpriseForm = ref({
companyName: '',
unifiedSocialCode: '',
legalPersonName: '',
legalPersonID: '',
legalPersonPhone: '',
legalPersonCode: '',
enterpriseAddress: '',
enterpriseEmail: '',
})
//
const isDevelopment = ref(false)
const devCurrentStep = ref('enterprise_info')
// 合同签署加载状态
const contractSignLoading = ref(false)
// 只补 enterprise_verify 所需 auth_url不改动原有页面展示数据结构
// 轮询策略:最多 5 秒,每秒 1 次(最多 5 次)
const pollAuthUrlOnly = async (maxTries = 5) => {
for (let i = 0; i < maxTries; i++) {
const res = await certificationApi.getCertificationDetails()
const status = res?.data?.status
const authUrl = res?.data?.metadata?.auth_url
// 仅同步状态,避免覆盖已有展示字段
if (status && certificationData.value) {
certificationData.value.status = status
await setCurrentStepByStatus()
}
if (authUrl) {
// 保留原 metadata仅更新链接字段
stepMeta.value = {
...(stepMeta.value || {}),
auth_url: authUrl,
}
return authUrl
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
return ''
}
// 事件处理:优先用提交接口返回的认证数据更新步骤,确保进入「人工审核」页,避免依赖二次请求
const handleEnterpriseSubmit = async (payload) => {
try {
loading.value = true
const nextAction = payload?.response?.data?.metadata?.next_action
if (nextAction) {
ElMessage.success(nextAction)
} else {
ElMessage.success('企业信息提交成功,请等待管理员审核')
}
if (payload?.response?.data?.status) {
certificationData.value = payload.response.data
stepMeta.value = payload.response.data?.metadata || {}
await setCurrentStepByStatus()
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
await pollAuthUrlOnly()
}
} else {
await getCertificationDetails()
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
await pollAuthUrlOnly()
}
}
} catch (error) {
ElMessage.error(error?.message || '获取认证状态失败,请刷新页面')
} finally {
loading.value = false
}
}
const handleEnterpriseVerifySubmit = async () => {
try {
ElMessage.success('企业认证成功')
currentStep.value = 'contract_sign'
} catch {
ElMessage.error('企业认证失败,请重试')
}
}
const handleStartContractSign = async () => {
try {
contractSignLoading.value = true
// 调用后端接口,发起合同签署
await certificationApi.applyContract()
ElMessage.success('合同签署流程已启动')
await getCertificationDetails()
} catch (error) {
ElMessage.error(error?.message || '发起签署失败,请稍后重试')
} finally {
contractSignLoading.value = false
}
}
const handleContactService = () => {
// 这里可以打开客服聊天窗口或跳转到客服页面
ElMessage.success('正在为您连接客服...')
// 可以添加具体的客服逻辑,比如打开聊天窗口
}
const handleGoBackFromRejected = () => {
// 从拒签状态返回,重置到企业认证状态
currentStep.value = 'enterprise_verify'
}
const handleGoBackFromExpired = () => {
// 从过期状态返回,重置到企业认证状态
currentStep.value = 'enterprise_verify'
}
const handleReapplyContract = async () => {
try {
contractSignLoading.value = true
// 重新申请合同签署
await certificationApi.applyContract()
ElMessage.success('合同重新申请成功')
await getCertificationDetails()
} catch (error) {
ElMessage.error(error?.message || '重新申请失败,请稍后重试')
} finally {
contractSignLoading.value = false
}
}
const handleDevStepChange = (value) => {
devCurrentStep.value = value
}
const switchToStep = (stepKey) => {
currentStep.value = stepKey
}
const resetToFirstStep = () => {
currentStep.value = 'enterprise_info'
}
// 跳转到控制台
const goToDashboard = () => {
router.push('/products')
}
// 跳转到账户中心
const goToProfile = () => {
router.push('/profile')
}
// 获取认证详情
const getCertificationDetails = async () => {
try {
loading.value = true
const res = await certificationApi.getCertificationDetails()
certificationData.value = res.data
console.log('s', res)
// 回显企业信息 - 修复字段路径问题
if (res.data && res.data.metadata?.enterprise_info) {
const enterpriseInfo = res.data.metadata.enterprise_info
// 只有当后端有数据时才覆盖,避免清空用户刚填写的信息
if (enterpriseInfo.company_name) {
enterpriseForm.value.companyName = enterpriseInfo.company_name
}
if (enterpriseInfo.unified_social_code) {
enterpriseForm.value.unifiedSocialCode = enterpriseInfo.unified_social_code
}
if (enterpriseInfo.legal_person_name) {
enterpriseForm.value.legalPersonName = enterpriseInfo.legal_person_name
}
if (enterpriseInfo.legal_person_id) {
enterpriseForm.value.legalPersonID = enterpriseInfo.legal_person_id
}
if (enterpriseInfo.legal_person_phone) {
enterpriseForm.value.legalPersonPhone = enterpriseInfo.legal_person_phone
}
if (enterpriseInfo.enterprise_address) {
enterpriseForm.value.enterpriseAddress = enterpriseInfo.enterprise_address
}
if (enterpriseInfo.enterprise_email) {
enterpriseForm.value.enterpriseEmail = enterpriseInfo.enterprise_email
}
}
// 步骤特定元数据
stepMeta.value = res.data?.metadata || {}
await setCurrentStepByStatus()
} catch (error) {
console.error('获取认证详情失败:', error)
ElMessage.error('获取认证详情失败')
} finally {
loading.value = false
}
}
// 根据认证状态设置当前步骤
const setCurrentStepByStatus = async () => {
if (!certificationData.value) {
currentStep.value = 'enterprise_info'
return
}
const previousStatus = currentStep.value
const newStatus = certificationData.value.status
switch (newStatus) {
case 'pending':
currentStep.value = 'enterprise_info'
break
case 'info_pending_review':
// 暂时跳过人工审核展示,直接进入企业认证步骤
currentStep.value = 'enterprise_verify'
break
case 'info_submitted':
currentStep.value = 'enterprise_verify'
break
case 'enterprise_verified':
case 'contract_applied':
case 'contract_rejected':
case 'contract_expired':
currentStep.value = 'contract_sign'
break
case 'contract_signed':
case 'completed':
currentStep.value = 'completed'
// 认证完成时重新获取用户信息
if (previousStatus !== 'completed') {
try {
await userStore.fetchUserProfile()
console.log('认证完成,已重新获取用户信息')
} catch (error) {
console.error('重新获取用户信息失败:', error)
}
}
break
case 'info_rejected':
currentStep.value = 'enterprise_info'
break
default:
currentStep.value = 'enterprise_info'
}
}
// 认证详情轮询用于iframe回调后确认状态变化
async function pollCertificationDetails(maxTries = 5) {
let tries = 0
let lastStatus = certificationData.value?.status
let changed = false
while (tries < maxTries && !changed) {
try {
// 不显示loading
const res = await certificationApi.getCertificationDetails()
const newStatus = res.data?.status
if (newStatus !== lastStatus) {
certificationData.value = res.data
// 回显企业信息 - 修复字段路径问题
if (res.data && res.data.metadata?.enterprise_info) {
const enterpriseInfo = res.data.metadata.enterprise_info
// 只有当后端有数据时才覆盖,避免清空用户刚填写的信息
if (enterpriseInfo.company_name) {
enterpriseForm.value.companyName = enterpriseInfo.company_name
}
if (enterpriseInfo.unified_social_code) {
enterpriseForm.value.unifiedSocialCode = enterpriseInfo.unified_social_code
}
if (enterpriseInfo.legal_person_name) {
enterpriseForm.value.legalPersonName = enterpriseInfo.legal_person_name
}
if (enterpriseInfo.legal_person_id) {
enterpriseForm.value.legalPersonID = enterpriseInfo.legal_person_id
}
if (enterpriseInfo.legal_person_phone) {
enterpriseForm.value.legalPersonPhone = enterpriseInfo.legal_person_phone
}
}
stepMeta.value = res.data?.metadata || {}
await setCurrentStepByStatus()
changed = true
break
}
} catch (error) {
// 可选:打印错误
console.warn('轮询认证详情失败:', error)
}
tries++
const delay = Math.min(tries, 5) * 1000 // 1s,2s,3s,4s,5s
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
// Iframe 回调消息监听
const handledScenes = new Set()
async function handleIframeMessage(event) {
// 这里可以加安全校验event.origin === '你的主站点origin'
// 目前只打印消息,后续可补充具体逻辑
const { type, scene } = event.data || {}
if (type === 'CHECK_STATUS') {
if (handledScenes.has(scene)) {
console.log('该scene已处理忽略重复消息:', scene)
return
}
console.log('收到iframe消息:', event)
handledScenes.add(scene)
console.log('需要检查后端状态scene:', scene)
try {
if (scene === 'sign') {
// 合同签署场景,调用 confirmSign 接口
const response = await certificationApi.confirmSign()
console.log('confirmSign 接口返回:', response)
// 确认签署状态后,重新获取完整的认证详情
await getCertificationDetails()
} else {
// 企业认证场景,使用原有的轮询方式
pollCertificationDetails()
}
} catch (error) {
console.error('检查状态失败:', error)
ElMessage.error('检查状态失败,请稍后重试')
}
}
}
// 初始化
onMounted(() => {
window.addEventListener('message', handleIframeMessage)
getCertificationDetails()
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleIframeMessage)
})
</script>
<style scoped>
.certification-page {
min-height: 100vh;
position: relative;
}
/* 加载状态 */
.loading-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 60px;
margin: 40px auto;
text-align: center;
min-height: 600px;
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 16px;
color: #64748b;
font-weight: 500;
}
/* 步骤条容器 */
.steps-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 36px;
margin: 0 auto 32px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.steps-header {
text-align: center;
margin-bottom: 24px;
}
.steps-title {
font-size: 28px;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
background: linear-gradient(135deg, #1e293b 0%, #475569 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.steps-subtitle {
font-size: 16px;
color: #64748b;
font-weight: 500;
}
.certification-steps {
margin-bottom: 0;
}
.certification-content {
min-height: 600px;
margin: 0 auto;
}
/* 开发模式控制器样式 */
.dev-controller {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 48px;
border: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
gap: 20px;
}
.dev-controller-content {
display: flex;
align-items: center;
gap: 20px;
}
.dev-controller-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.dev-controller-controls {
display: flex;
align-items: center;
gap: 10px;
}
.dev-step-select :deep(.el-input__inner) {
border-radius: 8px;
border-color: #e2e8f0;
background-color: #f8fafc;
}
.dev-step-select :deep(.el-input__inner:focus) {
border-color: #3b82f6;
box-shadow: 0 0 0 2px #3b82f6;
}
.dev-switch-btn :deep(.el-button--small) {
border-radius: 8px;
background-color: #3b82f6;
border-color: #3b82f6;
}
.dev-switch-btn :deep(.el-button--small:hover) {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.dev-reset-btn :deep(.el-button--small) {
border-radius: 8px;
background-color: #e2e8f0;
border-color: #e2e8f0;
color: #64748b;
}
.dev-reset-btn :deep(.el-button--small:hover) {
background-color: #cbd5e1;
border-color: #cbd5e1;
color: #475569;
}
.dev-icon {
width: 20px;
height: 20px;
color: #3b82f6;
}
/* 动画效果 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.certification-page {
padding: 20px 16px;
}
.steps-container {
padding: 32px 24px;
margin-bottom: 24px;
}
.steps-title {
font-size: 24px;
}
.steps-subtitle {
font-size: 14px;
}
.certification-steps :deep(.el-step__icon) {
width: 40px;
height: 40px;
}
.certification-steps :deep(.el-step__title) {
font-size: 14px;
}
.certification-steps :deep(.el-step__description) {
font-size: 12px;
}
.loading-container {
margin: 20px 16px;
padding: 40px 24px;
}
.dev-controller {
flex-direction: column;
align-items: flex-start;
gap: 15px;
padding: 16px;
}
.dev-controller-content {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.dev-controller-label {
font-size: 14px;
}
.dev-controller-controls {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.dev-step-select :deep(.el-input__inner) {
width: 100%;
}
.dev-switch-btn,
.dev-reset-btn {
width: 100%;
}
}
@media (max-width: 480px) {
.certification-steps :deep(.el-step__icon) {
width: 36px;
height: 36px;
}
.certification-steps :deep(.el-step__icon.is-text) {
font-size: 16px;
}
}
</style>