addf
This commit is contained in:
@@ -178,6 +178,12 @@ export const certificationApi = {
|
||||
// 申请合同签署
|
||||
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'),
|
||||
|
||||
@@ -187,6 +193,12 @@ export const certificationApi = {
|
||||
// 确认认证状态
|
||||
confirmAuth: (data) => request.post('/certifications/confirm-auth', data),
|
||||
|
||||
// 可选签署平台列表
|
||||
getSignPlatforms: () => request.get('/certifications/sign-platforms'),
|
||||
|
||||
// 选择签署平台并生成企业认证链接
|
||||
selectSignPlatform: (data) => request.post('/certifications/select-sign-platform', data),
|
||||
|
||||
// OCR营业执照识别
|
||||
recognizeBusinessLicense: (formData) => request.post('/certifications/ocr/business-license', formData, {
|
||||
headers: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="certification-reviews-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">企业审核</h1>
|
||||
<p class="page-subtitle">审核用户提交的企业信息,通过后可进入企业认证流程</p>
|
||||
<p class="page-subtitle">审核用户提交的企业信息;通过后由用户选择签署平台(e签宝/法大大)再继续认证</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
@@ -59,13 +59,18 @@
|
||||
<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="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 }">
|
||||
<el-tag :type="statusTagType(row)" size="small">
|
||||
{{ certificationStatusDisplay(row?.certification_status) }}
|
||||
{{ certificationStatusDisplay(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</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">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button>
|
||||
@@ -83,10 +88,13 @@
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-company">{{ row.company_name || '-' }}</div>
|
||||
<el-tag :type="statusTagType(row)" size="small">
|
||||
{{ certificationStatusDisplay(row?.certification_status) }}
|
||||
{{ certificationStatusDisplay(row) }}
|
||||
</el-tag>
|
||||
</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_phone || '-' }}
|
||||
@@ -162,8 +170,11 @@
|
||||
<dt>认证状态</dt>
|
||||
<dd>
|
||||
<el-tag :type="statusTagType(detail)" size="small">
|
||||
{{ certificationStatusDisplay(detail?.certification_status) }}
|
||||
{{ certificationStatusDisplay(detail) }}
|
||||
</el-tag>
|
||||
<span v-if="detail?.sign_platform" class="detail-platform">
|
||||
签署平台:{{ signPlatformDisplay(detail.sign_platform) }}
|
||||
</span>
|
||||
</dd>
|
||||
<template v-if="detail.failure_reason">
|
||||
<dt>失败原因</dt>
|
||||
@@ -363,12 +374,34 @@ const CERTIFICATION_STATUS_LABELS = {
|
||||
completed: '已完成',
|
||||
}
|
||||
|
||||
function certificationStatusDisplay(certificationStatus) {
|
||||
if (!certificationStatus) return '-'
|
||||
return CERTIFICATION_STATUS_LABELS[certificationStatus] || certificationStatus
|
||||
function signPlatformDisplay(platform) {
|
||||
if (!platform) return '-'
|
||||
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) {
|
||||
if (
|
||||
row?.certification_status === 'info_pending_review' &&
|
||||
row?.manual_review_status === 'approved'
|
||||
) {
|
||||
return 'success'
|
||||
}
|
||||
const status = row?.certification_status
|
||||
const m = {
|
||||
pending: 'info',
|
||||
@@ -386,12 +419,14 @@ function statusTagType(row) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否展示通过/拒绝:须当前认证为待审核,且本条提交记录为「校验通过」(verified)。
|
||||
* 历史失败记录 (failed) 与认证状态无关字段共用同一 certification_status,否则会误显按钮。
|
||||
* 是否展示通过/拒绝:须当前认证为待审核、人工审核尚未通过,且本条为「校验通过」(verified)。
|
||||
*/
|
||||
function canShowApproveReject(row) {
|
||||
if (!row) 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'
|
||||
}
|
||||
|
||||
@@ -666,6 +701,11 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-platform {
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.detail-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { Check } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const scene = ref('auth') // auth: 企业认证, sign: 合同签署
|
||||
|
||||
// 解析路由参数
|
||||
const CERTIFICATION_PAGE = '/profile/certification'
|
||||
|
||||
onMounted(() => {
|
||||
const paramScene = route.params.scene
|
||||
if (paramScene === 'sign') {
|
||||
@@ -29,8 +29,37 @@ onMounted(() => {
|
||||
} else {
|
||||
scene.value = 'auth'
|
||||
}
|
||||
// 向父iframe发送消息
|
||||
window.parent.postMessage({ type: 'CHECK_STATUS', scene: scene.value }, '*')
|
||||
const platform = route.params.platform || ''
|
||||
|
||||
// 企业认证完成:跳出 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(() =>
|
||||
@@ -39,7 +68,7 @@ const successText = computed(() =>
|
||||
const descText = computed(() =>
|
||||
scene.value === 'sign'
|
||||
? '合同已签署,正在为您确认签署状态...'
|
||||
: '企业认证已完成,正在为您确认认证状态...'
|
||||
: '企业认证已完成,正在跳转入驻页面...'
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -64,7 +64,17 @@
|
||||
<el-icon class="mr-2">
|
||||
<DocumentTextIcon />
|
||||
</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 size="large" @click="goToProfile" class="action-btn secondary-btn">
|
||||
<el-icon class="mr-2">
|
||||
|
||||
@@ -14,10 +14,31 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="contract-preview-section">
|
||||
<div v-if="contractUrl" class="pdf-viewer-wrapper">
|
||||
<!-- 法大大:EUI 预览页 iframe(get-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
|
||||
ref="pdfRef"
|
||||
:source="contractUrl"
|
||||
:source="activePreviewUrl"
|
||||
:page="page"
|
||||
:scale="pdfScale"
|
||||
@loaded="handleLoaded"
|
||||
@@ -31,12 +52,18 @@
|
||||
<el-button size="large" @click="fullscreen = true"> 全屏预览 </el-button>
|
||||
</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-button>
|
||||
</div>
|
||||
<!-- 极简全屏预览弹窗,无顶部栏 -->
|
||||
|
||||
<el-dialog
|
||||
v-model="fullscreen"
|
||||
fullscreen
|
||||
@@ -49,7 +76,7 @@
|
||||
>
|
||||
<div class="fullscreen-pdf-simple">
|
||||
<vue-pdf-embed
|
||||
:source="contractUrl"
|
||||
:source="activePreviewUrl"
|
||||
:page="page"
|
||||
:scale="fullscreenScale"
|
||||
class="fullscreen-pdf-embed"
|
||||
@@ -66,16 +93,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { DocumentTextIcon } from '@heroicons/vue/24/outline'
|
||||
import VuePdfEmbed from 'vue-pdf-embed'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { certificationApi } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
contractUrl: String,
|
||||
loading: Boolean
|
||||
contractUrl: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
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 page = ref(1)
|
||||
const pageCount = ref(0)
|
||||
@@ -86,6 +121,41 @@ const fullscreenScale = ref(1.7)
|
||||
function handleLoaded(pdf) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -126,155 +196,102 @@ function handleLoaded(pdf) {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.contract-preview-section {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.preview-iframe-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;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
min-height: 600px;
|
||||
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;
|
||||
height: calc(100% - 56px);
|
||||
overflow: auto;
|
||||
}
|
||||
.pdf-controls {
|
||||
margin: 24px 0 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pdf-btn {
|
||||
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);
|
||||
gap: 12px;
|
||||
height: 56px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.pdf-page-info {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contract-sign-btn {
|
||||
min-width: 220px;
|
||||
height: 52px;
|
||||
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;
|
||||
.sign-btn {
|
||||
align-self: center;
|
||||
min-width: 180px;
|
||||
}
|
||||
.fullscreen-pdf-simple {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafbfc;
|
||||
overflow: auto;
|
||||
background: #0f172a;
|
||||
}
|
||||
.fullscreen-pdf-embed {
|
||||
width: 100vw;
|
||||
max-width: 1000px;
|
||||
height: calc(100vh - 116px);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
.fullscreen-pdf-controls {
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
background-color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.fullscreen-close-btn {
|
||||
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;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #1e293b;
|
||||
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>
|
||||
|
||||
@@ -15,19 +15,24 @@
|
||||
</template>
|
||||
|
||||
<div class="contract-content">
|
||||
<!-- 合同签署iframe -->
|
||||
<!-- 合同签署iframe:必须使用长链 EmbedURL,短链不可嵌入 -->
|
||||
<div class="contract-iframe-section">
|
||||
<div class="iframe-container">
|
||||
<iframe
|
||||
:src="iframeUrl"
|
||||
v-if="activeIframeUrl"
|
||||
:src="activeIframeUrl"
|
||||
frameborder="0"
|
||||
class="contract-iframe"
|
||||
title="合同签署"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
<div v-if="iframeLoading" class="iframe-loading">
|
||||
<div v-if="iframeLoading || refreshing" class="iframe-loading">
|
||||
<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>
|
||||
@@ -36,29 +41,68 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
DocumentTextIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { DocumentTextIcon } from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { certificationApi } from '@/api'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
iframeUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
// 合同签署状态
|
||||
const contractCompleted = ref(false)
|
||||
const activeIframeUrl = ref('')
|
||||
const iframeLoading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const refreshError = ref('')
|
||||
|
||||
// iframe加载完成回调
|
||||
const handleIframeLoad = () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -168,6 +212,21 @@ const handleIframeLoad = () => {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
certificationData: {
|
||||
|
||||
152
src/pages/certification/components/PlatformSelect.vue
Normal file
152
src/pages/certification/components/PlatformSelect.vue
Normal 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>
|
||||
@@ -73,7 +73,12 @@
|
||||
:certification-data="certificationData"
|
||||
:submit-time="manualReviewSubmitTime"
|
||||
:company-name="enterpriseForm.companyName"
|
||||
@refresh="getCertificationDetails"
|
||||
@refresh="() => getCertificationDetails({ silent: true })"
|
||||
/>
|
||||
|
||||
<PlatformSelect
|
||||
v-if="currentStep === 'platform_select'"
|
||||
@selected="handlePlatformSelected"
|
||||
/>
|
||||
|
||||
<EnterpriseVerify
|
||||
@@ -86,7 +91,7 @@
|
||||
<div v-if="currentStep === 'contract_sign'">
|
||||
<ContractPreview
|
||||
v-if="certificationData?.status === 'enterprise_verified'"
|
||||
:contract-url="stepMeta.ContractURL"
|
||||
:contract-url="stepMeta.contract_url || stepMeta.ContractURL"
|
||||
:loading="contractSignLoading"
|
||||
@start-sign="handleStartContractSign"
|
||||
/>
|
||||
@@ -143,7 +148,9 @@ import ContractSign from './components/ContractSign.vue'
|
||||
import EnterpriseInfo from './components/EnterpriseInfo.vue'
|
||||
import EnterpriseVerify from './components/EnterpriseVerify.vue'
|
||||
import ManualReviewPending from './components/ManualReviewPending.vue'
|
||||
import PlatformSelect from './components/PlatformSelect.vue'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const certificationSteps = [
|
||||
{
|
||||
@@ -158,6 +165,12 @@ const certificationSteps = [
|
||||
description: '等待管理员审核企业信息',
|
||||
icon: ClockIcon,
|
||||
},
|
||||
{
|
||||
key: 'platform_select',
|
||||
title: '选择平台',
|
||||
description: '选择 e签宝 或法大大',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
key: 'enterprise_verify',
|
||||
title: '企业认证',
|
||||
@@ -222,6 +235,58 @@ const devCurrentStep = ref('enterprise_info')
|
||||
// 合同签署加载状态
|
||||
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,不改动原有页面展示数据结构
|
||||
// 轮询策略:最多 5 秒,每秒 1 次(最多 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 () => {
|
||||
try {
|
||||
ElMessage.success('企业认证成功')
|
||||
@@ -292,10 +370,24 @@ const handleEnterpriseVerifySubmit = async () => {
|
||||
const handleStartContractSign = async () => {
|
||||
try {
|
||||
contractSignLoading.value = true
|
||||
// 调用后端接口,发起合同签署
|
||||
await certificationApi.applyContract()
|
||||
const res = 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('合同签署流程已启动')
|
||||
await getCertificationDetails()
|
||||
// details 库内可能是短链,保留刚申请到的 iframe 长链
|
||||
if (embedUrl) {
|
||||
stepMeta.value = {
|
||||
...(stepMeta.value || {}),
|
||||
contract_sign_url: embedUrl,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '发起签署失败,请稍后重试')
|
||||
} finally {
|
||||
@@ -355,10 +447,12 @@ const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// 获取认证详情
|
||||
const getCertificationDetails = async () => {
|
||||
// 获取认证详情(silent=true 时不展示全页 loading,用于人工审核轮询)
|
||||
// 返回是否成功,便于调用方在失败时不要把步骤重置到「填写企业信息」
|
||||
const getCertificationDetails = async (opts = {}) => {
|
||||
const silent = opts?.silent === true
|
||||
try {
|
||||
loading.value = true
|
||||
if (!silent) loading.value = true
|
||||
const res = await certificationApi.getCertificationDetails()
|
||||
certificationData.value = res.data
|
||||
console.log('s', res)
|
||||
@@ -391,9 +485,15 @@ const getCertificationDetails = async () => {
|
||||
// 步骤特定元数据
|
||||
stepMeta.value = res.data?.metadata || {}
|
||||
await setCurrentStepByStatus()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('获取认证详情失败:', error)
|
||||
ElMessage.error('获取认证详情失败')
|
||||
// 已有数据时保持当前步骤,避免失败时默认落到「填写企业信息」
|
||||
if (certificationData.value) {
|
||||
await setCurrentStepByStatus()
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -414,20 +514,43 @@ const setCurrentStepByStatus = async () => {
|
||||
currentStep.value = 'enterprise_info'
|
||||
break
|
||||
case 'info_pending_review':
|
||||
if (certificationData.value?.metadata?.need_select_platform) {
|
||||
currentStep.value = 'platform_select'
|
||||
} else {
|
||||
currentStep.value = 'manual_review'
|
||||
}
|
||||
break
|
||||
case 'info_submitted':
|
||||
if (!certificationData.value?.metadata?.auth_url && certificationData.value?.metadata?.need_select_platform) {
|
||||
currentStep.value = 'platform_select'
|
||||
} else {
|
||||
currentStep.value = 'enterprise_verify'
|
||||
}
|
||||
break
|
||||
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':
|
||||
currentStep.value = 'contract_sign'
|
||||
// 进入签署中:启动轮询,自动推进到完成/失败
|
||||
startSignStatusPolling()
|
||||
break
|
||||
case 'contract_rejected':
|
||||
case 'contract_expired':
|
||||
currentStep.value = 'contract_sign'
|
||||
// 失败终态:停止轮询
|
||||
stopSignStatusPolling()
|
||||
break
|
||||
case 'contract_signed':
|
||||
case 'completed':
|
||||
currentStep.value = 'completed'
|
||||
// 完成:停止轮询
|
||||
stopSignStatusPolling()
|
||||
// 认证完成时重新获取用户信息
|
||||
if (previousStatus !== 'completed') {
|
||||
try {
|
||||
@@ -528,12 +651,37 @@ async function handleIframeMessage(event) {
|
||||
|
||||
// 初始化
|
||||
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)
|
||||
getCertificationDetails()
|
||||
getCertificationDetails().then(async () => {
|
||||
// 签署完成顶层回跳:推进状态到 completed
|
||||
if (route.query?.signCallback) {
|
||||
try {
|
||||
await certificationApi.confirmSign()
|
||||
await getCertificationDetails()
|
||||
} catch (e) {
|
||||
console.warn('签署回调确认失败,将轮询状态', e)
|
||||
pollCertificationDetails(8)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 从法大大 redirect 带回的 query(authResult / clientCorpId 等)时主动同步状态
|
||||
const q = route.query || {}
|
||||
if (q.authResult || q.openCorpId || q.clientCorpId) {
|
||||
pollCertificationDetails(8)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleIframeMessage)
|
||||
stopSignStatusPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -320,6 +320,11 @@ const routes = [
|
||||
component: () => import('@/pages/certification/IframeCallback.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/certification/callback/:platform/:scene',
|
||||
component: () => import('@/pages/certification/IframeCallback.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/legal/:doc',
|
||||
name: 'LegalReader',
|
||||
|
||||
Reference in New Issue
Block a user