This commit is contained in:
2026-05-27 16:57:43 +08:00
commit 8c1a09f075
195 changed files with 73506 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<template>
<div class="iframe-callback-page">
<div class="callback-content">
<div class="callback-icon">
<el-icon :size="48" color="#10b981"><Check /></el-icon>
</div>
<h2 class="callback-title">{{ successText }}</h2>
<p class="callback-desc">{{ descText }}</p>
<div class="callback-loading">
<span class="loading-spinner"></span>
<span>正在确认结果请稍候...</span>
</div>
</div>
</div>
</template>
<script setup>
import { Check } from '@element-plus/icons-vue'
const route = useRoute()
const scene = ref('auth') // auth: 企业认证, sign: 合同签署
// 解析路由参数
onMounted(() => {
const paramScene = route.params.scene
if (paramScene === 'sign') {
scene.value = 'sign'
} else {
scene.value = 'auth'
}
// 向父iframe发送消息
window.parent.postMessage({ type: 'CHECK_STATUS', scene: scene.value }, '*')
})
const successText = computed(() =>
scene.value === 'sign' ? '合同签署成功' : '企业认证成功'
)
const descText = computed(() =>
scene.value === 'sign'
? '合同已签署,正在为您确认签署状态...'
: '企业认证已完成,正在为您确认认证状态...'
)
</script>
<style scoped>
.iframe-callback-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
}
.callback-content {
background: #fff;
border-radius: 20px;
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08);
padding: 48px 32px;
text-align: center;
min-width: 320px;
}
.callback-icon {
margin-bottom: 16px;
}
.callback-title {
font-size: 28px;
font-weight: 700;
color: #10b981;
margin-bottom: 8px;
}
.callback-desc {
font-size: 16px;
color: #64748b;
margin-bottom: 32px;
}
.callback-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #64748b;
font-size: 16px;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,333 @@
# 企业入驻流程
## 📋 概述
企业入驻流程是一个基于Vue 3 + Element Plus + Tailwind CSS构建的步骤式认证系统用于引导用户完成企业信息填写、认证验证、合同签署等入驻流程。
## 🏗️ 架构设计
### 目录结构
```
certification/
├── index.vue # 主页面容器
├── components/ # 子组件目录
│ ├── EnterpriseInfo.vue # 企业信息填写
│ ├── EnterpriseVerify.vue # 企业认证验证
│ ├── ContractSign.vue # 合同签署
│ └── CertificationComplete.vue # 完成页面
└── README.md # 本文档
```
### 技术栈
- **前端框架**: Vue 3 (Composition API)
- **UI组件库**: Element Plus
- **样式框架**: Tailwind CSS v4
- **图标库**: Heroicons
- **状态管理**: Pinia (用户状态)
- **路由**: Vue Router
## 🔄 流程步骤
### 1. 填写企业信息 (`enterprise_info`)
**组件**: `EnterpriseInfo.vue`
**功能特性**:
- 企业基本信息收集
- 法人信息验证
- 手机号验证码验证
- 实时表单验证
**表单字段**:
- 企业名称 (2-100字符)
- 统一社会信用代码 (18位)
- 法人姓名 (2-20字符)
- 法人身份证号 (18位)
- 法人手机号 (11位)
- 验证码 (6位数字)
**验证规则**:
```javascript
// 统一社会信用代码验证
const pattern = /^[0-9A-HJ-NPQRTUWXY]{18}$/
// 身份证号验证
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
// 手机号验证
const pattern = /^1[3-9]\d{9}$/
```
### 2. 企业认证 (`enterprise_verify`)
**组件**: `EnterpriseVerify.vue`
**功能特性**:
- 集成e签宝企业认证服务
- iframe嵌入认证页面
- 认证状态监控
- 加载状态管理
**集成服务**:
- 认证URL: `https://smlt.esign.cn/Z1Dc9Ts`
- 支持iframe加载
- 认证完成回调处理
### 3. 签署合同 (`contract_sign`)
**组件**: `ContractSign.vue`
**功能特性**:
- 集成电子合同签署服务
- iframe嵌入签署页面
- 签署状态监控
- 完成确认机制
**集成服务**:
- 签署URL: `https://smlt.esign.cn/95wqyN2`
- 支持iframe加载
- 签署完成回调处理
### 4. 完成入驻 (`completed`)
**组件**: `CertificationComplete.vue`
**功能特性**:
- 入驻成功展示
- 企业信息汇总
- 后续操作引导
- 状态标签显示
**操作入口**:
- 前往控制台
- 返回账户中心
## 🎨 UI/UX 设计
### 视觉设计原则
- **现代化**: 毛玻璃效果、渐变背景、圆角设计
- **一致性**: 统一的色彩系统和间距规范
- **可访问性**: 清晰的视觉层次和状态反馈
### 响应式设计
```css
/* 桌面端 */
@media (min-width: 1200px) {
.iframe-container { height: 700px; }
}
/* 移动端 */
@media (max-width: 768px) {
.iframe-container { height: 400px; }
.form-section { padding: 20px; }
}
```
### 状态指示
- **未开始**: 灰色图标,等待状态
- **进行中**: 蓝色图标,脉冲动画
- **已完成**: 绿色图标,完成状态
## 🔧 技术实现
### 状态管理
```javascript
// 主要状态
const currentStep = ref('enterprise_info')
const certificationData = ref(null)
const enterpriseForm = ref({
companyName: '',
unifiedSocialCode: '',
legalPersonName: '',
legalPersonID: '',
legalPersonPhone: '',
legalPersonCode: ''
})
```
### 组件通信
```javascript
// 事件定义
const emit = defineEmits(['submit', 'apply', 'check-status', 'go-dashboard', 'go-profile'])
// 数据传递
const props = defineProps({
formData: Object,
enterpriseData: Object,
companyName: String,
certificationData: Object
})
```
### 表单验证
```javascript
// 自定义验证器
const validateUnifiedSocialCode = (rule, value, callback) => {
const pattern = /^[0-9A-HJ-NPQRTUWXY]{18}$/
if (!pattern.test(value)) {
callback(new Error('统一社会信用代码格式不正确'))
return
}
callback()
}
```
## 🚀 开发特性
### 开发模式
```javascript
// 开发模式控制器
const isDevelopment = ref(false)
const devCurrentStep = ref('enterprise_info')
// 步骤切换功能
const switchToStep = (stepKey) => {
currentStep.value = stepKey
}
const resetToFirstStep = () => {
currentStep.value = 'enterprise_info'
}
```
### 错误处理
- 表单验证错误提示
- API调用异常处理
- 网络错误友好提示
- 加载状态管理
### 性能优化
- 组件懒加载
- 表单数据缓存
- 防抖处理
- 内存清理
## 📱 移动端适配
### 布局调整
- 垂直布局适配
- 按钮全宽显示
- 步骤图标缩小
- 间距优化
### 交互优化
- 触摸友好的按钮尺寸
- 简化的表单布局
- 优化的iframe高度
- 响应式步骤条
## 🔌 第三方集成
### e签宝服务
- **企业认证**: 统一身份认证
- **合同签署**: 电子合同服务
- **iframe集成**: 无缝嵌入体验
### 短信服务
- **验证码发送**: 手机号验证
- **倒计时功能**: 防止重复发送
- **错误处理**: 发送失败重试
## 📊 状态流转
### 认证状态映射
```javascript
const setCurrentStepByStatus = () => {
switch (certificationData.value.status) {
case 'not_started':
case 'pending':
currentStep.value = 'enterprise_info'
break
case 'info_submitted':
currentStep.value = 'enterprise_verify'
break
case 'enterprise_verified':
case 'contract_applied':
case 'contract_pending':
case 'contract_approved':
currentStep.value = 'contract_sign'
break
case 'contract_signed':
case 'completed':
currentStep.value = 'completed'
break
default:
currentStep.value = 'enterprise_info'
}
}
```
## 🧪 测试指南
### 开发模式测试
1. 启用开发模式控制器
2. 使用步骤选择器切换不同步骤
3. 测试表单验证和提交
4. 验证响应式布局
### 功能测试
1. 表单验证测试
2. 验证码发送测试
3. iframe加载测试
4. 状态切换测试
### 兼容性测试
1. 不同浏览器测试
2. 移动端适配测试
3. 网络异常测试
4. 性能压力测试
## 📝 使用说明
### 基本使用
```vue
<template>
<div class="certification-page">
<!-- 步骤条 -->
<CustomSteps :steps="certificationSteps" :current-step="currentStep" />
<!-- 内容区域 -->
<EnterpriseInfo v-if="currentStep === 'enterprise_info'" @submit="handleSubmit" />
<EnterpriseVerify v-if="currentStep === 'enterprise_verify'" @submit="handleVerify" />
<ContractSign v-if="currentStep === 'contract_sign'" @apply="handleApply" />
<CertificationComplete v-if="currentStep === 'completed'" @go-dashboard="goToDashboard" />
</div>
</template>
```
### 配置步骤
```javascript
const certificationSteps = [
{
key: 'enterprise_info',
title: '填写企业信息',
description: '填写企业基本信息和法人信息',
icon: BuildingOfficeIcon
},
// ... 其他步骤
]
```
## 🔮 未来规划
### 功能增强
- [ ] 支持多语言国际化
- [ ] 增加进度保存功能
- [ ] 支持离线填写
- [ ] 增加数据导出功能
### 技术优化
- [ ] 引入TypeScript支持
- [ ] 增加单元测试覆盖
- [ ] 优化包体积
- [ ] 增加PWA支持
### 用户体验
- [ ] 增加引导提示
- [ ] 优化加载动画
- [ ] 增加操作确认
- [ ] 支持快捷键操作
## 📞 技术支持
如有问题或建议请联系开发团队或提交Issue。
---
*最后更新: 2026年*

