hookehuyr

feat(结账): 重构结账页面,添加个人信息录入组件

将原有的表单输入替换为iframe嵌入的外部表单组件
添加个人信息录入状态管理,支持本地缓存已填写数据
清理页面加载时的历史个人信息录入标记
1 +<template>
2 + <van-popup
3 + v-model:show="visible"
4 + position="right"
5 + :style="{ width: '100%', height: '100%' }"
6 + :close-on-click-overlay="false"
7 + :lock-scroll="true"
8 + @close="handleClose"
9 + >
10 + <div class="info-entry-container">
11 + <!-- 头部导航栏 -->
12 + <!-- <div class="header">
13 + <h2 class="title">个人信息录入</h2>
14 + </div> -->
15 +
16 + <!-- iframe容器 -->
17 + <div class="iframe-container" ref="iframeContainer">
18 + <iframe
19 + ref="iframeRef"
20 + :src="iframeSrc"
21 + frameborder="0"
22 + class="form-iframe"
23 + @load="handleIframeLoad"
24 + ></iframe>
25 + </div>
26 + </div>
27 + </van-popup>
28 +</template>
29 +
30 +<script setup>
31 +import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
32 +
33 +/**
34 + * 组件属性定义
35 + */
36 +const props = defineProps({
37 + // 控制弹窗显示状态
38 + show: {
39 + type: Boolean,
40 + default: false
41 + },
42 + // iframe的src地址
43 + iframeSrc: {
44 + type: String,
45 + required: true
46 + }
47 +})
48 +
49 +/**
50 + * 组件事件定义
51 + */
52 +const emit = defineEmits(['update:show', 'data-received', 'close'])
53 +
54 +// 响应式数据
55 +const visible = ref(false)
56 +const iframeRef = ref(null)
57 +const iframeContainer = ref(null)
58 +
59 +/**
60 + * 监听show属性变化,同步更新visible状态
61 + */
62 +watch(() => props.show, (newVal) => {
63 + visible.value = newVal
64 +}, { immediate: true })
65 +
66 +/**
67 + * 监听visible变化,同步更新父组件的show状态
68 + */
69 +watch(visible, (newVal) => {
70 + emit('update:show', newVal)
71 +})
72 +
73 +/**
74 + * 处理iframe加载完成事件
75 + */
76 +const handleIframeLoad = () => {
77 + nextTick(() => {
78 + setupMessageListener()
79 + })
80 +}
81 +
82 +/**
83 + * 调整iframe高度以适应内容
84 + */
85 +const adjustIframeHeight = () => {
86 + // 移除高度限制,让iframe自然滚动
87 + console.log('iframe高度调整已禁用,允许自然滚动')
88 +}
89 +
90 +/**
91 + * 设置消息监听器,用于接收iframe内表单的数据
92 + */
93 +const setupMessageListener = () => {
94 + window.addEventListener('message', handleMessage)
95 +}
96 +
97 +/**
98 + * 处理来自iframe的消息
99 + * @param {MessageEvent} event - 消息事件
100 + */
101 +const handleMessage = (event) => {
102 + try {
103 + // // 验证消息来源(可根据实际情况调整)
104 + // const allowedOrigins = [
105 + // 'https://oa-dev.onwall.cn',
106 + // 'https://oa.onwall.cn',
107 + // 'http://localhost',
108 + // 'http://127.0.0.1'
109 + // ]
110 +
111 + // const isAllowedOrigin = allowedOrigins.some(origin =>
112 + // event.origin.startsWith(origin)
113 + // )
114 +
115 + // if (!isAllowedOrigin) {
116 + // console.warn('收到来自未授权源的消息:', event.origin)
117 + // return
118 + // }
119 +
120 + // 解析消息数据
121 + let messageData = event.data
122 + if (typeof messageData === 'string') {
123 + try {
124 + messageData = JSON.parse(messageData)
125 + } catch (e) {
126 + // 如果不是JSON格式,直接使用原始数据
127 + }
128 + }
129 +
130 + console.log('收到iframe消息:', messageData)
131 +
132 + // 检查是否是表单提交的数据
133 + if (messageData && (messageData.type === 'formSubmit')) {
134 + // 发送数据给父组件
135 + emit('data-received', messageData)
136 +
137 + // 关闭弹窗
138 + handleClose()
139 + }
140 + } catch (error) {
141 + console.error('处理iframe消息时出错:', error)
142 + }
143 +}
144 +
145 +/**
146 + * 处理弹窗关闭
147 + */
148 +const handleClose = () => {
149 + visible.value = false
150 + emit('close')
151 +}
152 +
153 +/**
154 + * 组件挂载时的初始化
155 + */
156 +onMounted(() => {
157 + // 如果需要在挂载时就设置监听器
158 + setupMessageListener()
159 +})
160 +
161 +/**
162 + * 组件卸载时清理
163 + */
164 +onUnmounted(() => {
165 + window.removeEventListener('message', handleMessage)
166 +})
167 +
168 +/**
169 + * 暴露给父组件的方法
170 + */
171 +defineExpose({
172 + close: handleClose,
173 + adjustHeight: adjustIframeHeight
174 +})
175 +</script>
176 +
177 +<style lang="less" scoped>
178 +.info-entry-container {
179 + width: 100%;
180 + height: 100%;
181 + display: flex;
182 + flex-direction: column;
183 + background-color: #f5f5f5;
184 +}
185 +
186 +.header {
187 + display: flex;
188 + align-items: center;
189 + padding: 12px 16px;
190 + background-color: #fff;
191 + border-bottom: 1px solid #eee;
192 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
193 + position: relative;
194 + z-index: 10;
195 +}
196 +
197 +.close-btn {
198 + background: none;
199 + border: none;
200 + padding: 8px;
201 + cursor: pointer;
202 + display: flex;
203 + align-items: center;
204 + justify-content: center;
205 + border-radius: 4px;
206 + transition: background-color 0.2s;
207 +
208 + &:hover {
209 + background-color: #f0f0f0;
210 + }
211 +
212 + &:active {
213 + background-color: #e0e0e0;
214 + }
215 +}
216 +
217 +.title {
218 + flex: 1;
219 + text-align: center;
220 + font-size: 16px;
221 + font-weight: 500;
222 + color: #333;
223 + margin: 0;
224 +}
225 +
226 +.iframe-container {
227 + flex: 1;
228 + overflow: auto;
229 + background-color: #fff;
230 +}
231 +
232 +.form-iframe {
233 + width: 100%;
234 + height: 100%;
235 + border: none;
236 + display: block;
237 +}
238 +
239 +// 响应式设计
240 +@media (max-width: 768px) {
241 + .header {
242 + padding: 10px 12px;
243 + }
244 +
245 + .title {
246 + font-size: 14px;
247 + }
248 +
249 + .close-btn {
250 + padding: 6px;
251 + }
252 +}
253 +</style>
...@@ -80,61 +80,15 @@ ...@@ -80,61 +80,15 @@
80 <div class="px-4 pt-4"> 80 <div class="px-4 pt-4">
81 <form @submit.prevent="handleSubmit"> 81 <form @submit.prevent="handleSubmit">
82 <FrostedGlass class="rounded-xl p-4 mb-4"> 82 <FrostedGlass class="rounded-xl p-4 mb-4">
83 - <h3 class="font-medium mb-3">个人信息</h3> 83 + <!-- 预览个人信息iframe -->
84 - 84 + <iframe
85 - <div class="space-y-3"> 85 + v-if="!showInfoEntry"
86 - <div> 86 + :src="iframeInfoSrc"
87 - <label class="block text-sm text-gray-600 mb-1">姓名 <span class="text-red-500">*</span></label> 87 + class="w-full border-0"
88 - <input 88 + ref="infoEntryIframe"
89 - v-model="formData.receive_name" 89 + style="width: 100%; min-height: 600px; height: auto; border: none;"
90 - type="text" 90 + frameborder="0"
91 - class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" 91 + ></iframe>
92 - placeholder="请输入您的姓名"
93 - required
94 - />
95 - </div>
96 -
97 - <div>
98 - <label class="block text-sm text-gray-600 mb-1">手机号码 <span class="text-red-500">*</span></label>
99 - <input
100 - v-model="formData.receive_phone"
101 - type="tel"
102 - class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
103 - placeholder="请输入您的手机号码"
104 - required
105 - />
106 - </div>
107 -
108 - <!-- <div>
109 - <label class="block text-sm text-gray-600 mb-1">电子邮箱</label>
110 - <input
111 - v-model="formData.receive_email"
112 - type="email"
113 - class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
114 - placeholder="请输入您的邮箱(选填)"
115 - />
116 - </div> -->
117 -
118 - <div>
119 - <!-- <label class="block text-sm text-gray-600 mb-1">联系地址 <span class="text-red-500">*</span></label> -->
120 - <label class="block text-sm text-gray-600 mb-1">联系地址</label>
121 - <input
122 - v-model="formData.receive_address"
123 - type="text"
124 - class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
125 - placeholder="请输入您的详细地址"
126 - />
127 - </div>
128 -
129 - <div>
130 - <label class="block text-sm text-gray-600 mb-1">备注</label>
131 - <textarea
132 - v-model="formData.notes"
133 - class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none h-20"
134 - placeholder="有什么需要我们注意的事项?(选填)"
135 - />
136 - </div>
137 - </div>
138 </FrostedGlass> 92 </FrostedGlass>
139 93
140 <FrostedGlass class="rounded-xl p-4 mb-6"> 94 <FrostedGlass class="rounded-xl p-4 mb-6">
...@@ -276,43 +230,89 @@ ...@@ -276,43 +230,89 @@
276 </WechatPayment> 230 </WechatPayment>
277 </div> 231 </div>
278 </van-popup> 232 </van-popup>
233 +
234 + <!-- 个人信息录入弹窗 -->
235 + <FormPage
236 + v-model:show="showInfoEntry"
237 + :iframe-src="iframeSrc"
238 + @data-received="handleInfoEntryComplete"
239 + @close="handleInfoEntryClose"
240 + />
279 </AppLayout> 241 </AppLayout>
280 </template> 242 </template>
281 243
282 <script setup> 244 <script setup>
283 -import { ref } from 'vue' 245 +import { ref, onMounted, onUnmounted, watch } from 'vue'
284 import { useRoute, useRouter } from 'vue-router' 246 import { useRoute, useRouter } from 'vue-router'
285 import AppLayout from '@/components/layout/AppLayout.vue' 247 import AppLayout from '@/components/layout/AppLayout.vue'
286 import FrostedGlass from '@/components/ui/FrostedGlass.vue' 248 import FrostedGlass from '@/components/ui/FrostedGlass.vue'
287 import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' 249 import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
288 import WechatPayment from '@/components/payment/WechatPayment.vue' 250 import WechatPayment from '@/components/payment/WechatPayment.vue'
251 +import FormPage from '@/components/infoEntry/formPage.vue'
289 import { useCart } from '@/contexts/cart' 252 import { useCart } from '@/contexts/cart'
290 import { useTitle } from '@vueuse/core' 253 import { useTitle } from '@vueuse/core'
291 import { getUserInfoAPI } from "@/api/users"; 254 import { getUserInfoAPI } from "@/api/users";
292 255
293 const $route = useRoute() 256 const $route = useRoute()
294 const $router = useRouter() 257 const $router = useRouter()
295 -useTitle($route.meta.title) 258 +// useTitle($route.meta.title)
296 const router = useRouter() 259 const router = useRouter()
297 const { items: cartItems, mode, getTotalPrice, handleCheckout, clearCart, removeFromCart } = useCart() 260 const { items: cartItems, mode, getTotalPrice, handleCheckout, clearCart, removeFromCart } = useCart()
298 261
299 // Form state 262 // Form state
300 const formData = ref({ 263 const formData = ref({
301 - receive_name: '',
302 - receive_phone: '',
303 - receive_email: '',
304 - receive_address: '',
305 - notes: '',
306 pay_type: 'WeChat' 264 pay_type: 'WeChat'
307 }) 265 })
308 266
267 +// 存储当前课程的localStorage键名,用于页面离开时清理
268 +let currentInfoEntryKey = null
309 269
310 onMounted(async () => { 270 onMounted(async () => {
311 const { code, data } = await getUserInfoAPI(); 271 const { code, data } = await getUserInfoAPI();
312 - if (code) { 272 +
313 - // 获取默认用户信息 273 + // 页面加载时立即检查cartItems中是否有form_url
314 - formData.value.receive_name = data.user.name ? data.user.name : ''; 274 + if (cartItems.value && cartItems.value.length > 0) {
315 - formData.value.receive_phone = data.user.mobile ? data.user.mobile : ''; 275 + const itemWithForm = cartItems.value.find(item => item.form_url)
276 +
277 + if (itemWithForm && itemWithForm.form_url) {
278 + // 检查用户是否已经录入过个人信息
279 + const infoEntryKey = `info_entry_completed_${itemWithForm.id}`
280 + currentInfoEntryKey = infoEntryKey // 保存键名用于清理
281 + const savedInfoData = localStorage.getItem(infoEntryKey)
282 +
283 + console.log('检查个人信息录入状态:', {
284 + courseId: itemWithForm.id,
285 + hasSavedData: !!savedInfoData
286 + })
287 +
288 + if (!savedInfoData) {
289 + // 只有未录入过信息时才弹出弹窗
290 + showInfoEntry.value = true
291 + iframeSrc.value = itemWithForm.form_url
292 + console.log('首次录入,显示个人信息录入弹窗')
293 + } else {
294 + // 已录入过信息,从缓存中恢复数据并显示预览
295 + try {
296 + const infoData = JSON.parse(savedInfoData)
297 + formData.value.customize_id = infoData.customize_id
298 + iframeInfoSrc.value = infoData.form_url + '&page_type=info' + '&data_id=' + infoData.customize_id
299 + console.log('从缓存恢复个人信息数据:', infoData)
300 + } catch (error) {
301 + console.error('解析缓存数据失败:', error)
302 + // 如果解析失败,重新录入
303 + showInfoEntry.value = true
304 + iframeSrc.value = itemWithForm.form_url
305 + }
306 + }
307 + }
308 + }
309 +})
310 +
311 +// 页面离开时清理localStorage中的录入状态标记
312 +onUnmounted(() => {
313 + if (currentInfoEntryKey) {
314 + localStorage.removeItem(currentInfoEntryKey)
315 + console.log('页面离开,清理个人信息录入状态:', currentInfoEntryKey)
316 } 316 }
317 }) 317 })
318 318
...@@ -323,6 +323,27 @@ const orderStatus = ref('') ...@@ -323,6 +323,27 @@ const orderStatus = ref('')
323 const isProcessing = ref(false) 323 const isProcessing = ref(false)
324 const orderComplete = ref(false) 324 const orderComplete = ref(false)
325 325
326 +// 个人信息录入相关状态
327 +const showInfoEntry = ref(false)
328 +// 个人信息录入弹窗的iframe地址
329 +const iframeSrc = ref(null)
330 +// 个人信息录入弹窗的iframe地址
331 +const iframeInfoSrc = ref(null)
332 +
333 +/**
334 + * 监听个人信息录入弹窗状态,动态修改页面标题
335 + */
336 +watch(showInfoEntry, (newVal) => {
337 + if (newVal) {
338 + // 弹窗打开时,修改标题为"个人信息录入"
339 + // document.title = '个人信息录入'
340 + useTitle('个人信息录入')
341 + } else {
342 + // 弹窗关闭时,修改标题为"结账"
343 + useTitle($route.meta.title)
344 + }
345 +})
346 +
326 // 确认对话框状态 347 // 确认对话框状态
327 const showConfirmDialog = ref(false) 348 const showConfirmDialog = ref(false)
328 const itemToDelete = ref(null) 349 const itemToDelete = ref(null)
...@@ -342,12 +363,6 @@ const handleImageError = (e) => { ...@@ -342,12 +363,6 @@ const handleImageError = (e) => {
342 const handleSubmit = async (e) => { 363 const handleSubmit = async (e) => {
343 try { 364 try {
344 // 表单验证 365 // 表单验证
345 - if (!formData.value.receive_name?.trim()) {
346 - throw new Error('请输入姓名')
347 - }
348 - if (!formData.value.receive_phone?.trim()) {
349 - throw new Error('请输入手机号码')
350 - }
351 if (!formData.value.pay_type) { 366 if (!formData.value.pay_type) {
352 throw new Error('请选择支付方式') 367 throw new Error('请选择支付方式')
353 } 368 }
...@@ -378,6 +393,53 @@ const handleSubmit = async (e) => { ...@@ -378,6 +393,53 @@ const handleSubmit = async (e) => {
378 } 393 }
379 } 394 }
380 395
396 +// 处理个人信息录入完成
397 +const handleInfoEntryComplete = async (data) => {
398 + try {
399 + console.log('收到个人信息录入数据:', data)
400 +
401 + // cartItems是数组,需要找到包含form的项目
402 + const itemWithForm = cartItems.value.find(item => item.form)
403 + const formValue = itemWithForm ? itemWithForm.form : null
404 +
405 + // 把个人信息录入数据赋值给formData
406 + formData.value = { ...formData.value, form: formValue, customize_id: data.id }
407 +
408 + // 标记该课程的个人信息已录入完成,并保存相关数据
409 + if (itemWithForm && itemWithForm.id) {
410 + const infoEntryKey = `info_entry_completed_${itemWithForm.id}`
411 + const infoData = {
412 + completed: true,
413 + customize_id: data.id,
414 + form_url: itemWithForm.form_url,
415 + timestamp: Date.now()
416 + }
417 + localStorage.setItem(infoEntryKey, JSON.stringify(infoData))
418 + console.log('个人信息录入完成,已保存状态和数据:', infoEntryKey, infoData)
419 + }
420 +
421 + isProcessing.value = true
422 + } catch (error) {
423 + } finally {
424 + isProcessing.value = false
425 + }
426 +}
427 +
428 +// 处理个人信息录入关闭
429 +const handleInfoEntryClose = () => {
430 + showInfoEntry.value = false
431 +
432 + // cartItems是数组,需要找到包含form_url的项目
433 + const itemWithForm = cartItems.value.find(item => item.form_url)
434 + if (itemWithForm && itemWithForm.form_url) {
435 + // 写入地址查询详情
436 + iframeInfoSrc.value = itemWithForm.form_url + '&page_type=info' + '&data_id=' + formData.value.customize_id
437 + console.log('个人信息录入弹窗关闭,iframe地址:', iframeInfoSrc.value)
438 + } else {
439 + console.log('未找到包含form_url的购物车项目')
440 + }
441 +}
442 +
381 // 处理支付成功 443 // 处理支付成功
382 const handlePaymentSuccess = () => { 444 const handlePaymentSuccess = () => {
383 orderComplete.value = true 445 orderComplete.value = true
......
...@@ -449,13 +449,25 @@ const handlePurchase = () => { ...@@ -449,13 +449,25 @@ const handlePurchase = () => {
449 } 449 }
450 450
451 if (course.value) { 451 if (course.value) {
452 - addToCart({ 452 + // 调试日志:检查course.value.form_url的值
453 + console.log('CourseDetailPage - course.value.form_url:', course.value.form_url)
454 + console.log('CourseDetailPage - 完整course数据:', course.value)
455 +
456 + const cartItem = {
453 id: course.value.id, 457 id: course.value.id,
454 type: 'course', 458 type: 'course',
455 title: course.value.title, 459 title: course.value.title,
456 price: course.value.price, 460 price: course.value.price,
457 - imageUrl: course.value.imageUrl 461 + imageUrl: course.value.imageUrl,
458 - }) 462 + form: course.value.form, // 报名关联的表单
463 + // 需要把当前页面域名拼写进去
464 + form_url: window.location.origin + course.value.form_url, // 课程关联的表单的 URL
465 + }
466 +
467 + // 调试日志:检查传递给addToCart的数据
468 + console.log('CourseDetailPage - 传递给addToCart的数据:', cartItem)
469 +
470 + addToCart(cartItem)
459 proceedToCheckout() 471 proceedToCheckout()
460 } 472 }
461 } 473 }
...@@ -520,6 +532,20 @@ onMounted(async () => { ...@@ -520,6 +532,20 @@ onMounted(async () => {
520 router.push('/courses') 532 router.push('/courses')
521 } 533 }
522 } 534 }
535 +
536 +
537 + // 进入页面时清理所有info_entry_completed_开头的localStorage标记
538 + const keysToRemove = []
539 + for (let i = 0; i < localStorage.length; i++) {
540 + const key = localStorage.key(i)
541 + if (key && key.startsWith('info_entry_completed_')) {
542 + keysToRemove.push(key)
543 + }
544 + }
545 + keysToRemove.forEach(key => {
546 + localStorage.removeItem(key)
547 + console.log('清理历史个人信息录入标记:', key)
548 + })
523 }) 549 })
524 550
525 551
......