This commit is contained in:
2026-07-21 15:53:43 +08:00
parent 0761f71316
commit 480c46f15b
10 changed files with 641 additions and 168 deletions

View File

@@ -178,6 +178,12 @@ export const certificationApi = {
// 申请合同签署 // 申请合同签署
applyContract: () => request.post('/certifications/apply-contract'), applyContract: () => request.post('/certifications/apply-contract'),
// 刷新签署 iframe 长链(法大大 EmbedURL 约 10 分钟/单次)
refreshContractSignUrl: () => request.post('/certifications/refresh-contract-sign-url'),
// 刷新合同预览链接(法大大 get-preview-url
refreshContractPreviewUrl: () => request.post('/certifications/refresh-contract-preview-url'),
// 获取认证详情 // 获取认证详情
getCertificationDetails: () => request.get('/certifications/details'), getCertificationDetails: () => request.get('/certifications/details'),
@@ -187,6 +193,12 @@ export const certificationApi = {
// 确认认证状态 // 确认认证状态
confirmAuth: (data) => request.post('/certifications/confirm-auth', data), confirmAuth: (data) => request.post('/certifications/confirm-auth', data),
// 可选签署平台列表
getSignPlatforms: () => request.get('/certifications/sign-platforms'),
// 选择签署平台并生成企业认证链接
selectSignPlatform: (data) => request.post('/certifications/select-sign-platform', data),
// OCR营业执照识别 // OCR营业执照识别
recognizeBusinessLicense: (formData) => request.post('/certifications/ocr/business-license', formData, { recognizeBusinessLicense: (formData) => request.post('/certifications/ocr/business-license', formData, {
headers: { headers: {

View File

@@ -2,7 +2,7 @@
<div class="certification-reviews-page"> <div class="certification-reviews-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">企业审核</h1> <h1 class="page-title">企业审核</h1>
<p class="page-subtitle">审核用户提交的企业信息通过后可进入企业认证流程</p> <p class="page-subtitle">审核用户提交的企业信息通过后由用户选择签署平台e签宝/法大大再继续认证</p>
</div> </div>
<div class="toolbar"> <div class="toolbar">
@@ -59,13 +59,18 @@
<el-table-column prop="company_name" label="企业名称" min-width="180" show-overflow-tooltip /> <el-table-column prop="company_name" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="unified_social_code" label="统一社会信用代码" width="200" /> <el-table-column prop="unified_social_code" label="统一社会信用代码" width="200" />
<el-table-column prop="legal_person_name" label="法人姓名" width="100" /> <el-table-column prop="legal_person_name" label="法人姓名" width="100" />
<el-table-column prop="certification_status" label="认证状态" width="100"> <el-table-column prop="certification_status" label="认证状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusTagType(row)" size="small"> <el-tag :type="statusTagType(row)" size="small">
{{ certificationStatusDisplay(row?.certification_status) }} {{ certificationStatusDisplay(row) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="签署平台" width="100">
<template #default="{ row }">
{{ signPlatformDisplay(row?.sign_platform) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button> <el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button>
@@ -83,10 +88,13 @@
<div class="mobile-card-header"> <div class="mobile-card-header">
<div class="mobile-company">{{ row.company_name || '-' }}</div> <div class="mobile-company">{{ row.company_name || '-' }}</div>
<el-tag :type="statusTagType(row)" size="small"> <el-tag :type="statusTagType(row)" size="small">
{{ certificationStatusDisplay(row?.certification_status) }} {{ certificationStatusDisplay(row) }}
</el-tag> </el-tag>
</div> </div>
<div class="mobile-card-info"><span>提交时间</span>{{ formatDate(row.submit_at) }}</div> <div class="mobile-card-info"><span>提交时间</span>{{ formatDate(row.submit_at) }}</div>
<div class="mobile-card-info">
<span>签署平台</span>{{ signPlatformDisplay(row.sign_platform) }}
</div>
<div class="mobile-card-info"><span>法人</span>{{ row.legal_person_name || '-' }}</div> <div class="mobile-card-info"><span>法人</span>{{ row.legal_person_name || '-' }}</div>
<div class="mobile-card-info"> <div class="mobile-card-info">
<span>手机号</span>{{ row.legal_person_phone || '-' }} <span>手机号</span>{{ row.legal_person_phone || '-' }}
@@ -162,8 +170,11 @@
<dt>认证状态</dt> <dt>认证状态</dt>
<dd> <dd>
<el-tag :type="statusTagType(detail)" size="small"> <el-tag :type="statusTagType(detail)" size="small">
{{ certificationStatusDisplay(detail?.certification_status) }} {{ certificationStatusDisplay(detail) }}
</el-tag> </el-tag>
<span v-if="detail?.sign_platform" class="detail-platform">
签署平台{{ signPlatformDisplay(detail.sign_platform) }}
</span>
</dd> </dd>
<template v-if="detail.failure_reason"> <template v-if="detail.failure_reason">
<dt>失败原因</dt> <dt>失败原因</dt>
@@ -363,12 +374,34 @@ const CERTIFICATION_STATUS_LABELS = {
completed: '已完成', completed: '已完成',
} }
function certificationStatusDisplay(certificationStatus) { function signPlatformDisplay(platform) {
if (!certificationStatus) return '-' if (!platform) return '-'
return CERTIFICATION_STATUS_LABELS[certificationStatus] || certificationStatus if (platform === 'esign') return 'e签宝'
if (platform === 'fadada') return '法大大'
return platform
}
/** 审核已通过但仍等用户选平台时,状态机仍是 info_pending_review用审核字段辅助展示 */
function certificationStatusDisplay(row) {
if (!row) return '-'
if (
row.certification_status === 'info_pending_review' &&
row.manual_review_status === 'approved'
) {
return '待选平台'
}
const status = typeof row === 'string' ? row : row.certification_status
if (!status) return '-'
return CERTIFICATION_STATUS_LABELS[status] || status
} }
function statusTagType(row) { function statusTagType(row) {
if (
row?.certification_status === 'info_pending_review' &&
row?.manual_review_status === 'approved'
) {
return 'success'
}
const status = row?.certification_status const status = row?.certification_status
const m = { const m = {
pending: 'info', pending: 'info',
@@ -386,12 +419,14 @@ function statusTagType(row) {
} }
/** /**
* 是否展示通过/拒绝:须当前认证为待审核,且本条提交记录为「校验通过」(verified)。 * 是否展示通过/拒绝:须当前认证为待审核、人工审核尚未通过,且本条为「校验通过」(verified)。
* 历史失败记录 (failed) 与认证状态无关字段共用同一 certification_status否则会误显按钮。
*/ */
function canShowApproveReject(row) { function canShowApproveReject(row) {
if (!row) return false if (!row) return false
if (row.certification_status !== 'info_pending_review') return false if (row.certification_status !== 'info_pending_review') return false
if (row.manual_review_status === 'approved' || row.manual_review_status === 'rejected') {
return false
}
return row.status === 'verified' return row.status === 'verified'
} }
@@ -666,6 +701,11 @@ onBeforeUnmount(() => {
line-height: 1.5; line-height: 1.5;
word-break: break-word; word-break: break-word;
} }
.detail-platform {
margin-left: 10px;
font-size: 13px;
color: #64748b;
}
.detail-mono { .detail-mono {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-size: 12px; font-size: 12px;

View File

@@ -15,13 +15,13 @@
</template> </template>
<script setup> <script setup>
import { Check } from '@element-plus/icons-vue' import { Check } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const scene = ref('auth') // auth: 企业认证, sign: 合同签署 const scene = ref('auth') // auth: 企业认证, sign: 合同签署
// 解析路由参数 const CERTIFICATION_PAGE = '/profile/certification'
onMounted(() => { onMounted(() => {
const paramScene = route.params.scene const paramScene = route.params.scene
if (paramScene === 'sign') { if (paramScene === 'sign') {
@@ -29,8 +29,37 @@ onMounted(() => {
} else { } else {
scene.value = 'auth' scene.value = 'auth'
} }
// 向父iframe发送消息 const platform = route.params.platform || ''
window.parent.postMessage({ type: 'CHECK_STATUS', scene: scene.value }, '*')
// 企业认证完成:跳出 iframe顶层回到入驻页法大大 redirect 常发生在 iframe 内)
if (scene.value === 'auth') {
const target = `${window.location.origin}${CERTIFICATION_PAGE}${window.location.search || ''}`
if (window.top && window.top !== window.self) {
window.top.location.href = target
return
}
window.location.replace(CERTIFICATION_PAGE + (window.location.search || ''))
return
}
// 签署完成:同样顶层跳出到入驻页,由主页 confirmSign 推进到「完成」
if (scene.value === 'sign') {
const qs = new URLSearchParams(window.location.search || '')
qs.set('signCallback', '1')
if (platform) qs.set('platform', String(platform))
const target = `${window.location.origin}${CERTIFICATION_PAGE}?${qs.toString()}`
if (window.top && window.top !== window.self) {
window.top.location.href = target
return
}
window.location.replace(`${CERTIFICATION_PAGE}?${qs.toString()}`)
return
}
window.parent.postMessage(
{ type: 'CHECK_STATUS', scene: scene.value, platform },
'*'
)
}) })
const successText = computed(() => const successText = computed(() =>
@@ -39,7 +68,7 @@ const successText = computed(() =>
const descText = computed(() => const descText = computed(() =>
scene.value === 'sign' scene.value === 'sign'
? '合同已签署,正在为您确认签署状态...' ? '合同已签署,正在为您确认签署状态...'
: '企业认证已完成,正在为您确认认证状态...' : '企业认证已完成,正在跳转入驻页面...'
) )
</script> </script>

View File

@@ -64,7 +64,17 @@
<el-icon class="mr-2"> <el-icon class="mr-2">
<DocumentTextIcon /> <DocumentTextIcon />
</el-icon> </el-icon>
查看合同 查看已签合同
</el-button>
<el-button
v-if="contractUrl"
size="large"
tag="a"
:href="contractUrl"
target="_blank"
class="action-btn secondary-btn"
>
下载签署文档
</el-button> </el-button>
<el-button size="large" @click="goToProfile" class="action-btn secondary-btn"> <el-button size="large" @click="goToProfile" class="action-btn secondary-btn">
<el-icon class="mr-2"> <el-icon class="mr-2">

View File

@@ -14,10 +14,31 @@
</div> </div>
</template> </template>
<div class="contract-preview-section"> <div class="contract-preview-section">
<div v-if="contractUrl" class="pdf-viewer-wrapper"> <!-- 法大大EUI 预览页 iframeget-preview-url不用 PDF 直链 -->
<div v-if="previewMode === 'iframe'" class="preview-iframe-wrapper">
<iframe
v-if="activePreviewUrl"
:src="activePreviewUrl"
class="preview-iframe"
title="合同预览"
frameborder="0"
@load="previewLoading = false"
/>
<div v-if="previewLoading || refreshing" class="preview-loading">
<div class="loading-spinner"></div>
<p>{{ refreshing ? '正在获取预览...' : '正在加载预览...' }}</p>
</div>
<div v-if="previewError" class="preview-error">
<p>{{ previewError }}</p>
<el-button type="primary" size="small" @click="loadPreview">重新获取预览</el-button>
</div>
</div>
<!-- e签宝等PDF 直链 -->
<div v-else-if="activePreviewUrl" class="pdf-viewer-wrapper">
<vue-pdf-embed <vue-pdf-embed
ref="pdfRef" ref="pdfRef"
:source="contractUrl" :source="activePreviewUrl"
:page="page" :page="page"
:scale="pdfScale" :scale="pdfScale"
@loaded="handleLoaded" @loaded="handleLoaded"
@@ -31,12 +52,18 @@
<el-button size="large" @click="fullscreen = true"> 全屏预览 </el-button> <el-button size="large" @click="fullscreen = true"> 全屏预览 </el-button>
</div> </div>
</div> </div>
<el-button type="primary" @click="emit('start-sign')" :loading="props.loading">
<div v-else-if="!refreshing" class="preview-empty">
<p>暂无预览内容</p>
<el-button type="primary" size="small" @click="loadPreview">获取预览</el-button>
</div>
<el-button type="primary" @click="emit('start-sign')" :loading="props.loading" class="sign-btn">
<el-icon class="mr-2"><DocumentTextIcon /></el-icon> <el-icon class="mr-2"><DocumentTextIcon /></el-icon>
开始签署 立即签署
</el-button> </el-button>
</div> </div>
<!-- 极简全屏预览弹窗无顶部栏 -->
<el-dialog <el-dialog
v-model="fullscreen" v-model="fullscreen"
fullscreen fullscreen
@@ -49,7 +76,7 @@
> >
<div class="fullscreen-pdf-simple"> <div class="fullscreen-pdf-simple">
<vue-pdf-embed <vue-pdf-embed
:source="contractUrl" :source="activePreviewUrl"
:page="page" :page="page"
:scale="fullscreenScale" :scale="fullscreenScale"
class="fullscreen-pdf-embed" class="fullscreen-pdf-embed"
@@ -66,16 +93,24 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'
import { DocumentTextIcon } from '@heroicons/vue/24/outline' import { DocumentTextIcon } from '@heroicons/vue/24/outline'
import VuePdfEmbed from 'vue-pdf-embed' import VuePdfEmbed from 'vue-pdf-embed'
import { ElMessage } from 'element-plus'
import { certificationApi } from '@/api'
const props = defineProps({ const props = defineProps({
contractUrl: String, contractUrl: { type: String, default: '' },
loading: Boolean loading: { type: Boolean, default: false },
}) })
const emit = defineEmits(['start-sign']) const emit = defineEmits(['start-sign'])
const activePreviewUrl = ref('')
const previewMode = ref('iframe')
const previewLoading = ref(true)
const refreshing = ref(false)
const previewError = ref('')
const pdfRef = ref(null) const pdfRef = ref(null)
const page = ref(1) const page = ref(1)
const pageCount = ref(0) const pageCount = ref(0)
@@ -86,6 +121,41 @@ const fullscreenScale = ref(1.7)
function handleLoaded(pdf) { function handleLoaded(pdf) {
pageCount.value = pdf.numPages pageCount.value = pdf.numPages
} }
const loadPreview = async () => {
refreshing.value = true
previewError.value = ''
previewLoading.value = true
try {
const res = await certificationApi.refreshContractPreviewUrl()
const url = res?.data?.preview_url || ''
const mode = res?.data?.preview_mode || 'iframe'
if (!url) {
throw new Error('未获取到预览链接')
}
previewMode.value = mode
activePreviewUrl.value = url
} catch (e) {
previewError.value = e?.message || '获取预览失败'
// 兼容旧数据:仅当返回的是非法大大 PDF 直链时可回退
if (props.contractUrl && !String(props.contractUrl).includes('fadada.com')) {
previewMode.value = 'pdf'
activePreviewUrl.value = props.contractUrl
previewError.value = ''
} else {
ElMessage.error(previewError.value)
}
} finally {
refreshing.value = false
if (previewMode.value === 'pdf') {
previewLoading.value = false
}
}
}
onMounted(() => {
loadPreview()
})
</script> </script>
<style scoped> <style scoped>
@@ -126,155 +196,102 @@ function handleLoaded(pdf) {
font-size: 14px; font-size: 14px;
color: #64748b; color: #64748b;
margin: 0; margin: 0;
font-weight: 500;
} }
.contract-preview-section { .contract-preview-section {
padding: 32px 0; display: flex;
text-align: center; flex-direction: column;
gap: 20px;
align-items: stretch;
} }
.preview-iframe-wrapper,
.pdf-viewer-wrapper { .pdf-viewer-wrapper {
position: relative;
width: 100%;
height: 640px;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
background: #fff;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-loading,
.preview-error,
.preview-empty {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 16px; justify-content: center;
gap: 12px;
background: rgba(255, 255, 255, 0.92);
color: #64748b;
z-index: 2;
}
.preview-error {
color: #b91c1c;
}
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
.pdf-embed { .pdf-embed {
width: 100%; width: 100%;
max-width: 600px; height: calc(100% - 56px);
min-height: 600px; overflow: auto;
max-height: 600px;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
background: #fafbfc;
overflow-y: auto;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.pdf-embed::-webkit-scrollbar {
width: 8px;
}
.pdf-embed::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.pdf-embed::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
} }
.pdf-controls { .pdf-controls {
margin: 24px 0 0 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px;
justify-content: center; justify-content: center;
font-size: 16px; gap: 12px;
} height: 56px;
.pdf-btn { border-top: 1px solid #e2e8f0;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #fff;
font-weight: 600;
border: none;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
min-width: 110px;
height: 44px;
font-size: 16px;
}
.pdf-btn:disabled {
background: #e2e8f0;
color: #94a3b8;
box-shadow: none;
}
.pdf-btn:hover:not(:disabled) {
background: #2563eb;
color: #fff;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.18);
} }
.pdf-page-info { .pdf-page-info {
color: #64748b; color: #475569;
font-size: 16px; font-size: 14px;
font-weight: 500;
} }
.contract-sign-btn { .sign-btn {
min-width: 220px; align-self: center;
height: 52px; min-width: 180px;
font-size: 18px;
font-weight: 700;
border-radius: 14px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.18);
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
margin-top: 32px;
}
.contract-sign-btn:hover:not(:disabled) {
background: #2563eb;
color: #fff;
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.22);
}
.contract-sign-btn:disabled {
background: #e2e8f0;
color: #94a3b8;
box-shadow: none;
}
.fullscreen-dialog >>> .el-dialog__body {
padding: 0;
} }
.fullscreen-pdf-simple { .fullscreen-pdf-simple {
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; background: #0f172a;
justify-content: center;
background: #fafbfc;
overflow: auto;
} }
.fullscreen-pdf-embed { .fullscreen-pdf-embed {
width: 100vw; flex: 1;
max-width: 1000px;
height: calc(100vh - 116px);
border: none;
border-radius: 0;
background: #fff;
overflow: auto; overflow: auto;
box-shadow: none;
} }
.fullscreen-pdf-controls { .fullscreen-pdf-controls {
width: 100%;
height: 68px;
background-color: #e2e8f0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px;
justify-content: center; justify-content: center;
font-size: 18px; gap: 12px;
} padding: 12px;
.fullscreen-close-btn { background: #1e293b;
min-width: 120px;
height: 44px;
font-size: 16px;
font-weight: 700;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.18);
margin-left: 32px;
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
}
.fullscreen-close-btn:hover {
background: #2563eb;
color: #fff; color: #fff;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.22); }
@media (max-width: 768px) {
.preview-iframe-wrapper,
.pdf-viewer-wrapper {
height: 420px;
}
} }
</style> </style>

View File

@@ -15,19 +15,24 @@
</template> </template>
<div class="contract-content"> <div class="contract-content">
<!-- 合同签署iframe --> <!-- 合同签署iframe必须使用长链 EmbedURL短链不可嵌入 -->
<div class="contract-iframe-section"> <div class="contract-iframe-section">
<div class="iframe-container"> <div class="iframe-container">
<iframe <iframe
:src="iframeUrl" v-if="activeIframeUrl"
:src="activeIframeUrl"
frameborder="0" frameborder="0"
class="contract-iframe" class="contract-iframe"
title="合同签署" title="合同签署"
@load="handleIframeLoad" @load="handleIframeLoad"
></iframe> ></iframe>
<div v-if="iframeLoading" class="iframe-loading"> <div v-if="iframeLoading || refreshing" class="iframe-loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p>正在加载合同签署页面...</p> <p>{{ refreshing ? '正在获取签署页面...' : '正在加载合同签署页面...' }}</p>
</div>
<div v-if="refreshError" class="iframe-error">
<p>{{ refreshError }}</p>
<el-button type="primary" size="small" @click="refreshEmbedUrl">重新获取链接</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -36,29 +41,68 @@
</template> </template>
<script setup> <script setup>
import { import { ref, watch, onMounted } from 'vue'
DocumentTextIcon import { DocumentTextIcon } from '@heroicons/vue/24/outline'
} from '@heroicons/vue/24/outline' import { ElMessage } from 'element-plus'
import { certificationApi } from '@/api'
defineProps({ const props = defineProps({
companyName: { companyName: {
type: String, type: String,
default: '' default: '',
}, },
iframeUrl: { iframeUrl: {
type: String, type: String,
default: '' default: '',
} },
}) })
// 合同签署状态 const activeIframeUrl = ref('')
const contractCompleted = ref(false)
const iframeLoading = ref(true) const iframeLoading = ref(true)
const refreshing = ref(false)
const refreshError = ref('')
// iframe加载完成回调
const handleIframeLoad = () => { const handleIframeLoad = () => {
iframeLoading.value = false iframeLoading.value = false
} }
const refreshEmbedUrl = async () => {
refreshing.value = true
refreshError.value = ''
iframeLoading.value = true
try {
const res = await certificationApi.refreshContractSignUrl()
const url = res?.data?.contract_sign_url || ''
if (!url) {
throw new Error('未获取到可嵌入的签署链接')
}
activeIframeUrl.value = url
} catch (e) {
refreshError.value = e?.message || '获取签署链接失败,请重试'
// 兼容:刷新失败时若父组件已有链接则暂用(可能是短链,法大大会失败)
if (!activeIframeUrl.value && props.iframeUrl) {
activeIframeUrl.value = props.iframeUrl
}
ElMessage.error(refreshError.value)
} finally {
refreshing.value = false
}
}
onMounted(() => {
refreshEmbedUrl()
})
watch(
() => props.iframeUrl,
(url) => {
// 申请合同刚返回的长链可先展示,随后仍会以 refresh 为准
if (url && !activeIframeUrl.value) {
activeIframeUrl.value = url
}
},
{ immediate: true },
)
</script> </script>
<style scoped> <style scoped>
@@ -168,6 +212,21 @@ const handleIframeLoad = () => {
font-weight: 500; font-weight: 500;
} }
.iframe-error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(255, 255, 255, 0.95);
z-index: 2;
padding: 24px;
text-align: center;
color: #b91c1c;
}
.iframe-actions { .iframe-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -30,6 +30,7 @@
<script setup> <script setup>
import { ClockIcon } from '@heroicons/vue/24/outline' import { ClockIcon } from '@heroicons/vue/24/outline'
import { onMounted, onUnmounted, ref } from 'vue'
defineProps({ defineProps({
certificationData: { certificationData: {

View File

@@ -0,0 +1,152 @@
<template>
<div class="platform-select">
<div class="platform-select__header">
<h2 class="platform-select__title">选择签署平台</h2>
<p class="platform-select__desc">
企业信息已审核通过请选择企业认证与合同签署使用的平台选定后不可更改
</p>
</div>
<div class="platform-select__grid">
<button
v-for="item in platforms"
:key="item.platform"
type="button"
class="platform-card"
:class="{ 'is-active': selected === item.platform, 'is-disabled': !item.available }"
:disabled="!item.available || submitting"
@click="selected = item.platform"
>
<span class="platform-card__name">{{ item.name }}</span>
<span class="platform-card__desc">{{ item.description }}</span>
</button>
</div>
<div class="platform-select__actions">
<el-button type="primary" size="large" :loading="submitting" :disabled="!selected" @click="confirm">
确认并开始企业认证
</el-button>
</div>
</div>
</template>
<script setup>
import { certificationApi } from '@/api/index.js'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
const emit = defineEmits(['selected'])
const platforms = ref([])
const selected = ref('')
const submitting = ref(false)
onMounted(async () => {
try {
const res = await certificationApi.getSignPlatforms()
platforms.value = res?.data?.platforms || []
if (platforms.value.length === 1) {
selected.value = platforms.value[0].platform
}
} catch (e) {
ElMessage.error(e?.message || '获取签署平台失败')
}
})
async function confirm() {
if (!selected.value) return
submitting.value = true
try {
const res = await certificationApi.selectSignPlatform({ sign_platform: selected.value })
ElMessage.success('已选择签署平台')
emit('selected', res?.data)
} catch (e) {
ElMessage.error(e?.message || '选择签署平台失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.platform-select {
max-width: 720px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.platform-select__header {
margin-bottom: 28px;
text-align: center;
}
.platform-select__title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 600;
color: #1f2937;
}
.platform-select__desc {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #6b7280;
}
.platform-select__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.platform-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 20px;
text-align: left;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.platform-card:hover:not(.is-disabled) {
border-color: #2563eb;
}
.platform-card.is-active {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.platform-card.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.platform-card__name {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.platform-card__desc {
font-size: 13px;
line-height: 1.5;
color: #6b7280;
}
.platform-select__actions {
margin-top: 28px;
text-align: center;
}
@media (max-width: 640px) {
.platform-select__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -73,7 +73,12 @@
:certification-data="certificationData" :certification-data="certificationData"
:submit-time="manualReviewSubmitTime" :submit-time="manualReviewSubmitTime"
:company-name="enterpriseForm.companyName" :company-name="enterpriseForm.companyName"
@refresh="getCertificationDetails" @refresh="() => getCertificationDetails({ silent: true })"
/>
<PlatformSelect
v-if="currentStep === 'platform_select'"
@selected="handlePlatformSelected"
/> />
<EnterpriseVerify <EnterpriseVerify
@@ -86,7 +91,7 @@
<div v-if="currentStep === 'contract_sign'"> <div v-if="currentStep === 'contract_sign'">
<ContractPreview <ContractPreview
v-if="certificationData?.status === 'enterprise_verified'" v-if="certificationData?.status === 'enterprise_verified'"
:contract-url="stepMeta.ContractURL" :contract-url="stepMeta.contract_url || stepMeta.ContractURL"
:loading="contractSignLoading" :loading="contractSignLoading"
@start-sign="handleStartContractSign" @start-sign="handleStartContractSign"
/> />
@@ -143,7 +148,9 @@ import ContractSign from './components/ContractSign.vue'
import EnterpriseInfo from './components/EnterpriseInfo.vue' import EnterpriseInfo from './components/EnterpriseInfo.vue'
import EnterpriseVerify from './components/EnterpriseVerify.vue' import EnterpriseVerify from './components/EnterpriseVerify.vue'
import ManualReviewPending from './components/ManualReviewPending.vue' import ManualReviewPending from './components/ManualReviewPending.vue'
import PlatformSelect from './components/PlatformSelect.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const certificationSteps = [ const certificationSteps = [
{ {
@@ -158,6 +165,12 @@ const certificationSteps = [
description: '等待管理员审核企业信息', description: '等待管理员审核企业信息',
icon: ClockIcon, icon: ClockIcon,
}, },
{
key: 'platform_select',
title: '选择平台',
description: '选择 e签宝 或法大大',
icon: DocumentTextIcon,
},
{ {
key: 'enterprise_verify', key: 'enterprise_verify',
title: '企业认证', title: '企业认证',
@@ -222,6 +235,58 @@ const devCurrentStep = ref('enterprise_info')
// 合同签署加载状态 // 合同签署加载状态
const contractSignLoading = ref(false) const contractSignLoading = ref(false)
// 合同签署状态轮询:进入 contract_applied 后定时拉详情,触发后端 checkAndUpdateSignStatus
// 状态一旦变为 contract_signed / completed 或失败状态立即停止
let signPollTimer = null
const SIGN_POLL_INTERVAL = 5000 // 5 秒/次
const signPolling = ref(false)
const stopSignStatusPolling = () => {
if (signPollTimer) {
clearTimeout(signPollTimer)
signPollTimer = null
}
signPolling.value = false
}
const startSignStatusPolling = () => {
// 已在轮询则不重复启动
if (signPollTimer) return
signPolling.value = true
const tick = async () => {
// 兜底:状态已离开 contract_applied 则停止
const status = certificationData.value?.status
if (status !== 'contract_applied') {
stopSignStatusPolling()
return
}
try {
// 静默拉详情GetCertification 内会自动调用后端 checkAndUpdateSignStatus
const res = await certificationApi.getCertificationDetails()
if (res?.data) {
certificationData.value = res.data
stepMeta.value = res.data?.metadata || {}
const newStatus = res.data?.status
// 签署完成或失败:交给 setCurrentStepByStatus 切页,并停止轮询
if (newStatus === 'contract_signed' || newStatus === 'completed' ||
newStatus === 'contract_rejected' || newStatus === 'contract_expired') {
stopSignStatusPolling()
await setCurrentStepByStatus()
return
}
}
} catch (e) {
console.warn('合同签署状态轮询失败:', e)
}
// 还在轮询则排下一次
if (signPolling.value) {
signPollTimer = setTimeout(tick, SIGN_POLL_INTERVAL)
}
}
signPollTimer = setTimeout(tick, SIGN_POLL_INTERVAL)
}
// 只补 enterprise_verify 所需 auth_url不改动原有页面展示数据结构 // 只补 enterprise_verify 所需 auth_url不改动原有页面展示数据结构
// 轮询策略:最多 5 秒,每秒 1 次(最多 5 次) // 轮询策略:最多 5 秒,每秒 1 次(最多 5 次)
const pollAuthUrlOnly = async (maxTries = 5) => { const pollAuthUrlOnly = async (maxTries = 5) => {
@@ -280,6 +345,19 @@ const handleEnterpriseSubmit = async (payload) => {
} }
} }
const handlePlatformSelected = async (data) => {
if (data) {
certificationData.value = data
stepMeta.value = data.metadata || {}
}
await getCertificationDetails()
if (stepMeta.value?.auth_url) {
currentStep.value = 'enterprise_verify'
} else if (certificationData.value) {
await setCurrentStepByStatus()
}
}
const handleEnterpriseVerifySubmit = async () => { const handleEnterpriseVerifySubmit = async () => {
try { try {
ElMessage.success('企业认证成功') ElMessage.success('企业认证成功')
@@ -292,10 +370,24 @@ const handleEnterpriseVerifySubmit = async () => {
const handleStartContractSign = async () => { const handleStartContractSign = async () => {
try { try {
contractSignLoading.value = true contractSignLoading.value = true
// 调用后端接口,发起合同签署 const res = await certificationApi.applyContract()
await certificationApi.applyContract() const embedUrl = res?.data?.contract_sign_url
if (embedUrl) {
stepMeta.value = {
...(stepMeta.value || {}),
contract_sign_url: embedUrl,
contract_sign_short_url: res?.data?.contract_sign_short_url || '',
}
}
ElMessage.success('合同签署流程已启动') ElMessage.success('合同签署流程已启动')
await getCertificationDetails() await getCertificationDetails()
// details 库内可能是短链,保留刚申请到的 iframe 长链
if (embedUrl) {
stepMeta.value = {
...(stepMeta.value || {}),
contract_sign_url: embedUrl,
}
}
} catch (error) { } catch (error) {
ElMessage.error(error?.message || '发起签署失败,请稍后重试') ElMessage.error(error?.message || '发起签署失败,请稍后重试')
} finally { } finally {
@@ -355,10 +447,12 @@ const goToProfile = () => {
router.push('/profile') router.push('/profile')
} }
// 获取认证详情 // 获取认证详情silent=true 时不展示全页 loading用于人工审核轮询
const getCertificationDetails = async () => { // 返回是否成功,便于调用方在失败时不要把步骤重置到「填写企业信息」
const getCertificationDetails = async (opts = {}) => {
const silent = opts?.silent === true
try { try {
loading.value = true if (!silent) loading.value = true
const res = await certificationApi.getCertificationDetails() const res = await certificationApi.getCertificationDetails()
certificationData.value = res.data certificationData.value = res.data
console.log('s', res) console.log('s', res)
@@ -391,9 +485,15 @@ const getCertificationDetails = async () => {
// 步骤特定元数据 // 步骤特定元数据
stepMeta.value = res.data?.metadata || {} stepMeta.value = res.data?.metadata || {}
await setCurrentStepByStatus() await setCurrentStepByStatus()
return true
} catch (error) { } catch (error) {
console.error('获取认证详情失败:', error) console.error('获取认证详情失败:', error)
ElMessage.error('获取认证详情失败') ElMessage.error('获取认证详情失败')
// 已有数据时保持当前步骤,避免失败时默认落到「填写企业信息」
if (certificationData.value) {
await setCurrentStepByStatus()
}
return false
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -414,20 +514,43 @@ const setCurrentStepByStatus = async () => {
currentStep.value = 'enterprise_info' currentStep.value = 'enterprise_info'
break break
case 'info_pending_review': case 'info_pending_review':
if (certificationData.value?.metadata?.need_select_platform) {
currentStep.value = 'platform_select'
} else {
currentStep.value = 'manual_review' currentStep.value = 'manual_review'
}
break break
case 'info_submitted': case 'info_submitted':
if (!certificationData.value?.metadata?.auth_url && certificationData.value?.metadata?.need_select_platform) {
currentStep.value = 'platform_select'
} else {
currentStep.value = 'enterprise_verify' currentStep.value = 'enterprise_verify'
}
break break
case 'enterprise_verified': case 'enterprise_verified':
// 无签署任务时不应进入签署合同(创建失败脏数据),留在选平台
if (!certificationData.value?.metadata?.esign_flow_id && !certificationData.value?.metadata?.sign_task_ready) {
currentStep.value = 'platform_select'
} else {
currentStep.value = 'contract_sign'
}
break
case 'contract_applied': case 'contract_applied':
currentStep.value = 'contract_sign'
// 进入签署中:启动轮询,自动推进到完成/失败
startSignStatusPolling()
break
case 'contract_rejected': case 'contract_rejected':
case 'contract_expired': case 'contract_expired':
currentStep.value = 'contract_sign' currentStep.value = 'contract_sign'
// 失败终态:停止轮询
stopSignStatusPolling()
break break
case 'contract_signed': case 'contract_signed':
case 'completed': case 'completed':
currentStep.value = 'completed' currentStep.value = 'completed'
// 完成:停止轮询
stopSignStatusPolling()
// 认证完成时重新获取用户信息 // 认证完成时重新获取用户信息
if (previousStatus !== 'completed') { if (previousStatus !== 'completed') {
try { try {
@@ -528,12 +651,37 @@ async function handleIframeMessage(event) {
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
// 若法大大等第三方在 iframe 内回跳到本页,立即顶层打开入驻页
if (window.top && window.top !== window.self) {
const target = `${window.location.origin}/profile/certification${window.location.search || ''}`
window.top.location.href = target
return
}
window.addEventListener('message', handleIframeMessage) window.addEventListener('message', handleIframeMessage)
getCertificationDetails() getCertificationDetails().then(async () => {
// 签署完成顶层回跳:推进状态到 completed
if (route.query?.signCallback) {
try {
await certificationApi.confirmSign()
await getCertificationDetails()
} catch (e) {
console.warn('签署回调确认失败,将轮询状态', e)
pollCertificationDetails(8)
}
}
})
// 从法大大 redirect 带回的 queryauthResult / clientCorpId 等)时主动同步状态
const q = route.query || {}
if (q.authResult || q.openCorpId || q.clientCorpId) {
pollCertificationDetails(8)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('message', handleIframeMessage) window.removeEventListener('message', handleIframeMessage)
stopSignStatusPolling()
}) })
</script> </script>

View File

@@ -320,6 +320,11 @@ const routes = [
component: () => import('@/pages/certification/IframeCallback.vue'), component: () => import('@/pages/certification/IframeCallback.vue'),
meta: { requiresAuth: false }, meta: { requiresAuth: false },
}, },
{
path: '/certification/callback/:platform/:scene',
component: () => import('@/pages/certification/IframeCallback.vue'),
meta: { requiresAuth: false },
},
{ {
path: '/legal/:doc', path: '/legal/:doc',
name: 'LegalReader', name: 'LegalReader',