View File

@@ -0,0 +1,538 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-green-600">
<CheckCircleIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">入驻完成</h2>
<p class="header-subtitle">恭喜您完成企业入驻现在可以使用完整的API服务功能</p>
</div>
</div>
</template>
<div class="completion-content">
<div class="success-card">
<div class="success-icon">
<div class="icon-wrapper">
<el-icon class="text-green-500">
<CheckCircleIcon />
</el-icon>
</div>
</div>
<div class="success-text">
<h2 class="success-title">恭喜企业入驻已完成</h2>
<p class="success-desc">您的企业已完成入驻现在可以使用完整的API服务功能</p>
<p class="success-desc">下一步您只需要订阅贵司需要的api接口就可以实现在线调试和使用</p>
<div class="completion-info">
<h3 class="info-title">入驻信息</h3>
<div class="info-grid">
<div class="info-item">
<label class="info-label">入驻状态</label>
<div class="info-value">
<el-tag type="success" size="large" class="status-tag">已入驻</el-tag>
</div>
</div>
<div class="info-item">
<label class="info-label">入驻时间</label>
<div class="info-value">
{{ formatDate(certificationData.completed_at || formatDate(new Date())) }}
</div>
</div>
<div class="info-item">
<label class="info-label">企业名称</label>
<div class="info-value">{{ enterpriseInfo.company_name || '' }}</div>
</div>
<div class="info-item">
<label class="info-label">统一社会信用代码</label>
<div class="info-value">{{ enterpriseInfo.unified_social_code || '' }}</div>
</div>
</div>
</div>
<div class="completion-actions">
<el-button
type="primary"
size="large"
@click="showContract"
class="action-btn primary-btn"
>
<el-icon class="mr-2">
<DocumentTextIcon />
</el-icon>
查看合同
</el-button>
<el-button size="large" @click="goToProfile" class="action-btn secondary-btn">
<el-icon class="mr-2">
<UserIcon />
</el-icon>
返回账户中心
</el-button>
</div>
</div>
</div>
</div>
<!-- 合同查看弹窗 -->
<el-dialog
v-model="contractDialogVisible"
title="合同查看"
width="70%"
:close-on-click-modal="false"
:close-on-press-escape="true"
class="contract-dialog"
>
<div class="contract-container">
<iframe
v-if="contractUrl"
:src="contractUrl"
class="contract-iframe"
frameborder="0"
allowfullscreen
></iframe>
<div v-else class="no-contract">
<el-icon class="text-gray-400" size="48">
<DocumentIcon />
</el-icon>
<p>暂无合同文件</p>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="contractDialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { CheckCircleIcon, DocumentIcon, DocumentTextIcon, UserIcon } from '@heroicons/vue/24/outline'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
const userStore = useUserStore()
const { enterpriseForm, certificationData } = defineProps({
enterpriseForm: {
type: Object,
default: () => ({}),
},
certificationData: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['go-dashboard', 'go-profile'])
// 合同弹窗状态
const contractDialogVisible = ref(false)
// 响应式获取企业信息
const enterpriseInfo = computed(() => {
return userStore.userInfo?.enterprise_info || {}
})
// 获取合同URL
const contractUrl = computed(() => {
return certificationData?.metadata?.contract_url || ''
})
// 显示合同
const showContract = () => {
if (contractUrl.value) {
contractDialogVisible.value = true
} else {
ElMessage.warning('暂无合同文件')
}
}
// 跳转到控制台
const goToDashboard = () => {
emit('go-dashboard')
}
// 跳转到账户中心
const goToProfile = () => {
emit('go-profile')
}
// 格式化日期
const formatDate = (date) => {
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 格式化手机号
const formatPhone = (phone) => {
if (!phone) return ''
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
// 组件挂载时重新获取用户信息
onMounted(async () => {
try {
await userStore.fetchUserProfile()
console.log('认证完成页面:已重新获取用户信息')
} catch (error) {
console.error('认证完成页面:重新获取用户信息失败:', error)
}
})
</script>
<style scoped>
.step-card {
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
border: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.header-content {
flex: 1;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 4px 0;
}
.header-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
font-weight: 500;
}
/* 完成内容 */
.completion-content {
text-align: center;
padding: 40px 0;
}
.success-card {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border-radius: 24px;
padding: 48px;
border: 1px solid rgba(34, 197, 94, 0.2);
box-shadow: 0 16px 48px rgba(34, 197, 94, 0.15);
max-width: 800px;
margin: 0 auto;
}
.success-icon {
margin-bottom: 32px;
}
.icon-wrapper {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
font-size: 60px;
box-shadow: 0 16px 32px rgba(34, 197, 94, 0.3);
animation: successPulse 2s ease-in-out infinite;
}
.success-text {
max-width: 700px;
margin: 0 auto;
}
.success-title {
font-size: 36px;
font-weight: 700;
color: #166534;
margin: 0 0 16px 0;
background: linear-gradient(135deg, #166534 0%, #16a34a 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.success-desc {
font-size: 18px;
color: #16a34a;
margin: 0 0 40px 0;
line-height: 1.6;
}
/* 认证信息 */
.completion-info {
text-align: left;
margin: 40px 0;
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
border: 1px solid #e2e8f0;
}
.info-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin: 0 0 24px 0;
display: flex;
align-items: center;
gap: 8px;
}
.info-title::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 2px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.info-value {
font-size: 16px;
color: #1e293b;
font-weight: 600;
word-break: break-all;
}
.status-tag {
font-weight: 600;
border-radius: 8px;
padding: 6px 16px;
font-size: 14px;
}
/* 操作按钮 */
.completion-actions {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
margin-top: 40px;
}
.action-btn {
min-width: 180px;
height: 56px;
font-size: 16px;
font-weight: 600;
border-radius: 12px;
transition: all 0.3s ease;
}
.primary-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: none;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
color: white;
}
.primary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.4);
}
.secondary-btn {
border: 2px solid #e2e8f0;
background: white;
color: #64748b;
}
.secondary-btn:hover {
border-color: #10b981;
color: #10b981;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.15);
}
/* 合同弹窗样式 */
.contract-dialog {
border-radius: 16px;
}
.contract-container {
height: 70vh;
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
}
.contract-iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
.no-contract {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #64748b;
font-size: 16px;
}
.no-contract p {
margin-top: 16px;
font-weight: 500;
}
.dialog-footer {
text-align: right;
padding-top: 16px;
}
/* 动画效果 */
@keyframes successPulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 16px 32px rgba(34, 197, 94, 0.3);
}
50% {
transform: scale(1.05);
box-shadow: 0 20px 40px rgba(34, 197, 94, 0.4);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.completion-content {
padding: 20px 0;
}
.success-card {
padding: 32px 24px;
margin: 0 16px;
}
.success-title {
font-size: 28px;
}
.success-desc {
font-size: 16px;
}
.icon-wrapper {
width: 80px;
height: 80px;
font-size: 40px;
}
.card-header {
flex-direction: column;
text-align: center;
gap: 12px;
}
.header-icon {
width: 40px;
height: 40px;
font-size: 20px;
}
.header-title {
font-size: 20px;
}
.completion-info {
padding: 24px;
margin: 24px 0;
}
.info-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.completion-actions {
flex-direction: column;
align-items: center;
gap: 16px;
}
.action-btn {
min-width: 100%;
}
.contract-container {
height: 60vh;
}
}
@media (max-width: 480px) {
.success-card {
padding: 24px 16px;
}
.success-title {
font-size: 24px;
}
.icon-wrapper {
width: 60px;
height: 60px;
font-size: 30px;
}
.contract-container {
height: 50vh;
}
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div class="contract-expired-page">
<div class="expired-content">
<div class="expired-icon">
<el-icon :size="64" color="#f59e0b"><Clock /></el-icon>
</div>
<h2 class="expired-title">合同签署已过期</h2>
<div class="expired-info">
<h3 class="info-title">过期说明</h3>
<p class="info-text">合同签署时间已超过规定期限需要重新申请签署</p>
</div>
<div class="expired-details">
<div class="detail-item">
<span class="detail-label">合同生成时间</span>
<span class="detail-value">{{ formatTime(contractCreatedAt) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">签署期限</span>
<span class="detail-value">50分钟</span>
</div>
<div class="detail-item">
<span class="detail-label">过期时间</span>
<span class="detail-value">{{ formatTime(expiredAt) }}</span>
</div>
</div>
<div class="expired-actions">
<el-button
type="primary"
size="large"
@click="reapplyContract"
:loading="reapplyLoading"
class="reapply-btn"
>
<el-icon><Refresh /></el-icon>
重新申请签署
</el-button>
<el-button
type="info"
size="large"
@click="goBack"
class="back-btn"
>
返回上一步
</el-button>
</div>
<div class="expired-tips">
<h4>温馨提示</h4>
<ul>
<li>合同签署有效期为50分钟请及时完成签署</li>
<li>重新申请后您将获得新的签署链接</li>
<li>建议在网络良好的环境下完成签署操作</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { Clock, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
contractCreatedAt: {
type: String,
default: ''
}
})
const emit = defineEmits(['reapply-contract', 'go-back'])
const reapplyLoading = ref(false)
const expiredAt = computed(() => {
if (!props.contractCreatedAt) return ''
const created = new Date(props.contractCreatedAt)
const expired = new Date(created.getTime() + 50 * 60 * 1000) // 50分钟后过期
return expired.toISOString()
})
const formatTime = (timeStr) => {
if (!timeStr) return '未知'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const reapplyContract = async () => {
try {
reapplyLoading.value = true
emit('reapply-contract')
} catch (error) {
ElMessage.error('重新申请失败,请稍后重试')
} finally {
reapplyLoading.value = false
}
}
const goBack = () => {
emit('go-back')
}
</script>
<style scoped>
.contract-expired-page {
min-height: 600px;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.expired-content {
background: #fff;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 48px 40px;
text-align: center;
max-width: 600px;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.expired-icon {
margin-bottom: 24px;
}
.expired-title {
font-size: 32px;
font-weight: 700;
color: #f59e0b;
margin-bottom: 32px;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.expired-info {
background: #fffbeb;
border: 1px solid #fed7aa;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
text-align: left;
}
.info-title {
font-size: 18px;
font-weight: 600;
color: #d97706;
margin-bottom: 12px;
}
.info-text {
font-size: 16px;
color: #92400e;
line-height: 1.6;
margin: 0;
}
.expired-details {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
text-align: left;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-label {
color: #64748b;
font-weight: 500;
}
.detail-value {
color: #1e293b;
font-weight: 600;
}
.expired-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 32px;
}
.reapply-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: none;
border-radius: 12px;
padding: 12px 32px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.reapply-btn:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
}
.back-btn {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 32px;
font-weight: 600;
color: #64748b;
}
.back-btn:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: #475569;
}
.expired-tips {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 12px;
padding: 24px;
text-align: left;
}
.expired-tips h4 {
font-size: 16px;
font-weight: 600;
color: #0369a1;
margin-bottom: 12px;
}
.expired-tips ul {
margin: 0;
padding-left: 20px;
}
.expired-tips li {
font-size: 14px;
color: #0c4a6e;
line-height: 1.6;
margin-bottom: 8px;
}
.expired-tips li:last-child {
margin-bottom: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.contract-expired-page {
padding: 20px 16px;
}
.expired-content {
padding: 32px 24px;
}
.expired-title {
font-size: 24px;
}
.expired-actions {
flex-direction: column;
align-items: center;
}
.reapply-btn,
.back-btn {
width: 100%;
max-width: 280px;
}
.detail-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-blue-600">
<DocumentTextIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">合同预览</h2>
<p class="header-subtitle">请仔细阅读合同内容确认无误后再进行签署</p>
</div>
</div>
</template>
<div class="contract-preview-section">
<div v-if="contractUrl" class="pdf-viewer-wrapper">
<vue-pdf-embed
ref="pdfRef"
:source="contractUrl"
:page="page"
:scale="pdfScale"
@loaded="handleLoaded"
class="pdf-embed"
/>
<div class="pdf-controls">
<el-button size="large" :disabled="page <= 1" @click="page--"> 上一页 </el-button>
<span class="pdf-page-info"> {{ page }} / {{ pageCount }} </span>
<el-button size="large" :disabled="page >= pageCount" @click="page++"> 下一页 </el-button>
<el-divider direction="vertical" />
<el-button size="large" @click="fullscreen = true"> 全屏预览 </el-button>
</div>
</div>
<el-button type="primary" @click="emit('start-sign')" :loading="props.loading">
<el-icon class="mr-2"><DocumentTextIcon /></el-icon>
开始签署
</el-button>
</div>
<!-- 极简全屏预览弹窗无顶部栏 -->
<el-dialog
v-model="fullscreen"
fullscreen
append-to-body
:close-on-click-modal="true"
class="fullscreen-dialog"
:show-close="false"
lock-scroll
:header="false"
>
<div class="fullscreen-pdf-simple">
<vue-pdf-embed
:source="contractUrl"
:page="page"
:scale="fullscreenScale"
class="fullscreen-pdf-embed"
/>
<div class="fullscreen-pdf-controls">
<el-button size="large" :disabled="page <= 1" @click="page--">上一页</el-button>
<span class="pdf-page-info"> {{ page }} / {{ pageCount }} </span>
<el-button size="large" :disabled="page >= pageCount" @click="page++">下一页</el-button>
<el-button type="primary" size="large" @click="fullscreen = false"> 关闭预览 </el-button>
</div>
</div>
</el-dialog>
</el-card>
</template>
<script setup>
import { DocumentTextIcon } from '@heroicons/vue/24/outline'
import VuePdfEmbed from 'vue-pdf-embed'
const props = defineProps({
contractUrl: String,
loading: Boolean
})
const emit = defineEmits(['start-sign'])
const pdfRef = ref(null)
const page = ref(1)
const pageCount = ref(0)
const pdfScale = ref(1.3)
const fullscreen = ref(false)
const fullscreenScale = ref(1.7)
function handleLoaded(pdf) {
pageCount.value = pdf.numPages
}
</script>
<style scoped>
.step-card {
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
border: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.card-header {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.header-content {
flex: 1;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 4px 0;
}
.header-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
font-weight: 500;
}
.contract-preview-section {
padding: 32px 0;
text-align: center;
}
.pdf-viewer-wrapper {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
}
.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;
}
.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);
}
.pdf-page-info {
color: #64748b;
font-size: 16px;
font-weight: 500;
}
.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;
}
.fullscreen-pdf-simple {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafbfc;
overflow: auto;
}
.fullscreen-pdf-embed {
width: 100vw;
max-width: 1000px;
height: calc(100vh - 116px);
border: none;
border-radius: 0;
background: #fff;
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;
color: #fff;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.22);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="contract-rejected-page">
<div class="rejected-content">
<div class="rejected-icon">
<el-icon :size="64" color="#ef4444"><Close /></el-icon>
</div>
<h2 class="rejected-title">合同签署被拒绝</h2>
<div class="rejected-reason">
<h3 class="reason-title">拒绝原因</h3>
<p class="reason-text">{{ failureMessage || '用户主动拒绝签署合同' }}</p>
</div>
<div class="rejected-actions">
<el-button
type="primary"
size="large"
@click="contactCustomerService"
class="contact-btn"
>
<el-icon><Service /></el-icon>
联系客服
</el-button>
<el-button
type="info"
size="large"
@click="goBack"
class="back-btn"
>
返回上一步
</el-button>
</div>
<div class="rejected-tips">
<h4>温馨提示</h4>
<ul>
<li>如果您对合同内容有疑问请联系客服咨询</li>
<li>客服将协助您解决相关问题并重新申请签署</li>
<li>您也可以稍后重新申请合同签署</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { Close, Service } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
failureMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['contact-service', 'go-back'])
const contactCustomerService = () => {
// 这里可以打开客服聊天窗口或跳转到客服页面
ElMessage.success('正在为您连接客服...')
emit('contact-service')
}
const goBack = () => {
emit('go-back')
}
</script>
<style scoped>
.contract-rejected-page {
min-height: 600px;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.rejected-content {
background: #fff;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 48px 40px;
text-align: center;
max-width: 600px;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.rejected-icon {
margin-bottom: 24px;
}
.rejected-title {
font-size: 32px;
font-weight: 700;
color: #ef4444;
margin-bottom: 32px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rejected-reason {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
text-align: left;
}
.reason-title {
font-size: 18px;
font-weight: 600;
color: #dc2626;
margin-bottom: 12px;
}
.reason-text {
font-size: 16px;
color: #7f1d1d;
line-height: 1.6;
margin: 0;
}
.rejected-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 32px;
}
.contact-btn {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
border-radius: 12px;
padding: 12px 32px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.contact-btn:hover {
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.back-btn {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 32px;
font-weight: 600;
color: #64748b;
}
.back-btn:hover {
background: #f1f5f9;
border-color: #cbd5e1;
color: #475569;
}
.rejected-tips {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 12px;
padding: 24px;
text-align: left;
}
.rejected-tips h4 {
font-size: 16px;
font-weight: 600;
color: #0369a1;
margin-bottom: 12px;
}
.rejected-tips ul {
margin: 0;
padding-left: 20px;
}
.rejected-tips li {
font-size: 14px;
color: #0c4a6e;
line-height: 1.6;
margin-bottom: 8px;
}
.rejected-tips li:last-child {
margin-bottom: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.contract-rejected-page {
padding: 20px 16px;
}
.rejected-content {
padding: 32px 24px;
}
.rejected-title {
font-size: 24px;
}
.rejected-actions {
flex-direction: column;
align-items: center;
}
.contact-btn,
.back-btn {
width: 100%;
max-width: 280px;
}
}
</style>

View File

@@ -0,0 +1,257 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-purple-600">
<DocumentTextIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">签署合同</h2>
<p class="header-subtitle">请完成合同签署流程</p>
</div>
</div>
</template>
<div class="contract-content">
<!-- 合同签署iframe -->
<div class="contract-iframe-section">
<div class="iframe-container">
<iframe
:src="iframeUrl"
frameborder="0"
class="contract-iframe"
title="合同签署"
@load="handleIframeLoad"
></iframe>
<div v-if="iframeLoading" class="iframe-loading">
<div class="loading-spinner"></div>
<p>正在加载合同签署页面...</p>
</div>
</div>
</div>
</div>
</el-card>
</template>
<script setup>
import {
DocumentTextIcon
} from '@heroicons/vue/24/outline'
defineProps({
companyName: {
type: String,
default: ''
},
iframeUrl: {
type: String,
default: ''
}
})
// 合同签署状态
const contractCompleted = ref(false)
const iframeLoading = ref(true)
// iframe加载完成回调
const handleIframeLoad = () => {
iframeLoading.value = false
}
</script>
<style scoped>
.step-card {
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
border: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.header-content {
flex: 1;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 4px 0;
}
.header-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
font-weight: 500;
}
/* 合同内容 */
.contract-content {
max-width: 100%;
margin: 0 auto;
padding: 20px 0;
}
/* 合同签署iframe */
.contract-iframe-section {
padding: 20px;
}
.iframe-container {
position: relative;
width: 100%;
height: 600px; /* 调整iframe高度 */
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.contract-iframe {
width: 100%;
height: 100%;
border: none;
}
.iframe-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
border-radius: 12px;
z-index: 10;
}
.iframe-loading .loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top: 4px solid #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.iframe-loading p {
font-size: 16px;
color: #64748b;
font-weight: 500;
}
.iframe-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
}
.contract-complete-btn {
min-width: 180px;
border-radius: 12px;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border: none;
font-weight: 500;
transition: all 0.3s ease;
}
.contract-complete-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.contract-complete-btn:disabled {
background: #e2e8f0;
color: #64748b;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.contract-content {
max-width: 100%;
padding: 10px 0;
}
.contract-iframe-section {
padding: 15px;
}
.iframe-container {
height: 400px; /* 移动端调整iframe高度 */
}
.iframe-actions {
flex-direction: column;
gap: 12px;
}
.contract-complete-btn {
min-width: 100%;
}
.card-header {
flex-direction: column;
text-align: center;
gap: 12px;
}
.header-icon {
width: 40px;
height: 40px;
font-size: 20px;
}
.header-title {
font-size: 20px;
}
}
/* 大屏幕优化 */
@media (min-width: 1200px) {
.iframe-container {
height: 700px; /* 大屏幕增加iframe高度 */
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-green-600">
<ShieldCheckIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">企业认证</h2>
<p class="header-subtitle">请完成企业认证流程</p>
</div>
</div>
</template>
<div class="verify-content">
<!-- 企业认证iframe -->
<div class="esign-iframe-section">
<div class="iframe-container">
<iframe
:src="authUrl"
frameborder="0"
class="esign-iframe"
title="企业认证"
@load="handleIframeLoad"
></iframe>
<div v-if="iframeLoading" class="iframe-loading">
<div class="loading-spinner"></div>
<p>正在加载企业认证页面...</p>
</div>
</div>
</div>
</div>
</el-card>
</template>
<script setup>
import {
CheckIcon,
ShieldCheckIcon
} from '@heroicons/vue/24/outline'
import { ElMessage } from 'element-plus'
const props = defineProps({
enterpriseData: {
type: Object,
required: true
},
authUrl: {
type: String,
default: ''
}
})
const emit = defineEmits(['submit'])
// 企业认证状态
const esignCompleted = ref(false)
const iframeLoading = ref(true)
// 企业认证完成回调
const handleEsignComplete = async () => {
try {
esignCompleted.value = true
ElMessage.success('企业认证完成')
emit('submit')
} catch (error) {
console.error('企业认证失败:', error)
ElMessage.error('企业认证失败,请重试')
}
}
// 刷新iframe
const refreshIframe = () => {
const iframe = document.querySelector('.esign-iframe')
if (iframe) {
iframe.src = iframe.src // 刷新iframe
}
}
// iframe加载完成回调
const handleIframeLoad = () => {
iframeLoading.value = false
}
</script>
<style scoped>
.step-card {
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
border: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.header-content {
flex: 1;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0 0 4px 0;
}
.header-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
font-weight: 500;
}
/* 认证内容 */
.verify-content {
max-width: 100%;
margin: 0 auto;
padding: 20px 0;
}
/* 企业认证iframe */
.esign-iframe-section {
padding: 20px;
}
.iframe-container {
position: relative;
width: 100%;
height: 600px; /* 调整iframe高度 */
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.esign-iframe {
width: 100%;
height: 100%;
border: none;
}
.iframe-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
border-radius: 12px;
z-index: 10;
}
.iframe-loading .loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.iframe-loading p {
font-size: 16px;
color: #64748b;
font-weight: 500;
}
.iframe-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
}
.esign-complete-btn {
min-width: 180px;
border-radius: 12px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: none;
font-weight: 500;
transition: all 0.3s ease;
}
.esign-complete-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.esign-complete-btn:disabled {
background: #e2e8f0;
color: #64748b;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.refresh-btn {
min-width: 120px;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
font-weight: 500;
transition: all 0.3s ease;
}
.refresh-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* 动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.verify-content {
max-width: 100%;
padding: 10px 0;
}
.esign-iframe-section {
padding: 15px;
}
.iframe-container {
height: 400px; /* 移动端调整iframe高度 */
}
.iframe-actions {
flex-direction: column;
gap: 12px;
}
.esign-complete-btn,
.refresh-btn {
min-width: 100%;
}
.card-header {
flex-direction: column;
text-align: center;
gap: 12px;
}
.header-icon {
width: 40px;
height: 40px;
font-size: 20px;
}
.header-title {
font-size: 20px;
}
}
/* 大屏幕优化 */
@media (min-width: 1200px) {
.iframe-container {
height: 700px; /* 大屏幕增加iframe高度 */
}
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<el-card class="step-card">
<template #header>
<div class="card-header">
<div class="header-icon">
<el-icon class="text-amber-600">
<ClockIcon />
</el-icon>
</div>
<div class="header-content">
<h2 class="header-title">人工审核</h2>
<p class="header-subtitle">您的企业信息已提交请等待管理员审核</p>
</div>
</div>
</template>
<div class="manual-review-content">
<div class="review-status-box">
<el-icon class="status-icon"><ClockIcon /></el-icon>
<p class="status-text">我们正在审核您提交的企业信息请耐心等待</p>
<p v-if="submitTime" class="submit-time">提交时间{{ submitTime }}</p>
<p v-if="companyName" class="company-name">企业名称{{ companyName }}</p>
</div>
<el-button type="primary" class="refresh-btn" :loading="refreshing" @click="handleRefresh">
刷新状态
</el-button>
</div>
</el-card>
</template>
<script setup>
import { ClockIcon } from '@heroicons/vue/24/outline'
defineProps({
certificationData: {
type: Object,
default: null
},
submitTime: {
type: String,
default: ''
},
companyName: {
type: String,
default: ''
}
})
const emit = defineEmits(['refresh'])
const refreshing = ref(false)
const pollTimer = ref(null)
const handleRefresh = async () => {
refreshing.value = true
try {
emit('refresh')
} finally {
refreshing.value = false
}
}
onMounted(() => {
// 人工审核阶段:自动轮询状态,审核通过后会在父组件中自动切换步骤
pollTimer.value = window.setInterval(() => {
emit('refresh')
}, 5000)
})
onUnmounted(() => {
if (pollTimer.value) {
window.clearInterval(pollTimer.value)
pollTimer.value = null
}
})
</script>
<style scoped>
.manual-review-content {
padding: 24px 0;
text-align: center;
}
.review-status-box {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 12px;
padding: 32px 24px;
margin-bottom: 24px;
}
.status-icon {
font-size: 48px;
color: #d97706;
margin-bottom: 16px;
}
.status-text {
font-size: 16px;
color: #92400e;
margin: 0 0 12px;
line-height: 1.6;
}
.submit-time,
.company-name {
font-size: 14px;
color: #b45309;
margin: 4px 0;
}
.refresh-btn {
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,791 @@
<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()
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 = 'manual_review'
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>