feat: 新增新婚送福活动前端页面及功能实现
实现新婚送福活动前端页面,包括以下功能: 1. 活动介绍页展示 2. OCR识别结婚证功能 3. 短信验证码发送与校验 4. 结婚证信息自动填充 5. 表单提交与状态提示 6. 响应式设计与UI组件 新增相关API服务与类型定义,包括活动信息获取、OCR识别、短信验证等接口
|
|
@ -1,52 +1,24 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
unpackage/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Coverage directory
|
||||
coverage/
|
||||
|
||||
# Cache
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"oxc.vscode-oxc"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>1</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "1",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"axios": "^1.13.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vue-tsc": "^3.1.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 910 B |
|
After Width: | Height: | Size: 886 B |
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,293 @@
|
|||
<template>
|
||||
<div class="image-viewer-container w-full h-full">
|
||||
|
||||
<!-- 图片错误状态 -->
|
||||
<div v-if="hasError" class="image-error" :style="containerStyle">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<span class="error-text">{{ errorText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 缩略图 -->
|
||||
<div v-else @click="openViewer">
|
||||
<img :src="src" :alt="alt || '图片'" :style="{ ...containerStyle, cursor: preview ? 'pointer' : 'default' }"
|
||||
@load="handleLoad" @error="handleError" />
|
||||
</div>
|
||||
|
||||
<!-- 预览模态框 -->
|
||||
<transition name="modal">
|
||||
<div v-if="isViewerOpen" class="viewer-overlay" @click="closeViewer">
|
||||
<div class="viewer-container" @click.stop>
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="close-btn" @click="closeViewer" aria-label="关闭">×</button>
|
||||
|
||||
<!-- 预览图片 -->
|
||||
<img :src="src" :alt="alt || '图片预览'" class="preview-image" :style="{ cursor: zoom ? 'grab' : 'default' }" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// Props定义
|
||||
interface Props {
|
||||
src: string // 图片路径
|
||||
alt?: string // 图片描述
|
||||
width?: string | number // 图片宽度
|
||||
height?: string | number // 图片高度
|
||||
preview?: boolean // 是否启用预览功能
|
||||
zoom?: boolean // 是否允许缩放
|
||||
customClass?: string // 自定义CSS类名
|
||||
skeleton?: boolean // 是否显示骨架屏(懒加载)
|
||||
errorText?: string // 错误提示文本
|
||||
placeholder?: string // 占位图片
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: '',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
preview: true,
|
||||
zoom: false,
|
||||
customClass: '',
|
||||
skeleton: false,
|
||||
errorText: '图片加载失败',
|
||||
placeholder: ''
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<{
|
||||
load: []
|
||||
error: [error: Event]
|
||||
preview: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// 状态管理
|
||||
const isViewerOpen = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const errorEvent = ref<Event | null>(null)
|
||||
|
||||
// 计算容器样式
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
height: typeof props.height === 'number' ? `${props.height}px` : props.height
|
||||
}
|
||||
})
|
||||
|
||||
// 处理图片加载完成
|
||||
const handleLoad = (event: Event) => {
|
||||
isLoading.value = false
|
||||
hasError.value = false
|
||||
emit('load')
|
||||
}
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleError = (event: Event) => {
|
||||
isLoading.value = false
|
||||
hasError.value = true
|
||||
errorEvent.value = event
|
||||
emit('error', event)
|
||||
}
|
||||
|
||||
// 打开预览
|
||||
const openViewer = () => {
|
||||
if (!props.preview) return
|
||||
|
||||
isViewerOpen.value = true
|
||||
// 阻止背景滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
emit('preview')
|
||||
}
|
||||
|
||||
// 关闭预览
|
||||
const closeViewer = () => {
|
||||
isViewerOpen.value = false
|
||||
// 恢复背景滚动
|
||||
document.body.style.overflow = ''
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 缩略图样式 */
|
||||
.thumbnail {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.2s, opacity 0.3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.thumbnail:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.thumbnail.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 图片加载中状态 */
|
||||
.image-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片错误状态 */
|
||||
.image-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 4px;
|
||||
color: #6b7280;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 模态框过渡动画 */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 预览模态框样式 */
|
||||
.viewer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.viewer-container {
|
||||
position: relative;
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
animation: zoomIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: -20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-container {
|
||||
max-width: 98%;
|
||||
max-height: 98%;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
top: -40px;
|
||||
right: -10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<Transition name="message-fade">
|
||||
<div v-show="visible" class="message-container" :class="`message-${type}`" :style="customStyle">
|
||||
<div class="message-content">{{ message }}</div>
|
||||
<button v-if="closable" class="message-close" @click="close" aria-label="关闭">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
showIcon?: boolean
|
||||
closable?: boolean
|
||||
customClass?: string
|
||||
offset?: number
|
||||
center?: boolean
|
||||
}>()
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false)
|
||||
let timer: number | null = null
|
||||
|
||||
// 计算属性
|
||||
const customStyle = computed(() => {
|
||||
const style: Record<string, string> = {}
|
||||
if (props.offset) {
|
||||
style.marginTop = `${props.offset}px`
|
||||
}
|
||||
if (props.center) {
|
||||
style.textAlign = 'center'
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
// 方法
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
clearTimer()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
visible.value = true
|
||||
if (props.duration && props.duration > 0) {
|
||||
timer = window.setTimeout(() => {
|
||||
close()
|
||||
}, props.duration)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 消息类型样式 */
|
||||
.message-success {
|
||||
border-left: 4px solid #67C23A;
|
||||
background-color: #F0F9EB;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
border-left: 4px solid #F56C6C;
|
||||
background-color: #FEF0F0;
|
||||
}
|
||||
|
||||
.message-warning {
|
||||
border-left: 4px solid #E6A23C;
|
||||
background-color: #FDF6EC;
|
||||
}
|
||||
|
||||
.message-info {
|
||||
border-left: 4px solid #909399;
|
||||
background-color: #F4F4F5;
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
.message-icon {
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-success .message-icon {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.message-error .message-icon {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.message-warning .message-icon {
|
||||
color: #E6A23C;
|
||||
}
|
||||
|
||||
.message-info .message-icon {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.message-success .message-content {
|
||||
color: #306608;
|
||||
}
|
||||
|
||||
.message-error .message-content {
|
||||
color: #A60C0C;
|
||||
}
|
||||
|
||||
.message-warning .message-content {
|
||||
color: #9A5518;
|
||||
}
|
||||
|
||||
.message-info .message-content {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 关闭按钮样式 */
|
||||
.message-close {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.message-fade-enter-active,
|
||||
.message-fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.message-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.message-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.message-container {
|
||||
max-width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 鼠标悬停效果 */
|
||||
.message-container:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 消息容器的wrapper样式 */
|
||||
:global(.message-container-wrapper) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.message-container-wrapper > div) {
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { createApp, h } from 'vue'
|
||||
import Message from './Message.vue'
|
||||
|
||||
// 消息配置接口
|
||||
export interface MessageOptions {
|
||||
message: string
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
duration?: number
|
||||
showIcon?: boolean
|
||||
closable?: boolean
|
||||
customClass?: string
|
||||
offset?: number
|
||||
center?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
// 消息实例接口
|
||||
interface MessageInstance {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
// 消息容器
|
||||
const messageContainer = document.createElement('div')
|
||||
messageContainer.className = 'message-container-wrapper'
|
||||
document.body.appendChild(messageContainer)
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions: Partial<MessageOptions> = {
|
||||
duration: 3000,
|
||||
showIcon: true,
|
||||
closable: false,
|
||||
offset: 20,
|
||||
center: false
|
||||
}
|
||||
|
||||
// 消息管理类
|
||||
class MessageService {
|
||||
private instances: MessageInstance[] = []
|
||||
|
||||
// 创建消息实例
|
||||
private createMessage(options: string | MessageOptions): MessageInstance {
|
||||
// 标准化配置
|
||||
const finalOptions = typeof options === 'string'
|
||||
? { message: options, ...defaultOptions }
|
||||
: { ...defaultOptions, ...options }
|
||||
|
||||
// 创建容器元素
|
||||
const container = document.createElement('div')
|
||||
messageContainer.appendChild(container)
|
||||
|
||||
// 创建应用实例
|
||||
// 保存对this的引用以避免上下文问题
|
||||
const self = this
|
||||
const app = createApp({
|
||||
render: () => {
|
||||
return h(Message, {
|
||||
...finalOptions,
|
||||
onClose: () => {
|
||||
self.closeInstance(instance)
|
||||
finalOptions.onClose?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 挂载组件
|
||||
app.mount(container)
|
||||
|
||||
// 计算垂直偏移
|
||||
const verticalOffset = this.calculateOffset(finalOptions.offset ?? 0)
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${verticalOffset}px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
// 创建实例对象
|
||||
const instance: MessageInstance = {
|
||||
close: () => {
|
||||
this.closeInstance(instance)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存实例
|
||||
this.instances.push(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// 计算垂直偏移
|
||||
private calculateOffset(baseOffset: number): number {
|
||||
if (this.instances.length === 0) {
|
||||
return baseOffset
|
||||
}
|
||||
|
||||
let offset = baseOffset
|
||||
this.instances.forEach(() => {
|
||||
// 假设每个消息高度约为60px,间距为12px
|
||||
offset += 60 + 12
|
||||
})
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
// 关闭指定实例
|
||||
private closeInstance(instance: MessageInstance): void {
|
||||
const index = this.instances.indexOf(instance)
|
||||
if (index !== -1) {
|
||||
this.instances.splice(index, 1)
|
||||
|
||||
// 重新计算所有消息的位置
|
||||
this.updateOffsets()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新所有消息的位置
|
||||
private updateOffsets(): void {
|
||||
let offset = defaultOptions.offset || 20
|
||||
const messageElements = messageContainer.querySelectorAll('div > div')
|
||||
|
||||
messageElements.forEach((element, index) => {
|
||||
if (index < this.instances.length) {
|
||||
(element.parentElement as HTMLElement).style.top = `${offset}px`
|
||||
offset += 60 + 12
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示普通消息
|
||||
info(options: string | MessageOptions): MessageInstance {
|
||||
return this.createMessage(typeof options === 'string'
|
||||
? { message: options, type: 'info' }
|
||||
: { ...options, type: 'info' }
|
||||
)
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
success(options: string | MessageOptions): MessageInstance {
|
||||
return this.createMessage(typeof options === 'string'
|
||||
? { message: options, type: 'success' }
|
||||
: { ...options, type: 'success' }
|
||||
)
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
error(options: string | MessageOptions): MessageInstance {
|
||||
return this.createMessage(typeof options === 'string'
|
||||
? { message: options, type: 'error' }
|
||||
: { ...options, type: 'error' }
|
||||
)
|
||||
}
|
||||
|
||||
// 显示警告消息
|
||||
warning(options: string | MessageOptions): MessageInstance {
|
||||
return this.createMessage(typeof options === 'string'
|
||||
? { message: options, type: 'warning' }
|
||||
: { ...options, type: 'warning' }
|
||||
)
|
||||
}
|
||||
|
||||
// 关闭所有消息
|
||||
closeAll(): void {
|
||||
this.instances.forEach(instance => {
|
||||
instance.close()
|
||||
})
|
||||
this.instances = []
|
||||
}
|
||||
}
|
||||
|
||||
// 导出消息服务实例
|
||||
export const message = new MessageService()
|
||||
|
||||
// 提供默认导出
|
||||
export default message
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-[#FCEEF3] overflow-hidden">
|
||||
<!-- 顶部头图 -->
|
||||
<div class="w-full overflow-hidden relative">
|
||||
<img src="/头图.png" alt="活动宣传" class="w-full h-auto" />
|
||||
|
||||
<!-- 顶部Logo和标题 -->
|
||||
<div class="absolute top-0 left-0 py-4 h-full w-full flex flex-col items-start justify-center px-4">
|
||||
<div class="flex items-start mb-4">
|
||||
<img src="/logo.png" alt="中国福利彩票" class="h-8 pl-2" />
|
||||
</div>
|
||||
<img src="/ziti.png" alt="宁福您彩 新婚送福" class="h-15" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<div class="p-4 -mt-10 z-10 relative">
|
||||
<div class="max-w-md mx-auto bg-white rounded-xl shadow-sm p-6">
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center justify-center text-xl text-[#E8424D] mb-6">
|
||||
<img src="/爱心点缀1.png" alt="" class="h-5">
|
||||
<span class="px-2">领取专属喜礼</span>
|
||||
<img src="/爱心点缀2.png" alt="" class="h-5">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- 手机号输入 -->
|
||||
<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"
|
||||
class="outline-none focus:border-[#E8424D] transition-colors flex-1 h-11" />
|
||||
|
||||
<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}秒后重发` : '获取验证码' }}
|
||||
</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"
|
||||
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 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 class="relative inline-block" @click="handleScanClick">
|
||||
<img src="/扫描.png" alt="" class="h-20">
|
||||
<div
|
||||
class="h-16 w-26 absolute border border-[#f0f0f0] rounded-md top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex justify-center">
|
||||
<span class="text-[#E2E4E9] text-xs pt-1">结婚证</span>
|
||||
</div>
|
||||
<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>
|
||||
<span class="inline-block w-25 text-right">结婚证字号:</span>
|
||||
<span class="pl-2">{{ 11111 }}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-block w-25 text-right">男方姓名:</span>
|
||||
<span class="pl-2">{{ 11111 }}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="inline-block w-25 text-right">女方姓名:</span>
|
||||
<span class="pl-2">{{ 11111 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- 活动结束状态 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import message from '../../components/message';
|
||||
import apiService from '../../services/apiService'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const ocrUploadId = ref<HTMLInputElement>();
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
phone: '',
|
||||
verificationCode: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 检查活动是否已结束
|
||||
apiService.getCurrentActivity().then((response: any) => {
|
||||
console.log(response);
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
// 倒计时
|
||||
const countdown = ref(0)
|
||||
let countdownTimer: number | null = null
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = () => {
|
||||
const { phone } = formData.value
|
||||
|
||||
// 简单手机号验证
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
message.error('请输入正确的手机号码')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用发送短信验证码接口
|
||||
apiService.sendSms({
|
||||
mobile: phone,
|
||||
type: 0,
|
||||
}).then(() => {
|
||||
// 开始倒计时
|
||||
countdown.value = 60
|
||||
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
|
||||
countdownTimer = window.setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--
|
||||
} else {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}).catch((error) => {
|
||||
console.error('发送验证码失败:', error)
|
||||
message.error('验证码发送失败,请稍后重试')
|
||||
})
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
const verifyCode = () => {
|
||||
const { verificationCode } = formData.value
|
||||
|
||||
if (verificationCode.length !== 6 || !/^\d{6}$/.test(verificationCode)) {
|
||||
message.error('请输入6位数字验证码')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用验证短信验证码接口
|
||||
apiService.login({
|
||||
mobile: formData.value.phone,
|
||||
smsCode: verificationCode,
|
||||
}).then(() => {
|
||||
message.success('验证码验证成功')
|
||||
}).catch((error) => {
|
||||
console.error('验证验证码失败:', error)
|
||||
message.error('验证码验证失败,请重试')
|
||||
})
|
||||
}
|
||||
|
||||
// 处理扫描点击事件
|
||||
const handleScanClick = () => {
|
||||
ocrUploadId.value?.click()
|
||||
}
|
||||
const handleImageChange = () => {
|
||||
const file = ocrUploadId.value?.files?.[0]
|
||||
if (!file) {
|
||||
message.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用上传OCR图片接口
|
||||
const formFormData = new FormData()
|
||||
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,
|
||||
uploadId: response.data.uploadId,
|
||||
}).then((res) => {
|
||||
console.log(res);
|
||||
}).catch((error) => {
|
||||
console.error('解析OCR信息失败:', error)
|
||||
message.error('解析OCR信息失败,请稍后重试')
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error('发送验证码失败:', error)
|
||||
message.error('验证码发送失败,请稍后重试')
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error('上传OCR图片失败:', error)
|
||||
message.error('图片上传失败,请稍后重试')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 提交表单
|
||||
const submitForm = () => {
|
||||
if (!formData.value.phone || !formData.value.verificationCode) {
|
||||
message.error('请完善所有信息')
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟提交
|
||||
console.log('提交表单数据:', formData.value)
|
||||
message.success('提交成功,正在处理您的请求...')
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
import { onUnmounted } from 'vue'
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// 定义路由配置
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../pages/home/index.vue'),
|
||||
meta: {
|
||||
title: '首页'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由前置守卫,用于设置页面标题
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title as string
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
# API 文档(领取与兑奖流程)
|
||||
|
||||
本文件整理新婚送福活动前端页面所用接口,包括领取流程与兑奖页面改造所需的现有接口与拟新增接口。文档基于当前代码库(com-marriage-client 与 com-admin-client)。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 服务与端口:
|
||||
- 领取端(com-marriage-client):`http://101.35.149.39:8200`,应用名 `nxfc-marriage-client`
|
||||
- 后台端(com-admin-client):`http://101.35.149.39:8000`,应用名 `nxfc-admin-client`
|
||||
- 认证:
|
||||
- 站点登录与短信验证接口位于 `com-marriage-client` 的 `/marriage/common` 路径下。
|
||||
- 后台活动管理接口位于 `com-admin-client` 的 `/admin/activity` 路径下(通常用于管理端)。
|
||||
|
||||
---
|
||||
|
||||
## 领取流程页面
|
||||
|
||||
### 1)活动介绍页
|
||||
|
||||
用于展示当前活动的名称、时间和面额信息。
|
||||
|
||||
- 拟新增(面向领取端,便于前端直接获取展示数据)
|
||||
|
||||
- 方法:`GET`
|
||||
|
||||
- 路径:`/marriage/activity/current`
|
||||
|
||||
- 入参:无
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"activityName": "新婚送福",
|
||||
"activityStartTime": "2025-09-15 00:00:00",
|
||||
"activityEndTime": "2025-10-31 23:59:59",
|
||||
"money": 6000,
|
||||
"status": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 备注:如暂未在领取端提供该接口,可临时通过后台端获取列表接口获得活动信息:
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/admin/activity/list`
|
||||
- 入参:无
|
||||
- 出参:`ResultObject` 包含活动集合,字段参考 `MarriageActivity`。
|
||||
|
||||
### 2)OCR识别结婚证,自动带出信息,领取二维码页
|
||||
|
||||
为避免用户手动输入,新增 OCR 流程:上传结婚证图片并识别证件信息,前端使用识别结果自动填充表单,再进行短信校验与领取。
|
||||
|
||||
- OCR 上传图片 [新增]
|
||||
|
||||
- 方法:`POST`
|
||||
|
||||
- 路径:`/marriage/ocr/upload`
|
||||
|
||||
- 入参:`multipart/form-data`,字段:`file`(结婚证图片)
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "", "data": { "uploadId": "ab12cd34..." } }
|
||||
```
|
||||
|
||||
- 备注:服务端将图片内容以 Base64 缓存于 Redis(键:`OCR_UPLOAD-{uploadId}`,有效期 10 分钟),不落盘文件。
|
||||
|
||||
- 发送短信验证码
|
||||
|
||||
- 方法:`POST`
|
||||
|
||||
- 路径:`/marriage/common/sms`
|
||||
|
||||
- 入参:`CommSmsDTO`
|
||||
|
||||
- `mobile`:手机号(必填)
|
||||
- `type`:验证码类型,`0`=登录;`1`=兑换领取;`2`=OCR识别(本页使用 `2`) [扩展]
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "", "data": null }
|
||||
```
|
||||
|
||||
- 备注:当前实现对 `type=1` 的校验逻辑使用了 `code` 字段比对,可能与期望不一致(代码中以 `MarriageCode::getCode == mobile` 判断重复)。如需严格校验手机号唯一性,可调整实现。
|
||||
|
||||
- OCR 识别并返回证件信息(供前端自动填充) [新增]
|
||||
|
||||
- 方法:`POST`
|
||||
|
||||
- 路径:`/marriage/ocr/parse`
|
||||
|
||||
- 入参:`OcrParseDTO`
|
||||
|
||||
- `mobile`:手机号(必填)
|
||||
- `smsCode`:短信验证码(必填,取自上方 `/sms`,`type=2`)
|
||||
- `uploadId`:上传接口返回的标识(必填)
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"raw": "<百度OCR原始JSON字符串>",
|
||||
"words": ["宁夏回族自治区婚姻登记证", "字号6403812025001056", "男方张三", "女方李四", "登记日期2025-10-01"],
|
||||
"parsed": {
|
||||
"marriageNo": "6403812025001056",
|
||||
"husbandName": "张三",
|
||||
"wifeName": "李四",
|
||||
"registerDate": "2025-10-01"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 备注:前端可将 `parsed.marriageNo` 用于后续校验与领取,将 `husbandName`/`wifeName`用于展示或校验提示;若识别失败,提示重新上传清晰图片。
|
||||
|
||||
- 领取前校验(生成二维码前的校验与预览)
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/marriage/receiveCheck`
|
||||
- 入参:`MarriageCodeDTO`
|
||||
- `marriageNo`:结婚登记证号(必填,长度≥11,且需符合活动条件)
|
||||
- `receiveName`:领取人姓名(必填)
|
||||
- `receiveMobile`:领取人手机号(必填)
|
||||
- `code`:核销码(必填)
|
||||
- `smsCode`:短信验证码(必填,取自上方 `/sms`,`type=2`)
|
||||
- `signImage`:领取人电子签名(选填,校验阶段可为空)
|
||||
- `salesNo`:站点号(选填,用于落库统计)
|
||||
- 出参:`ResultObject<MarriageCodeVO>`,其中包含可用于二维码展示的 `code` 等字段。
|
||||
- 业务规则摘要:
|
||||
- 校验结婚证号格式与活动条件。
|
||||
- 校验手机号、结婚证号、核销码是否已参与或使用过。
|
||||
- 校验短信验证码正确性(Redis 缓存键:`VERICODE_MOBILE 1-<mobile>`)。
|
||||
|
||||
- 确认领取(落库并置为已核销)
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/marriage/receiveCode`
|
||||
- 入参:`MarriageCodeDTO`
|
||||
- 与 `receiveCheck` 基本一致,但 `signImage` 必填(签字图片,建议前端以 Base64 上传)
|
||||
- 出参:`ResultObject`(成功返回 200)
|
||||
- 落库行为:将 `MarriageCode` 的 `marriageNo`、`receiveName`、`receiveMobile`、`signImage`、`salesNo`、`receiveMoney=6000`、`receiveTime`、`status=1` 写入并更新。
|
||||
|
||||
### 3)查看已领取二维码页(包括二维码状态)
|
||||
|
||||
支持按站点和月份查看领取列表统计;也建议提供按核销码查询单条状态的接口。
|
||||
|
||||
- 已有:按站点号与月份分页统计列表
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/marriage/codeList`
|
||||
- 入参:`MarriageCodeDTO`
|
||||
- `salesNo`:站点号(必填)
|
||||
- `page`、`size`:分页参数(必填)
|
||||
- `dataTime`:月份(格式 `yyyy-MM`,可选,默认当前月)
|
||||
- 出参:`ResultObject<MarriageCodeVO>`
|
||||
- `codeList`:列表(`MarriageCodeListVO`)
|
||||
- `receiveCount`:数量合计(字符串)
|
||||
- `receiveAmount`:金额合计(字符串,单位分)
|
||||
|
||||
- 拟新增:按核销码查询状态(用于二维码状态页)
|
||||
|
||||
- 方法:`POST`
|
||||
|
||||
- 路径:`/marriage/code/status`
|
||||
|
||||
- 入参:
|
||||
|
||||
```json
|
||||
{ "code": "xxxxxx" }
|
||||
```
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"code": "xxxxxx",
|
||||
"status": 0,
|
||||
"marriageNo": null,
|
||||
"receiveName": null,
|
||||
"receiveMobile": null,
|
||||
"receiveTime": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 备注:`status=0` 未核销;`status=1` 已核销。已核销时返回对应的领取人信息与时间。
|
||||
|
||||
---
|
||||
|
||||
## OCR识别流程(免手动输入)
|
||||
|
||||
### 端到端流程
|
||||
|
||||
1. 前端上传结婚证图片:`POST /marriage/ocr/upload` [新增] → 返回 `uploadId`
|
||||
2. 发送短信验证码:`POST /marriage/common/sms`(`type=2`) [扩展]
|
||||
3. 识别证件信息:`POST /marriage/ocr/parse`(`mobile`、`smsCode`、`uploadId`) [新增] → 返回 `parsed.marriageNo` 等信息
|
||||
4. 自动填充领取表单,走校验与领取:`POST /marriage/receiveCheck` → `POST /marriage/receiveCode`
|
||||
|
||||
### 安全与权限
|
||||
|
||||
- `POST /marriage/ocr/**` 与 `POST /marriage/common/sms` 已放行匿名访问,用于领取端无登录场景。
|
||||
- 后端不存储用户上传的原始图片文件,仅在 Redis 缓存 Base64(10 分钟),减少存储与泄露风险。
|
||||
|
||||
### 配置项标注
|
||||
|
||||
- 百度 OCR 相关配置(开发环境) [新增]
|
||||
- `baidu.ocr.appId`、`baidu.ocr.apiKey`、`baidu.ocr.secretKey`(建议通过环境变量注入)
|
||||
- `baidu.ocr.authUrl`(默认:`https://aip.baidubce.com/oauth/2.0/token`)
|
||||
- `baidu.ocr.generalUrl`(默认:`https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic`)
|
||||
- `baidu.ocr.storagePath`(当前不落盘,仅占位)
|
||||
|
||||
### 测试说明
|
||||
|
||||
- 集成测试覆盖 OCR 上传与识别接口,使用本地伪服务模拟百度返回 [新增]
|
||||
- 测试类:`com-marriage-client/src/test/java/com/jinrui/marriage/client/controller/OcrControllerTest.java`
|
||||
- 通过属性重定向百度 URL 到本地:`baidu.ocr.authUrl`、`baidu.ocr.generalUrl`
|
||||
- 断言返回的 `parsed.marriageNo` 包含示例字号片段
|
||||
|
||||
---
|
||||
|
||||
## 兑奖页面(改为自动带出结婚证号、手机号、姓名)
|
||||
|
||||
目标:在兑奖页输入(或扫码)核销码后,自动拉取该码对应的 `marriageNo`、`receiveMobile`、`receiveName` 等信息进行表单预填。
|
||||
|
||||
- 拟新增:根据核销码获取领取信息(用于自动带出)
|
||||
|
||||
- 方法:`POST`
|
||||
|
||||
- 路径:`/marriage/code/info`
|
||||
|
||||
- 入参:
|
||||
|
||||
```json
|
||||
{ "code": "xxxxxx" }
|
||||
```
|
||||
|
||||
- 出参(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"code": "xxxxxx",
|
||||
"status": 1,
|
||||
"marriageNo": "NX-2025-XXXXXXXXX",
|
||||
"receiveName": "张三",
|
||||
"receiveMobile": "13800000000",
|
||||
"receiveTime": "2025-10-10 12:00:00",
|
||||
"salesNo": "1001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 备注:
|
||||
|
||||
- 若未领取(`status=0`),则上述字段为空或不返回;前端可引导先走“领取流程”。
|
||||
- 若已领取(`status=1`),则用于自动填充兑奖页的表单,减少重复输入。
|
||||
|
||||
---
|
||||
|
||||
## 站点登录(如需)
|
||||
|
||||
用于站点管理后台或业务员端登录。
|
||||
|
||||
- 发送登录短信验证码(type=0)
|
||||
- 方法:`POST`
|
||||
- 路径:`/marriage/common/sms`
|
||||
- 入参:`CommSmsDTO`(`mobile`,`type=0`)
|
||||
- 出参:`ResultObject`
|
||||
|
||||
- 登录
|
||||
- 方法:`POST`
|
||||
- 路径:`/marriage/common/login`
|
||||
- 入参:`MarrigeLoginDTO`
|
||||
- `mobile`、`password`(当前固定为 `88888888`)、`smsCode`
|
||||
- 出参:`ResultObject`(包含登录票据与站点信息)
|
||||
|
||||
---
|
||||
|
||||
## 返回结构与错误码
|
||||
|
||||
- 所有接口统一返回 `ResultObject`:
|
||||
- `code`:业务状态码,`200` 表示成功
|
||||
- `msg`:提示信息
|
||||
- `data`:实际数据载体(对象或集合)
|
||||
- 常见失败信息(摘录):
|
||||
- `结婚证字号不能为空/长度不对/不符合活动条件`
|
||||
- `领取人姓名不能为空/领取人手机号不能为空`
|
||||
- `核验码不能为空/此代金卷不正确/此代金卷已使用过`
|
||||
- `该领取人已领取过新婚送福活动刮刮乐/这个证号已参与过活动`
|
||||
- `验证码错误,请重新输入`
|
||||
|
||||
---
|
||||
|
||||
## 字段模型参考
|
||||
|
||||
- `MarriageCodeDTO`
|
||||
- `marriageNo`、`receiveName`、`code`、`receiveMobile`、`signImage`、`salesNo`、`smsCode`、`dataTime`
|
||||
- `CommSmsDTO`
|
||||
- `mobile`、`type`(`0` 登录,`1` 兑换领取)
|
||||
- `MarriageCodeVO` / `MarriageCodeListVO`
|
||||
- 列表与详情视图,包含领取统计与单条领取信息
|
||||
|
||||
---
|
||||
|
||||
## 前端配合建议
|
||||
|
||||
- 二维码内容直接使用核销码 `code` 编码,状态页通过 `code/status` 查询。
|
||||
- 兑奖页在扫描或输入核销码后,先调用 `code/info` 自动填充,再根据业务需要提交后续操作。
|
||||
- 如本地开发环境不具备外网资源(DB/Redis/微信),建议提供 `local` profile,临时屏蔽定时任务与外部依赖,仅用于接口联调与页面开发。
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import request from '@/utils/axios'
|
||||
|
||||
// 接口类型定义
|
||||
export interface ActivityInfo {
|
||||
activityName: string
|
||||
activityStartTime: string
|
||||
activityEndTime: string
|
||||
money: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface OcrUploadResponse {
|
||||
uploadId: string
|
||||
}
|
||||
|
||||
export interface OcrParseResponse {
|
||||
raw: string
|
||||
words: string[]
|
||||
parsed: {
|
||||
marriageNo: string
|
||||
husbandName: string
|
||||
wifeName: string
|
||||
registerDate: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarriageCodeDTO {
|
||||
marriageNo?: string
|
||||
receiveName?: string
|
||||
receiveMobile?: string
|
||||
code?: string
|
||||
smsCode?: string
|
||||
signImage?: string
|
||||
salesNo?: string
|
||||
dataTime?: string
|
||||
}
|
||||
|
||||
export interface MarriageCodeVO {
|
||||
code: string
|
||||
status: number
|
||||
marriageNo: string | null
|
||||
receiveName: string | null
|
||||
receiveMobile: string | null
|
||||
receiveTime: string | null
|
||||
salesNo?: string
|
||||
}
|
||||
|
||||
export interface MarriageCodeListVO {
|
||||
codeList: MarriageCodeVO[]
|
||||
receiveCount: string
|
||||
receiveAmount: string
|
||||
}
|
||||
|
||||
export interface CommSmsDTO {
|
||||
mobile: string
|
||||
type: 0 | 1 | 2; // 0=登录;1=兑换领取;2=OCR识别
|
||||
}
|
||||
|
||||
// API服务实现
|
||||
export const apiService = {
|
||||
// 活动介绍页
|
||||
// 获取当前活动信息
|
||||
getCurrentActivity() {
|
||||
return request.get<ActivityInfo>('/marriage/activity/current')
|
||||
},
|
||||
|
||||
// OCR识别相关接口
|
||||
// 上传OCR图片
|
||||
uploadOcrImage(formData: FormData) {
|
||||
return request.post<OcrUploadResponse>('/marriage/ocr/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 发送短信验证码
|
||||
sendSms(data: CommSmsDTO) {
|
||||
return request.post('/marriage/common/sms', data)
|
||||
},
|
||||
|
||||
// OCR识别并返回证件信息
|
||||
parseOcrInfo(data: {
|
||||
mobile: string
|
||||
smsCode: string
|
||||
uploadId: string
|
||||
}) {
|
||||
return request.post<OcrParseResponse>('/marriage/ocr/parse', data)
|
||||
},
|
||||
|
||||
// 领取流程相关接口
|
||||
// 领取前校验(生成二维码前的校验与预览)
|
||||
receiveCheck(data: MarriageCodeDTO) {
|
||||
return request.post<MarriageCodeVO>('/marriage/receiveCheck', data)
|
||||
},
|
||||
|
||||
// 确认领取(落库并置为已核销)
|
||||
receiveCode(data: MarriageCodeDTO) {
|
||||
return request.post('/marriage/receiveCode', data)
|
||||
},
|
||||
|
||||
// 二维码状态相关接口
|
||||
// 按站点号与月份分页统计列表
|
||||
getCodeList(data: {
|
||||
salesNo: string
|
||||
page: number
|
||||
size: number
|
||||
dataTime?: string
|
||||
}) {
|
||||
return request.post<MarriageCodeListVO>('/marriage/codeList', data)
|
||||
},
|
||||
|
||||
// 按核销码查询状态
|
||||
getCodeStatus(data: { code: string }) {
|
||||
return request.post<MarriageCodeVO>('/marriage/code/status', data)
|
||||
},
|
||||
|
||||
// 兑奖页面相关接口
|
||||
// 根据核销码获取领取信息(用于自动带出)
|
||||
getCodeInfo(data: { code: string }) {
|
||||
return request.post<MarriageCodeVO>('/marriage/code/info', data)
|
||||
},
|
||||
|
||||
// 站点登录相关接口
|
||||
// 登录
|
||||
login(data: {
|
||||
mobile: string
|
||||
password?: string
|
||||
smsCode?: string
|
||||
}) {
|
||||
return request.post('/marriage/common/login', data)
|
||||
},
|
||||
|
||||
// 后台活动管理接口(可选,用于管理端)
|
||||
// 获取活动列表
|
||||
getActivityList() {
|
||||
return request.post('/admin/activity/list')
|
||||
},
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// API服务类型声明
|
||||
|
||||
// 接口类型定义
|
||||
export interface ActivityInfo {
|
||||
activityName: string
|
||||
activityStartTime: string
|
||||
activityEndTime: string
|
||||
money: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface OcrUploadResponse {
|
||||
uploadId: string
|
||||
}
|
||||
|
||||
export interface OcrParseResponse {
|
||||
raw: string
|
||||
words: string[]
|
||||
parsed: {
|
||||
marriageNo: string
|
||||
husbandName: string
|
||||
wifeName: string
|
||||
registerDate: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarriageCodeDTO {
|
||||
marriageNo?: string
|
||||
receiveName?: string
|
||||
receiveMobile?: string
|
||||
code?: string
|
||||
smsCode?: string
|
||||
signImage?: string
|
||||
salesNo?: string
|
||||
dataTime?: string
|
||||
}
|
||||
|
||||
export interface MarriageCodeVO {
|
||||
code: string
|
||||
status: number
|
||||
marriageNo: string | null
|
||||
receiveName: string | null
|
||||
receiveMobile: string | null
|
||||
receiveTime: string | null
|
||||
salesNo?: string
|
||||
}
|
||||
|
||||
export interface MarriageCodeListVO {
|
||||
codeList: MarriageCodeVO[]
|
||||
receiveCount: string
|
||||
receiveAmount: string
|
||||
}
|
||||
|
||||
export interface CommSmsDTO {
|
||||
mobile: string
|
||||
type: number // 0=登录;1=兑换领取;2=OCR识别
|
||||
}
|
||||
|
||||
// API服务接口定义
|
||||
export interface ApiService {
|
||||
// 活动介绍页
|
||||
// 获取当前活动信息
|
||||
getCurrentActivity(): Promise<any>
|
||||
|
||||
// OCR识别相关接口
|
||||
// 上传OCR图片
|
||||
uploadOcrImage(formData: FormData): Promise<any>
|
||||
|
||||
// 发送短信验证码
|
||||
sendSms(data: CommSmsDTO): Promise<any>
|
||||
|
||||
// OCR识别并返回证件信息
|
||||
parseOcrInfo(data: {
|
||||
mobile: string
|
||||
smsCode: string
|
||||
uploadId: string
|
||||
}): Promise<any>
|
||||
|
||||
// 领取流程相关接口
|
||||
// 领取前校验(生成二维码前的校验与预览)
|
||||
receiveCheck(data: MarriageCodeDTO): Promise<any>
|
||||
|
||||
// 确认领取(落库并置为已核销)
|
||||
receiveCode(data: MarriageCodeDTO): Promise<any>
|
||||
|
||||
// 二维码状态相关接口
|
||||
// 按站点号与月份分页统计列表
|
||||
getCodeList(data: {
|
||||
salesNo: string
|
||||
page: number
|
||||
size: number
|
||||
dataTime?: string
|
||||
}): Promise<any>
|
||||
|
||||
// 按核销码查询状态
|
||||
getCodeStatus(data: { code: string }): Promise<any>
|
||||
|
||||
// 兑奖页面相关接口
|
||||
// 根据核销码获取领取信息(用于自动带出)
|
||||
getCodeInfo(data: { code: string }): Promise<any>
|
||||
|
||||
// 站点登录相关接口
|
||||
// 登录
|
||||
login(data: {
|
||||
mobile: string
|
||||
password: string
|
||||
smsCode: string
|
||||
}): Promise<any>
|
||||
|
||||
// 后台活动管理接口(可选,用于管理端)
|
||||
// 获取活动列表
|
||||
getActivityList(): Promise<any>
|
||||
}
|
||||
|
||||
export const apiService: ApiService
|
||||
export default apiService
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// 为axios工具添加类型声明
|
||||
declare module '@/utils/axios' {
|
||||
import axios from 'axios'
|
||||
|
||||
interface CustomAxiosInstance extends axios.AxiosInstance {
|
||||
get<T = any>(url: string, config?: axios.AxiosRequestConfig): Promise<T>
|
||||
post<T = any>(url: string, data?: any, config?: axios.AxiosRequestConfig): Promise<T>
|
||||
put<T = any>(url: string, data?: any, config?: axios.AxiosRequestConfig): Promise<T>
|
||||
delete<T = any>(url: string, config?: axios.AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
const request: CustomAxiosInstance
|
||||
export default request
|
||||
export const get: CustomAxiosInstance['get']
|
||||
export const post: CustomAxiosInstance['post']
|
||||
export const put: CustomAxiosInstance['put']
|
||||
export const del: CustomAxiosInstance['delete']
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import axios from 'axios'
|
||||
|
||||
// 创建axios实例
|
||||
const request = axios.create({
|
||||
baseURL: '/api', // 与Vite代理配置一致
|
||||
timeout: 10000, // 请求超时时间
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加token等认证信息
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
if(response.data.code == 200){
|
||||
return response.data;
|
||||
}else{
|
||||
return Promise.reject(response.data);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// 错误处理
|
||||
console.error('API请求错误:', error)
|
||||
|
||||
// 可以根据错误状态码进行不同的处理
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 未授权,清除token并跳转到登录页
|
||||
localStorage.removeItem('token')
|
||||
// 这里可以添加跳转登录页的逻辑
|
||||
break
|
||||
case 403:
|
||||
alert('没有权限访问该资源')
|
||||
break
|
||||
case 404:
|
||||
alert('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
alert('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
alert(`请求错误: ${error.response.data.message || '未知错误'}`)
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
alert('网络错误,请检查网络连接')
|
||||
} else {
|
||||
// 请求配置出错
|
||||
alert('请求配置错误')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 导出常用的请求方法
|
||||
export default request
|
||||
export const { get, post, put, delete: del } = request
|
||||
BIN
static/logo.png
|
Before Width: | Height: | Size: 3.9 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts}",
|
||||
],
|
||||
theme: {},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/types/*.d.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://101.35.149.39:8200',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
secure: false,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||