feat: 实现结婚证领取流程完整功能

- 添加dayjs依赖用于日期处理
- 新增Dialog组件用于消息提示
- 完善短信验证码发送与校验逻辑
- 实现OCR识别结婚证信息功能
- 完成领取前校验与提交接口对接
- 根据活动状态显示不同界面
This commit is contained in:
前端小啊白 2025-11-15 09:37:56 +08:00
parent 0ba15bd9db
commit 0d3462a8f5
6 changed files with 558 additions and 81 deletions

View File

@ -11,6 +11,7 @@
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"tailwindcss": "^4.1.17",
"vue": "^3.5.24",
"vue-router": "^4.6.3"

View File

@ -14,6 +14,9 @@ importers:
axios:
specifier: ^1.13.2
version: 1.13.2
dayjs:
specifier: ^1.11.19
version: 1.11.19
tailwindcss:
specifier: ^4.1.17
version: 4.1.17
@ -530,6 +533,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -1203,6 +1209,8 @@ snapshots:
csstype@3.1.3: {}
dayjs@1.11.19: {}
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {}

251
src/components/Dialog.vue Normal file
View File

@ -0,0 +1,251 @@
<template>
<Teleport to="body">
<Transition name="dialog">
<div v-if="visible" class="dialog-overlay" @click.self="handleOverlayClick">
<div class="dialog-container">
<!-- 对话框头部 -->
<div class="dialog-header" v-if="title || $slots.title">
<slot name="title">{{ title }}</slot>
<button v-if="closable" class="dialog-close" @click="handleClose">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4 4L12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 对话框内容 -->
<div class="dialog-body">
<slot>{{ message }}</slot>
</div>
<!-- 对话框底部按钮区域 -->
<div class="dialog-footer" v-if="showFooter">
<slot name="footer">
<button v-if="showCancelButton" class="dialog-button dialog-button-cancel" @click="handleCancel">
{{ cancelButtonText }}
</button>
<button class="dialog-button dialog-button-confirm" @click="handleConfirm">
{{ confirmButtonText }}
</button>
</slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
// Props
const props = defineProps({
//
visible: {
type: Boolean,
default: false
},
//
title: {
type: String,
default: ''
},
//
message: {
type: String,
default: ''
},
//
closable: {
type: Boolean,
default: true
},
//
showFooter: {
type: Boolean,
default: true
},
//
showCancelButton: {
type: Boolean,
default: true
},
//
confirmButtonText: {
type: String,
default: '确认'
},
//
cancelButtonText: {
type: String,
default: '取消'
},
//
closeOnClickOverlay: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits<{
//
close: []
//
confirm: []
//
cancel: []
}>()
//
const handleClose = () => {
emit('close')
}
//
const handleConfirm = () => {
emit('confirm')
}
//
const handleCancel = () => {
emit('cancel')
}
//
const handleOverlayClick = () => {
if (props.closeOnClickOverlay) {
emit('close')
}
}
</script>
<style scoped>
/* 遮罩层样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
/* 对话框容器样式 */
.dialog-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 对话框头部样式 */
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 500;
color: #333;
}
/* 关闭按钮样式 */
.dialog-close {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #999;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.dialog-close:hover {
color: #333;
}
/* 对话框内容样式 */
.dialog-body {
padding: 20px;
flex: 1;
overflow-y: auto;
color: #666;
line-height: 1.5;
}
/* 对话框底部样式 */
.dialog-footer {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 按钮样式 */
.dialog-button {
padding: 8px 16px;
border-radius: 4px;
border: none;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.dialog-button-cancel {
background-color: #f5f5f5;
color: #666;
}
.dialog-button-cancel:hover {
background-color: #e8e8e8;
}
.dialog-button-confirm {
background-color: #1890ff;
color: #fff;
}
.dialog-button-confirm:hover {
background-color: #40a9ff;
}
/* 动画样式 */
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.3s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-active .dialog-container,
.dialog-leave-active .dialog-container {
transition: transform 0.3s ease;
}
.dialog-enter-from .dialog-container {
transform: scale(0.9);
}
.dialog-leave-to .dialog-container {
transform: scale(0.9);
}
</style>

162
src/components/dialog.ts Normal file
View File

@ -0,0 +1,162 @@
import { createApp, h, nextTick } from 'vue'
import Dialog from './Dialog.vue'
// 对话框配置接口
export interface DialogOptions {
// 对话框标题
title?: string
// 对话框内容
message?: string
// 是否显示关闭按钮
closable?: boolean
// 是否显示底部按钮区域
showFooter?: boolean
// 是否显示取消按钮
showCancelButton?: boolean
// 确认按钮文本
confirmButtonText?: string
// 取消按钮文本
cancelButtonText?: string
// 点击遮罩层是否可以关闭对话框
closeOnClickOverlay?: boolean
// 确认回调函数
onConfirm?: () => void
// 取消回调函数
onCancel?: () => void
// 关闭回调函数
onClose?: () => void
}
// Dialog 实例接口
interface DialogInstance {
show: () => void
hide: () => void
close: () => void
}
/**
*
* @param options
* @returns
*/
export function showDialog(options: DialogOptions): DialogInstance {
// 默认配置
const defaultOptions: DialogOptions = {
title: '',
message: '',
closable: true,
showFooter: true,
showCancelButton: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
closeOnClickOverlay: true,
onConfirm: () => {},
onCancel: () => {},
onClose: () => {}
}
// 合并配置
const mergedOptions = { ...defaultOptions, ...options }
// 创建一个容器元素
const container = document.createElement('div')
// 对话框状态
let visible = true
// 创建应用实例
const app = createApp({
render() {
return h(Dialog, {
visible,
title: mergedOptions.title,
message: mergedOptions.message,
closable: mergedOptions.closable,
showFooter: mergedOptions.showFooter,
showCancelButton: mergedOptions.showCancelButton,
confirmButtonText: mergedOptions.confirmButtonText,
cancelButtonText: mergedOptions.cancelButtonText,
closeOnClickOverlay: mergedOptions.closeOnClickOverlay,
onConfirm: () => {
mergedOptions.onConfirm?.()
hideDialog()
},
onCancel: () => {
mergedOptions.onCancel?.()
hideDialog()
},
onClose: () => {
mergedOptions.onClose?.()
hideDialog()
}
})
}
})
// 隐藏对话框
function hideDialog() {
visible = false
// 等待动画完成后卸载组件
nextTick(() => {
setTimeout(() => {
app.unmount()
if (container.parentNode) {
container.parentNode.removeChild(container)
}
}, 300)
})
}
// 显示对话框(默认为显示状态)
function showDialog() {
visible = true
}
// 挂载组件
app.mount(container)
document.body.appendChild(container)
// 返回控制方法
return {
show: showDialog,
hide: hideDialog,
close: hideDialog
}
}
/**
*
* @param message
* @param title
* @param onConfirm
* @returns
*/
export function showConfirmDialog(message: string, title: string = '确认', onConfirm?: () => void): DialogInstance {
return showDialog({
title,
message,
showCancelButton: true,
onConfirm
})
}
/**
*
* @param message
* @param title
* @returns
*/
export function showAlertDialog(message: string, title: string = '提示'): DialogInstance {
return showDialog({
title,
message,
showCancelButton: false
})
}
// 默认导出
export default {
show: showDialog,
confirm: showConfirmDialog,
alert: showAlertDialog
}

View File

@ -26,6 +26,8 @@
</div>
<div>
<div v-if="![2, 3].includes(activityInfo.status)">
<!-- 手机号输入 -->
<div class="mb-5 flex items-center gap-3 border border-gray-200 rounded-lg px-4">
<input v-model="formData.phone" type="tel" placeholder="请输入手机号码" maxlength="11"
@ -33,24 +35,27 @@
<button @click="sendVerificationCode" :disabled="countdown > 0 || formData.phone.length !== 11"
class="h-11 rounded-lg text-sm text-[#E8424D] transition-all disabled:text-[#8a8a8a]">
{{ countdown > 0 ? `${countdown}秒后重发` : '获取验证码' }}
{{ countdown > 0 ? `${countdown}秒后重发` : formData.verifyCode ? '重新获取' : '获取验证码' }}
</button>
</div>
<!-- 验证码输入 -->
<div class="mb-5 flex items-center gap-3 border border-gray-200 rounded-lg px-4">
<input v-model="formData.verificationCode" type="text" placeholder="请输入验证码" maxlength="6"
class="outline-none focus:border-[#E8424D] transition-colors flex-1 h-11" />
<button @click="verifyCode" :disabled="formData.verificationCode.length !== 6"
<input :disabled="formData.verifyCode" v-model="formData.smsCode" type="text" placeholder="请输入验证码"
maxlength="6" class="outline-none focus:border-[#E8424D] transition-colors flex-1 h-11" />
<button v-if="!formData.verifyCode" @click="verifyCode" :disabled="formData.smsCode.length !== 6"
class="h-8 px-4 bg-[#E8424D] text-white rounded-lg text-sm transition-all hover:opacity-90 active:scale-98 disabled:bg-[#8a8a8a]">
验证
</button>
</div>
</div>
<div v-if="formData.verifyCode">
<!-- 结婚证信息 -->
<div class="mb-6">
<h3 class="text-base text-gray-800 mb-3 font-medium">结婚证信息:</h3>
<div v-if="false" class="border border-gray-200 rounded-lg p-5 flex items-center justify-center">
<div v-if="!formData.marriageNo"
class="border border-gray-200 rounded-lg p-5 flex items-center justify-center">
<div class="relative inline-block" @click="handleScanClick">
<img src="/扫描.png" alt="" class="h-20">
<div
@ -60,7 +65,8 @@
<input type="file" ref="ocrUploadId" class="hidden" accept="image/*" @change="handleImageChange" />
</div>
</div>
<div class="w-full flex flex-col items-start border border-gray-200 rounded-lg p-5 bg-[#FAFAFA]">
<div v-if="formData.marriageNo"
class="w-full flex flex-col items-start border border-gray-200 rounded-lg p-5 bg-[#FAFAFA]">
<div>
<span class="inline-block w-25 text-right">结婚证字号</span>
<span class="pl-2">{{ 11111 }}</span>
@ -77,18 +83,29 @@
</div>
<!-- 提交按钮 -->
<button @click="submitForm"
class="w-full h-12 bg-gradient-to-r from-[#E8424D] to-[#FF7A7A] text-white rounded-full text-lg font-bold transition-all hover:opacity-90 active:scale-98">
<button @click="submitForm" :disabled="!formData.marriageNo"
class="w-full h-12 bg-gradient-to-r from-[#E8424D] to-[#FF7A7A] text-white rounded-full text-lg font-bold transition-all hover:opacity-90 active:scale-98 disabled:from-[#8a8a8a] disabled:to-[#8a8a8a]">
提交领取喜礼
</button>
</div>
</div>
<div>
<div v-if="activityInfo.status === 2">
<!-- 活动结束状态 -->
<div class="mt-6 p-4 border border-[#E8424D] bg-[#FFF5F5] rounded-lg text-center">
<div class="text-[#E8424D] text-lg font-semibold mb-2">活动已结束</div>
<div class="text-gray-600 text-sm">感谢您的参与敬请期待下一次活动</div>
<div class="mt-4 text-xs text-gray-500">活动时间2024年1月1日 - 2024年6月30日</div>
<div class="text-gray-600 text-sm">{{ activityInfo.activityName }}活动已结束感谢您的参与敬请期待下一次活动</div>
<div class="mt-4 text-xs text-gray-500">活动时间{{ activityInfo.activityStartTime }} - {{
activityInfo.activityEndTime }}</div>
</div>
</div>
<div v-else-if="activityInfo.status === 3">
<!-- 活动失败状态 -->
<div class="mt-6 p-4 border border-[#FF7A7A] bg-[#FFF5F5] rounded-lg text-center">
<div class="text-[#FF7A7A] text-lg font-semibold mb-2">活动获取失败</div>
<div class="text-gray-600 text-sm">活动获取失败请稍后重试</div>
<div class="mt-4 text-xs text-gray-500">活动时间--</div>
</div>
</div>
@ -101,20 +118,40 @@
import message from '../../components/message';
import apiService from '../../services/apiService'
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
const ocrUploadId = ref<HTMLInputElement>();
//
const formData = ref({
phone: '',
verificationCode: ''
smsCode: '',
verifyCode: false, //
marriageNo: '', //
husbandName: '', //
wifeName: '', //
registerDate: '', //
})
const activityInfo = ref<any>({}); //
onMounted(() => {
//
apiService.getCurrentActivity().then((response: any) => {
console.log(response);
if (response.data) {
activityInfo.value = {
...response.data,
activityStartTime: dayjs(response.data.activityStartTime).format('YYYY年MM月DD日'),
activityEndTime: dayjs(response.data.activityEndTime).format('YYYY年MM月DD日'),
};
if (new Date(response.data.activityEndTime) < new Date()) {
message.error('活动已结束');
activityInfo.value.status = 2;
}
}
}).catch((error) => {
message.error('获取活动信息失败,请稍后重试')
activityInfo.value.status = 3;
})
})
@ -135,8 +172,11 @@ const sendVerificationCode = () => {
//
apiService.sendSms({
mobile: phone,
type: 0,
type: 3,
}).then(() => {
formData.value.verifyCode = false;
formData.value.smsCode = '';
//
countdown.value = 60
@ -161,19 +201,20 @@ const sendVerificationCode = () => {
//
const verifyCode = () => {
const { verificationCode } = formData.value
const { smsCode } = formData.value
if (verificationCode.length !== 6 || !/^\d{6}$/.test(verificationCode)) {
if (smsCode.length !== 6 || !/^\d{6}$/.test(smsCode)) {
message.error('请输入6位数字验证码')
return
}
//
apiService.login({
apiService.verifySms({
mobile: formData.value.phone,
smsCode: verificationCode,
smsCode: smsCode,
type: 3,
}).then(() => {
message.success('验证码验证成功')
formData.value.verifyCode = true;
}).catch((error) => {
console.error('验证验证码失败:', error)
message.error('验证码验证失败,请重试')
@ -196,27 +237,27 @@ const handleImageChange = () => {
formFormData.append('file', file)
apiService.uploadOcrImage(formFormData).then((response: any) => {
apiService.sendSms({
mobile: formData.value.phone,
type: 2, // 2 OCR code
}).then((res) => {
apiService.parseOcrInfo({
mobile: formData.value.phone,
smsCode: res.data,
smsCode: formData.value.smsCode,
uploadId: response.data.uploadId,
}).then((res) => {
console.log(res);
}).then((res: any) => {
if (res?.data?.parsed?.marriageNo) {
formData.value.marriageNo = res?.data?.parsed?.marriageNo;
formData.value.husbandName = res?.data?.parsed?.husbandName;
formData.value.wifeName = res?.data?.parsed?.wifeName;
formData.value.registerDate = res?.data?.parsed?.registerDate;
} else {
message.error('解析OCR信息失败请稍后重试');
ocrUploadId.value && (ocrUploadId.value.value = '');
}
}).catch((error) => {
console.error('解析OCR信息失败:', error)
message.error('解析OCR信息失败请稍后重试')
ocrUploadId.value && (ocrUploadId.value.value = '');
})
}).catch((error) => {
console.error('发送验证码失败:', error)
message.error('验证码发送失败,请稍后重试')
})
}).catch((error) => {
console.error('上传OCR图片失败:', error)
message.error('图片上传失败,请稍后重试')
ocrUploadId.value && (ocrUploadId.value.value = '');
})
}
@ -224,14 +265,22 @@ const handleImageChange = () => {
//
const submitForm = () => {
if (!formData.value.phone || !formData.value.verificationCode) {
if (!formData.value.phone || !formData.value.smsCode) {
message.error('请完善所有信息')
return
}
//
console.log('提交表单数据:', formData.value)
message.success('提交成功,正在处理您的请求...')
apiService.receiveCheck({
marriageNo: formData.value.marriageNo,
receiveName: formData.value.husbandName,
receiveMobile: formData.value.phone,
code: formData.value.smsCode,
smsCode: formData.value.smsCode,
}).then((res: any) => {
console.log(res);
}).catch((error) => {
message.error('领取失败,请稍后重试')
})
}
//

View File

@ -53,7 +53,8 @@ export interface MarriageCodeListVO {
export interface CommSmsDTO {
mobile: string
type: 0 | 1 | 2; // 0=登录1=兑换领取2=OCR识别
smsCode?: string
type: 0 | 1 | 2 | 3; // 0=登录1=兑换领取2=OCR识别3=本流程使用
}
// API服务实现
@ -79,6 +80,11 @@ export const apiService = {
return request.post('/marriage/common/sms', data)
},
// 校验短信验证码
verifySms(data: CommSmsDTO) {
return request.post('/marriage/common/checkCode', data)
},
// OCR识别并返回证件信息
parseOcrInfo(data: {
mobile: string
@ -91,7 +97,7 @@ export const apiService = {
// 领取流程相关接口
// 领取前校验(生成二维码前的校验与预览)
receiveCheck(data: MarriageCodeDTO) {
return request.post<MarriageCodeVO>('/marriage/receiveCheck', data)
return request.post<MarriageCodeVO>('/marriage/receiveCheck2', data)
},
// 确认领取(落库并置为已核销)