refactor(cart): 将购物车逻辑从React迁移到Vue
迁移购物车上下文逻辑,从React的CartContext.jsx迁移到Vue的cart.js,以支持Vue项目的需求。新的实现使用Vue的ref和provide/inject API,并保留了原有的功能,如购物车管理、本地存储同步和结账流程。
Showing
5 changed files
with
985 additions
and
237 deletions
src/contexts/CartContext.jsx
deleted
100644 → 0
| 1 | -import React, { createContext, useContext, useState, useEffect } from 'react'; | ||
| 2 | -import { useNavigate } from 'react-router-dom'; | ||
| 3 | - | ||
| 4 | -// Create a context for the cart | ||
| 5 | -const CartContext = createContext(); | ||
| 6 | - | ||
| 7 | -// Custom hook to use the cart context | ||
| 8 | -export const useCart = () => { | ||
| 9 | - const context = useContext(CartContext); | ||
| 10 | - if (!context) { | ||
| 11 | - throw new Error('useCart must be used within a CartProvider'); | ||
| 12 | - } | ||
| 13 | - return context; | ||
| 14 | -}; | ||
| 15 | - | ||
| 16 | -// Cart provider component | ||
| 17 | -export const CartProvider = ({ children }) => { | ||
| 18 | - const [cartItems, setCartItems] = useState([]); | ||
| 19 | - const navigate = useNavigate(); | ||
| 20 | - | ||
| 21 | - // Load cart from localStorage on component mount | ||
| 22 | - useEffect(() => { | ||
| 23 | - const storedCart = localStorage.getItem('cart'); | ||
| 24 | - if (storedCart) { | ||
| 25 | - try { | ||
| 26 | - setCartItems(JSON.parse(storedCart)); | ||
| 27 | - } catch (error) { | ||
| 28 | - console.error('Failed to parse cart from localStorage:', error); | ||
| 29 | - // Reset cart if there's an error | ||
| 30 | - localStorage.removeItem('cart'); | ||
| 31 | - } | ||
| 32 | - } | ||
| 33 | - }, []); | ||
| 34 | - | ||
| 35 | - // Save cart to localStorage whenever it changes | ||
| 36 | - useEffect(() => { | ||
| 37 | - localStorage.setItem('cart', JSON.stringify(cartItems)); | ||
| 38 | - }, [cartItems]); | ||
| 39 | - | ||
| 40 | - // Add an item to the cart | ||
| 41 | - const addToCart = (item) => { | ||
| 42 | - setCartItems(prevItems => { | ||
| 43 | - // Check if item already exists in cart | ||
| 44 | - const existingItemIndex = prevItems.findIndex(i => | ||
| 45 | - i.id === item.id && i.type === item.type | ||
| 46 | - ); | ||
| 47 | - | ||
| 48 | - if (existingItemIndex >= 0) { | ||
| 49 | - // Item exists, update the quantity | ||
| 50 | - const updatedItems = [...prevItems]; | ||
| 51 | - updatedItems[existingItemIndex] = { | ||
| 52 | - ...updatedItems[existingItemIndex], | ||
| 53 | - quantity: updatedItems[existingItemIndex].quantity + 1 | ||
| 54 | - }; | ||
| 55 | - return updatedItems; | ||
| 56 | - } else { | ||
| 57 | - // Item doesn't exist, add it with quantity 1 | ||
| 58 | - return [...prevItems, { ...item, quantity: 1 }]; | ||
| 59 | - } | ||
| 60 | - }); | ||
| 61 | - }; | ||
| 62 | - | ||
| 63 | - // Remove an item from the cart | ||
| 64 | - const removeFromCart = (itemId, itemType) => { | ||
| 65 | - setCartItems(prevItems => | ||
| 66 | - prevItems.filter(item => !(item.id === itemId && item.type === itemType)) | ||
| 67 | - ); | ||
| 68 | - }; | ||
| 69 | - | ||
| 70 | - // Update quantity of an item in the cart | ||
| 71 | - const updateQuantity = (itemId, itemType, quantity) => { | ||
| 72 | - if (quantity < 1) return; | ||
| 73 | - | ||
| 74 | - setCartItems(prevItems => | ||
| 75 | - prevItems.map(item => | ||
| 76 | - (item.id === itemId && item.type === itemType) | ||
| 77 | - ? { ...item, quantity } | ||
| 78 | - : item | ||
| 79 | - ) | ||
| 80 | - ); | ||
| 81 | - }; | ||
| 82 | - | ||
| 83 | - // Clear the entire cart | ||
| 84 | - const clearCart = () => { | ||
| 85 | - setCartItems([]); | ||
| 86 | - }; | ||
| 87 | - | ||
| 88 | - // Get the number of items in cart | ||
| 89 | - const getItemCount = () => { | ||
| 90 | - return cartItems.reduce((total, item) => total + item.quantity, 0); | ||
| 91 | - }; | ||
| 92 | - | ||
| 93 | - // Calculate the total price of items in the cart | ||
| 94 | - const getTotalPrice = () => { | ||
| 95 | - return cartItems.reduce( | ||
| 96 | - (total, item) => total + (item.price * item.quantity), | ||
| 97 | - 0 | ||
| 98 | - ); | ||
| 99 | - }; | ||
| 100 | - | ||
| 101 | - // Proceed to checkout | ||
| 102 | - const proceedToCheckout = () => { | ||
| 103 | - if (cartItems.length > 0) { | ||
| 104 | - navigate('/checkout'); | ||
| 105 | - } | ||
| 106 | - }; | ||
| 107 | - | ||
| 108 | - // Handle the checkout process | ||
| 109 | - const handleCheckout = (userData) => { | ||
| 110 | - // In a real application, this would send the order to a backend | ||
| 111 | - console.log('Processing order with data:', { items: cartItems, userData }); | ||
| 112 | - | ||
| 113 | - // Simulating successful checkout | ||
| 114 | - return new Promise((resolve) => { | ||
| 115 | - setTimeout(() => { | ||
| 116 | - clearCart(); | ||
| 117 | - resolve({ success: true, orderId: 'ORD-' + Date.now() }); | ||
| 118 | - }, 1500); | ||
| 119 | - }); | ||
| 120 | - }; | ||
| 121 | - | ||
| 122 | - // Values to provide in the context | ||
| 123 | - const value = { | ||
| 124 | - cartItems, | ||
| 125 | - addToCart, | ||
| 126 | - removeFromCart, | ||
| 127 | - updateQuantity, | ||
| 128 | - clearCart, | ||
| 129 | - getItemCount, | ||
| 130 | - getTotalPrice, | ||
| 131 | - proceedToCheckout, | ||
| 132 | - handleCheckout | ||
| 133 | - }; | ||
| 134 | - | ||
| 135 | - return ( | ||
| 136 | - <CartContext.Provider value={value}> | ||
| 137 | - {children} | ||
| 138 | - </CartContext.Provider> | ||
| 139 | - ); | ||
| 140 | -}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
src/contexts/cart.js
0 → 100644
| 1 | +import { ref, provide, inject, watchEffect } from 'vue' | ||
| 2 | +import { useRouter } from 'vue-router' | ||
| 3 | + | ||
| 4 | +const CartSymbol = Symbol() | ||
| 5 | + | ||
| 6 | +export function provideCart() { | ||
| 7 | + const router = useRouter() | ||
| 8 | + const cartItems = ref([]) | ||
| 9 | + | ||
| 10 | + // 从localStorage加载购物车数据 | ||
| 11 | + try { | ||
| 12 | + const storedCart = localStorage.getItem('cart') | ||
| 13 | + if (storedCart) { | ||
| 14 | + cartItems.value = JSON.parse(storedCart) | ||
| 15 | + } | ||
| 16 | + } catch (error) { | ||
| 17 | + console.error('Failed to parse cart from localStorage:', error) | ||
| 18 | + localStorage.removeItem('cart') | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + // 监听购物车变化并保存到localStorage | ||
| 22 | + watchEffect(() => { | ||
| 23 | + localStorage.setItem('cart', JSON.stringify(cartItems.value)) | ||
| 24 | + }) | ||
| 25 | + | ||
| 26 | + // 添加商品到购物车 | ||
| 27 | + function addToCart(item) { | ||
| 28 | + const existingItemIndex = cartItems.value.findIndex( | ||
| 29 | + i => i.id === item.id && i.type === item.type | ||
| 30 | + ) | ||
| 31 | + | ||
| 32 | + if (existingItemIndex >= 0) { | ||
| 33 | + const updatedItems = [...cartItems.value] | ||
| 34 | + updatedItems[existingItemIndex] = { | ||
| 35 | + ...updatedItems[existingItemIndex], | ||
| 36 | + quantity: updatedItems[existingItemIndex].quantity + 1 | ||
| 37 | + } | ||
| 38 | + cartItems.value = updatedItems | ||
| 39 | + } else { | ||
| 40 | + cartItems.value = [...cartItems.value, { ...item, quantity: 1 }] | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + // 从购物车移除商品 | ||
| 45 | + function removeFromCart(itemId, itemType) { | ||
| 46 | + cartItems.value = cartItems.value.filter( | ||
| 47 | + item => !(item.id === itemId && item.type === itemType) | ||
| 48 | + ) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + // 更新商品数量 | ||
| 52 | + function updateQuantity(itemId, itemType, quantity) { | ||
| 53 | + if (quantity < 1) return | ||
| 54 | + | ||
| 55 | + cartItems.value = cartItems.value.map(item => | ||
| 56 | + item.id === itemId && item.type === itemType | ||
| 57 | + ? { ...item, quantity } | ||
| 58 | + : item | ||
| 59 | + ) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + // 清空购物车 | ||
| 63 | + function clearCart() { | ||
| 64 | + cartItems.value = [] | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + // 获取购物车商品总数 | ||
| 68 | + function getItemCount() { | ||
| 69 | + return cartItems.value.reduce((total, item) => total + item.quantity, 0) | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + // 计算购物车总价 | ||
| 73 | + function getTotalPrice() { | ||
| 74 | + return cartItems.value.reduce( | ||
| 75 | + (total, item) => total + item.price * item.quantity, | ||
| 76 | + 0 | ||
| 77 | + ) | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + // 跳转到结账页面 | ||
| 81 | + function proceedToCheckout() { | ||
| 82 | + if (cartItems.value.length > 0) { | ||
| 83 | + router.push('/checkout') | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + // 处理结账流程 | ||
| 88 | + function handleCheckout(userData) { | ||
| 89 | + console.warn('Processing order with data:', { items: cartItems.value, userData }) | ||
| 90 | + | ||
| 91 | + return new Promise((resolve) => { | ||
| 92 | + setTimeout(() => { | ||
| 93 | + clearCart() | ||
| 94 | + resolve({ success: true, orderId: 'ORD-' + Date.now() }) | ||
| 95 | + }, 1500) | ||
| 96 | + }) | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + const cart = { | ||
| 100 | + items: cartItems, | ||
| 101 | + addToCart, | ||
| 102 | + removeFromCart, | ||
| 103 | + updateQuantity, | ||
| 104 | + clearCart, | ||
| 105 | + getItemCount, | ||
| 106 | + getTotalPrice, | ||
| 107 | + proceedToCheckout, | ||
| 108 | + handleCheckout | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + provide(CartSymbol, cart) | ||
| 112 | + | ||
| 113 | + return cart | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +export function useCart() { | ||
| 117 | + const cart = inject(CartSymbol) | ||
| 118 | + if (!cart) { | ||
| 119 | + throw new Error('useCart() must be used within a component that has called provideCart()') | ||
| 120 | + } | ||
| 121 | + return cart | ||
| 122 | +} |
| 1 | <script setup> | 1 | <script setup> |
| 2 | -import { ref, onMounted, defineComponent, h } from 'vue' | 2 | +import { ref, onMounted, defineComponent, h } from "vue"; |
| 3 | -import { useRoute, useRouter } from 'vue-router' | 3 | +import { useRoute, useRouter } from "vue-router"; |
| 4 | -import AppLayout from '@/components/layout/AppLayout.vue' | 4 | +import AppLayout from "@/components/layout/AppLayout.vue"; |
| 5 | -import FrostedGlass from '@/components/ui/FrostedGlass.vue' | 5 | +import FrostedGlass from "@/components/ui/FrostedGlass.vue"; |
| 6 | -import { activities } from '@/utils/mockData' | 6 | +import { activities } from "@/utils/mockData"; |
| 7 | 7 | ||
| 8 | -const route = useRoute() | 8 | +const route = useRoute(); |
| 9 | -const router = useRouter() | 9 | +const router = useRouter(); |
| 10 | -const activity = ref(null) | 10 | +const activity = ref(null); |
| 11 | -const activeTab = ref('活动信息') | 11 | +const activeTab = ref("活动信息"); |
| 12 | 12 | ||
| 13 | // 获取活动数据 | 13 | // 获取活动数据 |
| 14 | onMounted(() => { | 14 | onMounted(() => { |
| 15 | - const id = route.params.id | 15 | + const id = route.params.id; |
| 16 | - const foundActivity = activities.find(a => a.id === id) | 16 | + const foundActivity = activities.find((a) => a.id === id); |
| 17 | if (foundActivity) { | 17 | if (foundActivity) { |
| 18 | - activity.value = foundActivity | 18 | + activity.value = foundActivity; |
| 19 | } else { | 19 | } else { |
| 20 | // 活动未找到,重定向到活动列表页 | 20 | // 活动未找到,重定向到活动列表页 |
| 21 | - router.push('/activities') | 21 | + router.push("/activities"); |
| 22 | } | 22 | } |
| 23 | -}) | 23 | +}); |
| 24 | 24 | ||
| 25 | // 获取状态颜色类名 | 25 | // 获取状态颜色类名 |
| 26 | const getStatusColorClass = (status) => { | 26 | const getStatusColorClass = (status) => { |
| 27 | switch (status) { | 27 | switch (status) { |
| 28 | - case '活动中': | 28 | + case "活动中": |
| 29 | - return 'bg-blue-100 text-blue-700' | 29 | + return "bg-blue-100 text-blue-700"; |
| 30 | - case '进行中': | 30 | + case "进行中": |
| 31 | - return 'bg-green-100 text-green-700' | 31 | + return "bg-green-100 text-green-700"; |
| 32 | - case '即将开始': | 32 | + case "即将开始": |
| 33 | - return 'bg-orange-100 text-orange-700' | 33 | + return "bg-orange-100 text-orange-700"; |
| 34 | - case '已结束': | 34 | + case "已结束": |
| 35 | - return 'bg-gray-100 text-gray-700' | 35 | + return "bg-gray-100 text-gray-700"; |
| 36 | default: | 36 | default: |
| 37 | - return 'bg-gray-100 text-gray-700' | 37 | + return "bg-gray-100 text-gray-700"; |
| 38 | } | 38 | } |
| 39 | -} | 39 | +}; |
| 40 | 40 | ||
| 41 | // 相关活动 | 41 | // 相关活动 |
| 42 | -const relatedActivities = ref([]) | 42 | +const relatedActivities = ref([]); |
| 43 | onMounted(() => { | 43 | onMounted(() => { |
| 44 | if (activity.value) { | 44 | if (activity.value) { |
| 45 | relatedActivities.value = activities | 45 | relatedActivities.value = activities |
| 46 | - .filter(a => a.id !== activity.value.id) | 46 | + .filter((a) => a.id !== activity.value.id) |
| 47 | - .slice(0, 3) | 47 | + .slice(0, 3); |
| 48 | } | 48 | } |
| 49 | -}) | 49 | +}); |
| 50 | 50 | ||
| 51 | // 页面导航 | 51 | // 页面导航 |
| 52 | const navigateTo = (path) => { | 52 | const navigateTo = (path) => { |
| 53 | - router.push(path) | 53 | + router.push(path); |
| 54 | -} | 54 | +}; |
| 55 | 55 | ||
| 56 | // 右侧内容组件 | 56 | // 右侧内容组件 |
| 57 | const RightContent = defineComponent({ | 57 | const RightContent = defineComponent({ |
| 58 | setup() { | 58 | setup() { |
| 59 | - return () => h('div', { class: 'flex' }, [ | 59 | + return () => |
| 60 | - h('button', { class: 'p-2' }, [ | 60 | + h("div", { class: "flex" }, [ |
| 61 | - h('svg', { | 61 | + h("button", { class: "p-2" }, [ |
| 62 | - xmlns: 'http://www.w3.org/2000/svg', | 62 | + h( |
| 63 | - class: 'h-6 w-6 text-gray-700', | 63 | + "svg", |
| 64 | - fill: 'none', | 64 | + { |
| 65 | - viewBox: '0 0 24 24', | 65 | + xmlns: "http://www.w3.org/2000/svg", |
| 66 | - stroke: 'currentColor' | 66 | + class: "h-6 w-6 text-gray-700", |
| 67 | - }, [ | 67 | + fill: "none", |
| 68 | - h('path', { | 68 | + viewBox: "0 0 24 24", |
| 69 | - 'stroke-linecap': 'round', | 69 | + stroke: "currentColor", |
| 70 | - 'stroke-linejoin': 'round', | 70 | + }, |
| 71 | - 'stroke-width': '2', | 71 | + [ |
| 72 | - d: 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z' | 72 | + h("path", { |
| 73 | - }) | 73 | + "stroke-linecap": "round", |
| 74 | - ]) | 74 | + "stroke-linejoin": "round", |
| 75 | - ]), | 75 | + "stroke-width": "2", |
| 76 | - h('button', { class: 'p-2' }, [ | 76 | + d: |
| 77 | - h('svg', { | 77 | + "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z", |
| 78 | - xmlns: 'http://www.w3.org/2000/svg', | 78 | + }), |
| 79 | - class: 'h-6 w-6 text-gray-700', | 79 | + ] |
| 80 | - fill: 'none', | 80 | + ), |
| 81 | - viewBox: '0 0 24 24', | 81 | + ]), |
| 82 | - stroke: 'currentColor' | 82 | + h("button", { class: "p-2" }, [ |
| 83 | - }, [ | 83 | + h( |
| 84 | - h('path', { | 84 | + "svg", |
| 85 | - 'stroke-linecap': 'round', | 85 | + { |
| 86 | - 'stroke-linejoin': 'round', | 86 | + xmlns: "http://www.w3.org/2000/svg", |
| 87 | - 'stroke-width': '2', | 87 | + class: "h-6 w-6 text-gray-700", |
| 88 | - d: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z' | 88 | + fill: "none", |
| 89 | - }) | 89 | + viewBox: "0 0 24 24", |
| 90 | - ]) | 90 | + stroke: "currentColor", |
| 91 | - ]) | 91 | + }, |
| 92 | - ]) | 92 | + [ |
| 93 | - } | 93 | + h("path", { |
| 94 | -}) | 94 | + "stroke-linecap": "round", |
| 95 | + "stroke-linejoin": "round", | ||
| 96 | + "stroke-width": "2", | ||
| 97 | + d: | ||
| 98 | + "M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z", | ||
| 99 | + }), | ||
| 100 | + ] | ||
| 101 | + ), | ||
| 102 | + ]), | ||
| 103 | + ]); | ||
| 104 | + }, | ||
| 105 | +}); | ||
| 95 | </script> | 106 | </script> |
| 96 | 107 | ||
| 97 | <template> | 108 | <template> |
| ... | @@ -104,8 +115,12 @@ const RightContent = defineComponent({ | ... | @@ -104,8 +115,12 @@ const RightContent = defineComponent({ |
| 104 | :alt="activity?.title" | 115 | :alt="activity?.title" |
| 105 | class="w-full h-full object-cover" | 116 | class="w-full h-full object-cover" |
| 106 | /> | 117 | /> |
| 107 | - <div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4"> | 118 | + <div |
| 108 | - <div class="inline-block px-2 py-1 rounded bg-white/20 backdrop-blur-sm text-white text-xs mb-2"> | 119 | + class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4" |
| 120 | + > | ||
| 121 | + <div | ||
| 122 | + class="inline-block px-2 py-1 rounded bg-white/20 backdrop-blur-sm text-white text-xs mb-2" | ||
| 123 | + > | ||
| 109 | {{ activity?.subtitle }} | 124 | {{ activity?.subtitle }} |
| 110 | </div> | 125 | </div> |
| 111 | <h1 class="text-xl text-white font-bold">{{ activity?.title }}</h1> | 126 | <h1 class="text-xl text-white font-bold">{{ activity?.title }}</h1> |
| ... | @@ -121,14 +136,30 @@ const RightContent = defineComponent({ | ... | @@ -121,14 +136,30 @@ const RightContent = defineComponent({ |
| 121 | <span class="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-xs"> | 136 | <span class="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-xs"> |
| 122 | 教育 | 137 | 教育 |
| 123 | </span> | 138 | </span> |
| 124 | - <span :class="['px-2 py-1 rounded-full text-xs', getStatusColorClass(activity?.status)]"> | 139 | + <span |
| 140 | + :class="[ | ||
| 141 | + 'px-2 py-1 rounded-full text-xs', | ||
| 142 | + getStatusColorClass(activity?.status), | ||
| 143 | + ]" | ||
| 144 | + > | ||
| 125 | {{ activity?.status }} | 145 | {{ activity?.status }} |
| 126 | </span> | 146 | </span> |
| 127 | </div> | 147 | </div> |
| 128 | <div> | 148 | <div> |
| 129 | <button class="p-1"> | 149 | <button class="p-1"> |
| 130 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-pink-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 150 | + <svg |
| 131 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /> | 151 | + xmlns="http://www.w3.org/2000/svg" |
| 152 | + class="h-6 w-6 text-pink-500" | ||
| 153 | + fill="none" | ||
| 154 | + viewBox="0 0 24 24" | ||
| 155 | + stroke="currentColor" | ||
| 156 | + > | ||
| 157 | + <path | ||
| 158 | + stroke-linecap="round" | ||
| 159 | + stroke-linejoin="round" | ||
| 160 | + stroke-width="2" | ||
| 161 | + d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" | ||
| 162 | + /> | ||
| 132 | </svg> | 163 | </svg> |
| 133 | </button> | 164 | </button> |
| 134 | </div> | 165 | </div> |
| ... | @@ -145,7 +176,7 @@ const RightContent = defineComponent({ | ... | @@ -145,7 +176,7 @@ const RightContent = defineComponent({ |
| 145 | 'pb-3 font-medium', | 176 | 'pb-3 font-medium', |
| 146 | activeTab === tab | 177 | activeTab === tab |
| 147 | ? 'text-green-600 border-b-2 border-green-600' | 178 | ? 'text-green-600 border-b-2 border-green-600' |
| 148 | - : 'text-gray-500' | 179 | + : 'text-gray-500', |
| 149 | ]" | 180 | ]" |
| 150 | > | 181 | > |
| 151 | {{ tab }} | 182 | {{ tab }} |
| ... | @@ -161,9 +192,25 @@ const RightContent = defineComponent({ | ... | @@ -161,9 +192,25 @@ const RightContent = defineComponent({ |
| 161 | <div class="space-y-4"> | 192 | <div class="space-y-4"> |
| 162 | <div class="flex items-start"> | 193 | <div class="flex items-start"> |
| 163 | <div class="p-2 rounded-full bg-green-100 mr-3"> | 194 | <div class="p-2 rounded-full bg-green-100 mr-3"> |
| 164 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 195 | + <svg |
| 165 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> | 196 | + xmlns="http://www.w3.org/2000/svg" |
| 166 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> | 197 | + class="h-5 w-5 text-green-600" |
| 198 | + fill="none" | ||
| 199 | + viewBox="0 0 24 24" | ||
| 200 | + stroke="currentColor" | ||
| 201 | + > | ||
| 202 | + <path | ||
| 203 | + stroke-linecap="round" | ||
| 204 | + stroke-linejoin="round" | ||
| 205 | + stroke-width="2" | ||
| 206 | + d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" | ||
| 207 | + /> | ||
| 208 | + <path | ||
| 209 | + stroke-linecap="round" | ||
| 210 | + stroke-linejoin="round" | ||
| 211 | + stroke-width="2" | ||
| 212 | + d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" | ||
| 213 | + /> | ||
| 167 | </svg> | 214 | </svg> |
| 168 | </div> | 215 | </div> |
| 169 | <div class="flex-1"> | 216 | <div class="flex-1"> |
| ... | @@ -174,8 +221,19 @@ const RightContent = defineComponent({ | ... | @@ -174,8 +221,19 @@ const RightContent = defineComponent({ |
| 174 | 221 | ||
| 175 | <div class="flex items-start"> | 222 | <div class="flex items-start"> |
| 176 | <div class="p-2 rounded-full bg-blue-100 mr-3"> | 223 | <div class="p-2 rounded-full bg-blue-100 mr-3"> |
| 177 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 224 | + <svg |
| 178 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> | 225 | + xmlns="http://www.w3.org/2000/svg" |
| 226 | + class="h-5 w-5 text-blue-600" | ||
| 227 | + fill="none" | ||
| 228 | + viewBox="0 0 24 24" | ||
| 229 | + stroke="currentColor" | ||
| 230 | + > | ||
| 231 | + <path | ||
| 232 | + stroke-linecap="round" | ||
| 233 | + stroke-linejoin="round" | ||
| 234 | + stroke-width="2" | ||
| 235 | + d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" | ||
| 236 | + /> | ||
| 179 | </svg> | 237 | </svg> |
| 180 | </div> | 238 | </div> |
| 181 | <div class="flex-1"> | 239 | <div class="flex-1"> |
| ... | @@ -187,28 +245,64 @@ const RightContent = defineComponent({ | ... | @@ -187,28 +245,64 @@ const RightContent = defineComponent({ |
| 187 | 245 | ||
| 188 | <div class="flex items-start"> | 246 | <div class="flex items-start"> |
| 189 | <div class="p-2 rounded-full bg-purple-100 mr-3"> | 247 | <div class="p-2 rounded-full bg-purple-100 mr-3"> |
| 190 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 248 | + <svg |
| 191 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /> | 249 | + xmlns="http://www.w3.org/2000/svg" |
| 250 | + class="h-5 w-5 text-purple-600" | ||
| 251 | + fill="none" | ||
| 252 | + viewBox="0 0 24 24" | ||
| 253 | + stroke="currentColor" | ||
| 254 | + > | ||
| 255 | + <path | ||
| 256 | + stroke-linecap="round" | ||
| 257 | + stroke-linejoin="round" | ||
| 258 | + stroke-width="2" | ||
| 259 | + d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" | ||
| 260 | + /> | ||
| 192 | </svg> | 261 | </svg> |
| 193 | </div> | 262 | </div> |
| 194 | <div class="flex-1"> | 263 | <div class="flex-1"> |
| 195 | <p class="font-medium">参与人数</p> | 264 | <p class="font-medium">参与人数</p> |
| 196 | - <p class="text-gray-600 text-sm mt-1">{{ activity?.participantsCount }}人 / 上限{{ activity?.maxParticipants }}人</p> | 265 | + <p class="text-gray-600 text-sm mt-1"> |
| 266 | + {{ activity?.participantsCount }}人 / 上限{{ | ||
| 267 | + activity?.maxParticipants | ||
| 268 | + }}人 | ||
| 269 | + </p> | ||
| 197 | <div class="w-full bg-gray-200 rounded-full h-1.5 mt-2"> | 270 | <div class="w-full bg-gray-200 rounded-full h-1.5 mt-2"> |
| 198 | - <div class="bg-green-600 h-1.5 rounded-full" :style="{ width: (activity?.participantsCount / activity?.maxParticipants * 100) + '%' }"></div> | 271 | + <div |
| 272 | + class="bg-green-600 h-1.5 rounded-full" | ||
| 273 | + :style="{ | ||
| 274 | + width: | ||
| 275 | + (activity?.participantsCount / activity?.maxParticipants) * | ||
| 276 | + 100 + | ||
| 277 | + '%', | ||
| 278 | + }" | ||
| 279 | + ></div> | ||
| 199 | </div> | 280 | </div> |
| 200 | </div> | 281 | </div> |
| 201 | </div> | 282 | </div> |
| 202 | 283 | ||
| 203 | <div class="flex items-start"> | 284 | <div class="flex items-start"> |
| 204 | <div class="p-2 rounded-full bg-orange-100 mr-3"> | 285 | <div class="p-2 rounded-full bg-orange-100 mr-3"> |
| 205 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 286 | + <svg |
| 206 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | 287 | + xmlns="http://www.w3.org/2000/svg" |
| 288 | + class="h-5 w-5 text-orange-600" | ||
| 289 | + fill="none" | ||
| 290 | + viewBox="0 0 24 24" | ||
| 291 | + stroke="currentColor" | ||
| 292 | + > | ||
| 293 | + <path | ||
| 294 | + stroke-linecap="round" | ||
| 295 | + stroke-linejoin="round" | ||
| 296 | + stroke-width="2" | ||
| 297 | + d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" | ||
| 298 | + /> | ||
| 207 | </svg> | 299 | </svg> |
| 208 | </div> | 300 | </div> |
| 209 | <div class="flex-1"> | 301 | <div class="flex-1"> |
| 210 | <p class="font-medium">活动费用</p> | 302 | <p class="font-medium">活动费用</p> |
| 211 | - <p class="text-gray-600 text-sm mt-1">¥{{ activity?.price }}/人(包含材料费、场地费)</p> | 303 | + <p class="text-gray-600 text-sm mt-1"> |
| 304 | + ¥{{ activity?.price }}/人(包含材料费、场地费) | ||
| 305 | + </p> | ||
| 212 | </div> | 306 | </div> |
| 213 | </div> | 307 | </div> |
| 214 | </div> | 308 | </div> |
| ... | @@ -218,15 +312,13 @@ const RightContent = defineComponent({ | ... | @@ -218,15 +312,13 @@ const RightContent = defineComponent({ |
| 218 | <FrostedGlass class="p-4 mb-4 rounded-xl"> | 312 | <FrostedGlass class="p-4 mb-4 rounded-xl"> |
| 219 | <h2 class="text-lg font-semibold mb-3">活动介绍</h2> | 313 | <h2 class="text-lg font-semibold mb-3">活动介绍</h2> |
| 220 | <p class="text-gray-700 whitespace-pre-line"> | 314 | <p class="text-gray-700 whitespace-pre-line"> |
| 221 | - {{ activity?.title }} 是一个精心设计的亲子互动活动,旨在增强家长与孩子之间的沟通与理解。 | 315 | + {{ |
| 222 | - | 316 | + activity?.title |
| 317 | + }} | ||
| 318 | + 是一个精心设计的亲子互动活动,旨在增强家长与孩子之间的沟通与理解。 | ||
| 223 | 通过一系列有趣的游戏和学习环节,让孩子在轻松愉快的氛围中学习知识,培养良好的学习习惯和价值观。同时,也给予家长更多指导和支持,帮助他们更好地理解孩子的需求和成长过程。 | 319 | 通过一系列有趣的游戏和学习环节,让孩子在轻松愉快的氛围中学习知识,培养良好的学习习惯和价值观。同时,也给予家长更多指导和支持,帮助他们更好地理解孩子的需求和成长过程。 |
| 224 | - | 320 | + 活动特色: - 专业导师全程引导 - 互动性强,参与感高 - 寓教于乐,收获满满 - |
| 225 | - 活动特色: | 321 | + 结交志同道合的家庭 |
| 226 | - - 专业导师全程引导 | ||
| 227 | - - 互动性强,参与感高 | ||
| 228 | - - 寓教于乐,收获满满 | ||
| 229 | - - 结交志同道合的家庭 | ||
| 230 | </p> | 322 | </p> |
| 231 | </FrostedGlass> | 323 | </FrostedGlass> |
| 232 | </div> | 324 | </div> |
| ... | @@ -237,17 +329,26 @@ const RightContent = defineComponent({ | ... | @@ -237,17 +329,26 @@ const RightContent = defineComponent({ |
| 237 | <div class="grid grid-cols-4 gap-4"> | 329 | <div class="grid grid-cols-4 gap-4"> |
| 238 | <div v-for="index in 12" :key="index" class="flex flex-col items-center"> | 330 | <div v-for="index in 12" :key="index" class="flex flex-col items-center"> |
| 239 | <div class="w-14 h-14 rounded-full bg-gray-200 overflow-hidden mb-1"> | 331 | <div class="w-14 h-14 rounded-full bg-gray-200 overflow-hidden mb-1"> |
| 240 | - <svg xmlns="http://www.w3.org/2000/svg" class="h-full w-full text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | 332 | + <svg |
| 241 | - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> | 333 | + xmlns="http://www.w3.org/2000/svg" |
| 334 | + class="h-full w-full text-gray-400" | ||
| 335 | + fill="none" | ||
| 336 | + viewBox="0 0 24 24" | ||
| 337 | + stroke="currentColor" | ||
| 338 | + > | ||
| 339 | + <path | ||
| 340 | + stroke-linecap="round" | ||
| 341 | + stroke-linejoin="round" | ||
| 342 | + stroke-width="1" | ||
| 343 | + d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" | ||
| 344 | + /> | ||
| 242 | </svg> | 345 | </svg> |
| 243 | </div> | 346 | </div> |
| 244 | <span class="text-xs text-gray-600">用户{{ index }}</span> | 347 | <span class="text-xs text-gray-600">用户{{ index }}</span> |
| 245 | </div> | 348 | </div> |
| 246 | </div> | 349 | </div> |
| 247 | <div class="mt-4 flex justify-center"> | 350 | <div class="mt-4 flex justify-center"> |
| 248 | - <button class="text-green-600 text-sm"> | 351 | + <button class="text-green-600 text-sm">查看全部参与者</button> |
| 249 | - 查看全部参与者 | ||
| 250 | - </button> | ||
| 251 | </div> | 352 | </div> |
| 252 | </FrostedGlass> | 353 | </FrostedGlass> |
| 253 | </div> | 354 | </div> |
| ... | @@ -310,6 +411,28 @@ const RightContent = defineComponent({ | ... | @@ -310,6 +411,28 @@ const RightContent = defineComponent({ |
| 310 | </div> | 411 | </div> |
| 311 | </div> | 412 | </div> |
| 312 | </div> | 413 | </div> |
| 414 | + | ||
| 415 | + <!-- Bottom Action Bar --> | ||
| 416 | + <div | ||
| 417 | + class="fixed bottom-16 left-0 right-0 bg-white p-3 shadow-lg flex justify-between items-center" | ||
| 418 | + > | ||
| 419 | + <div class="flex items-end"> | ||
| 420 | + <div class="text-red-500 text-lg font-bold">¥366</div> | ||
| 421 | + <div class="text-xs text-gray-500 ml-1 line-through">原价¥468</div> | ||
| 422 | + </div> | ||
| 423 | + <div class="flex space-x-3"> | ||
| 424 | + <button | ||
| 425 | + class="px-4 py-2 border border-green-600 text-green-600 rounded-full text-sm" | ||
| 426 | + > | ||
| 427 | + 咨询详情 | ||
| 428 | + </button> | ||
| 429 | + <button | ||
| 430 | + class="px-6 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-full text-sm font-medium shadow-md" | ||
| 431 | + > | ||
| 432 | + 立即报名 | ||
| 433 | + </button> | ||
| 434 | + </div> | ||
| 435 | + </div> | ||
| 313 | </div> | 436 | </div> |
| 314 | </AppLayout> | 437 | </AppLayout> |
| 315 | </template> | 438 | </template> | ... | ... |
src/views/checkout/CheckoutPage.jsx
0 → 100644
| 1 | +import React, { useState } from 'react'; | ||
| 2 | +import { useNavigate } from 'react-router-dom'; | ||
| 3 | +import AppLayout from '../../components/layout/AppLayout'; | ||
| 4 | +import FrostedGlass from '../../components/ui/FrostedGlass'; | ||
| 5 | +import { useCart } from '../../contexts/CartContext'; | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * CheckoutPage component handles the order checkout process | ||
| 9 | + * with user information collection and payment method selection | ||
| 10 | + * | ||
| 11 | + * @returns {JSX.Element} CheckoutPage component | ||
| 12 | + */ | ||
| 13 | +const CheckoutPage = () => { | ||
| 14 | + const navigate = useNavigate(); | ||
| 15 | + const { cartItems, getTotalPrice, handleCheckout, clearCart } = useCart(); | ||
| 16 | + | ||
| 17 | + // Form state | ||
| 18 | + const [formData, setFormData] = useState({ | ||
| 19 | + name: '', | ||
| 20 | + phone: '', | ||
| 21 | + email: '', | ||
| 22 | + address: '', | ||
| 23 | + notes: '', | ||
| 24 | + paymentMethod: 'wechat' | ||
| 25 | + }); | ||
| 26 | + | ||
| 27 | + // Loading and success states | ||
| 28 | + const [isProcessing, setIsProcessing] = useState(false); | ||
| 29 | + const [orderComplete, setOrderComplete] = useState(false); | ||
| 30 | + const [orderId, setOrderId] = useState(''); | ||
| 31 | + | ||
| 32 | + // Format price with Chinese Yuan symbol | ||
| 33 | + const formatPrice = (price) => { | ||
| 34 | + return `¥${price.toFixed(2)}`; | ||
| 35 | + }; | ||
| 36 | + | ||
| 37 | + // Handle form input changes | ||
| 38 | + const handleInputChange = (e) => { | ||
| 39 | + const { name, value } = e.target; | ||
| 40 | + setFormData(prev => ({ | ||
| 41 | + ...prev, | ||
| 42 | + [name]: value | ||
| 43 | + })); | ||
| 44 | + }; | ||
| 45 | + | ||
| 46 | + // Handle form submission | ||
| 47 | + const handleSubmit = async (e) => { | ||
| 48 | + e.preventDefault(); | ||
| 49 | + | ||
| 50 | + if (!formData.name || !formData.phone || !formData.address) { | ||
| 51 | + alert('请填写必要信息'); | ||
| 52 | + return; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + setIsProcessing(true); | ||
| 56 | + | ||
| 57 | + try { | ||
| 58 | + // Process checkout | ||
| 59 | + const result = await handleCheckout(formData); | ||
| 60 | + | ||
| 61 | + if (result.success) { | ||
| 62 | + setOrderId(result.orderId); | ||
| 63 | + setOrderComplete(true); | ||
| 64 | + } | ||
| 65 | + } catch (error) { | ||
| 66 | + console.error('Checkout failed:', error); | ||
| 67 | + alert('支付失败,请重试'); | ||
| 68 | + } finally { | ||
| 69 | + setIsProcessing(false); | ||
| 70 | + } | ||
| 71 | + }; | ||
| 72 | + | ||
| 73 | + // Handle navigation back to home after order completion | ||
| 74 | + const handleBackToHome = () => { | ||
| 75 | + navigate('/'); | ||
| 76 | + }; | ||
| 77 | + | ||
| 78 | + // If cart is empty, redirect to home | ||
| 79 | + if (cartItems.length === 0 && !orderComplete) { | ||
| 80 | + return ( | ||
| 81 | + <AppLayout title="结账"> | ||
| 82 | + <div className="h-screen flex flex-col items-center justify-center px-4"> | ||
| 83 | + <FrostedGlass className="p-6 rounded-xl text-center"> | ||
| 84 | + <svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-gray-400 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 85 | + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" /> | ||
| 86 | + </svg> | ||
| 87 | + <h2 className="text-xl font-bold mb-2">购物车为空</h2> | ||
| 88 | + <p className="text-gray-600 mb-6">您的购物车中没有任何商品</p> | ||
| 89 | + <button | ||
| 90 | + onClick={() => navigate('/courses')} | ||
| 91 | + className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium" | ||
| 92 | + > | ||
| 93 | + 浏览课程 | ||
| 94 | + </button> | ||
| 95 | + </FrostedGlass> | ||
| 96 | + </div> | ||
| 97 | + </AppLayout> | ||
| 98 | + ); | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + // Show order completion screen | ||
| 102 | + if (orderComplete) { | ||
| 103 | + return ( | ||
| 104 | + <AppLayout title="支付成功"> | ||
| 105 | + <div className="h-screen flex flex-col items-center justify-center px-4"> | ||
| 106 | + <FrostedGlass className="p-6 rounded-xl text-center"> | ||
| 107 | + <div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> | ||
| 108 | + <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 109 | + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> | ||
| 110 | + </svg> | ||
| 111 | + </div> | ||
| 112 | + <h2 className="text-2xl font-bold mb-2">支付成功!</h2> | ||
| 113 | + <p className="text-gray-600 mb-2">您的订单已经成功提交</p> | ||
| 114 | + <p className="text-gray-500 text-sm mb-6">订单号: {orderId}</p> | ||
| 115 | + <button | ||
| 116 | + onClick={handleBackToHome} | ||
| 117 | + className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium" | ||
| 118 | + > | ||
| 119 | + 返回首页 | ||
| 120 | + </button> | ||
| 121 | + </FrostedGlass> | ||
| 122 | + </div> | ||
| 123 | + </AppLayout> | ||
| 124 | + ); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + return ( | ||
| 128 | + <AppLayout title="结账" showBackButton onBackClick={() => navigate(-1)}> | ||
| 129 | + <div className="pb-20"> | ||
| 130 | + {/* Order Summary */} | ||
| 131 | + <div className="p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10"> | ||
| 132 | + <FrostedGlass className="rounded-xl p-4"> | ||
| 133 | + <h3 className="font-medium mb-3">订单摘要</h3> | ||
| 134 | + <div className="space-y-3"> | ||
| 135 | + {cartItems.map((item) => ( | ||
| 136 | + <div key={`${item.type}-${item.id}`} className="flex justify-between"> | ||
| 137 | + <div className="flex flex-1"> | ||
| 138 | + <div className="w-12 h-12 rounded-lg overflow-hidden mr-3 flex-shrink-0"> | ||
| 139 | + <img | ||
| 140 | + src={item.imageUrl || "/assets/images/course-placeholder.jpg"} | ||
| 141 | + alt={item.title} | ||
| 142 | + className="w-full h-full object-cover" | ||
| 143 | + onError={(e) => { | ||
| 144 | + e.target.onerror = null; | ||
| 145 | + e.target.src = "/assets/images/course-placeholder.jpg"; | ||
| 146 | + }} | ||
| 147 | + /> | ||
| 148 | + </div> | ||
| 149 | + <div> | ||
| 150 | + <p className="font-medium text-sm line-clamp-1">{item.title}</p> | ||
| 151 | + <p className="text-xs text-gray-500"> | ||
| 152 | + {item.type === 'course' ? '课程' : '活动'} · {item.quantity} 份 | ||
| 153 | + </p> | ||
| 154 | + </div> | ||
| 155 | + </div> | ||
| 156 | + <div className="ml-2 text-right"> | ||
| 157 | + <p className="font-medium text-sm">{formatPrice(item.price * item.quantity)}</p> | ||
| 158 | + <p className="text-xs text-gray-500">{formatPrice(item.price)} / 份</p> | ||
| 159 | + </div> | ||
| 160 | + </div> | ||
| 161 | + ))} | ||
| 162 | + </div> | ||
| 163 | + | ||
| 164 | + <div className="mt-4 pt-3 border-t border-gray-200"> | ||
| 165 | + <div className="flex justify-between items-center text-sm"> | ||
| 166 | + <span className="text-gray-600">小计</span> | ||
| 167 | + <span className="font-medium">{formatPrice(getTotalPrice())}</span> | ||
| 168 | + </div> | ||
| 169 | + <div className="flex justify-between items-center text-sm mt-1"> | ||
| 170 | + <span className="text-gray-600">优惠</span> | ||
| 171 | + <span className="text-red-500">- ¥0.00</span> | ||
| 172 | + </div> | ||
| 173 | + <div className="flex justify-between items-center mt-2 font-medium"> | ||
| 174 | + <span>总计</span> | ||
| 175 | + <span className="text-lg text-green-600">{formatPrice(getTotalPrice())}</span> | ||
| 176 | + </div> | ||
| 177 | + </div> | ||
| 178 | + </FrostedGlass> | ||
| 179 | + </div> | ||
| 180 | + | ||
| 181 | + {/* Checkout Form */} | ||
| 182 | + <div className="px-4 pt-4"> | ||
| 183 | + <form onSubmit={handleSubmit}> | ||
| 184 | + <FrostedGlass className="rounded-xl p-4 mb-4"> | ||
| 185 | + <h3 className="font-medium mb-3">个人信息</h3> | ||
| 186 | + | ||
| 187 | + <div className="space-y-3"> | ||
| 188 | + <div> | ||
| 189 | + <label className="block text-sm text-gray-600 mb-1">姓名 *</label> | ||
| 190 | + <input | ||
| 191 | + type="text" | ||
| 192 | + name="name" | ||
| 193 | + value={formData.name} | ||
| 194 | + onChange={handleInputChange} | ||
| 195 | + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 196 | + placeholder="请输入您的姓名" | ||
| 197 | + required | ||
| 198 | + /> | ||
| 199 | + </div> | ||
| 200 | + | ||
| 201 | + <div> | ||
| 202 | + <label className="block text-sm text-gray-600 mb-1">手机号码 *</label> | ||
| 203 | + <input | ||
| 204 | + type="tel" | ||
| 205 | + name="phone" | ||
| 206 | + value={formData.phone} | ||
| 207 | + onChange={handleInputChange} | ||
| 208 | + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 209 | + placeholder="请输入您的手机号码" | ||
| 210 | + required | ||
| 211 | + /> | ||
| 212 | + </div> | ||
| 213 | + | ||
| 214 | + <div> | ||
| 215 | + <label className="block text-sm text-gray-600 mb-1">电子邮箱</label> | ||
| 216 | + <input | ||
| 217 | + type="email" | ||
| 218 | + name="email" | ||
| 219 | + value={formData.email} | ||
| 220 | + onChange={handleInputChange} | ||
| 221 | + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 222 | + placeholder="请输入您的邮箱(选填)" | ||
| 223 | + /> | ||
| 224 | + </div> | ||
| 225 | + | ||
| 226 | + <div> | ||
| 227 | + <label className="block text-sm text-gray-600 mb-1">联系地址 *</label> | ||
| 228 | + <input | ||
| 229 | + type="text" | ||
| 230 | + name="address" | ||
| 231 | + value={formData.address} | ||
| 232 | + onChange={handleInputChange} | ||
| 233 | + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 234 | + placeholder="请输入您的详细地址" | ||
| 235 | + required | ||
| 236 | + /> | ||
| 237 | + </div> | ||
| 238 | + | ||
| 239 | + <div> | ||
| 240 | + <label className="block text-sm text-gray-600 mb-1">备注</label> | ||
| 241 | + <textarea | ||
| 242 | + name="notes" | ||
| 243 | + value={formData.notes} | ||
| 244 | + onChange={handleInputChange} | ||
| 245 | + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none h-20" | ||
| 246 | + placeholder="有什么需要我们注意的事项?(选填)" | ||
| 247 | + /> | ||
| 248 | + </div> | ||
| 249 | + </div> | ||
| 250 | + </FrostedGlass> | ||
| 251 | + | ||
| 252 | + <FrostedGlass className="rounded-xl p-4 mb-6"> | ||
| 253 | + <h3 className="font-medium mb-3">支付方式</h3> | ||
| 254 | + <div className="space-y-2"> | ||
| 255 | + <label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 256 | + <input | ||
| 257 | + type="radio" | ||
| 258 | + name="paymentMethod" | ||
| 259 | + value="wechat" | ||
| 260 | + checked={formData.paymentMethod === 'wechat'} | ||
| 261 | + onChange={handleInputChange} | ||
| 262 | + className="mr-3" | ||
| 263 | + /> | ||
| 264 | + <span className="flex-1">微信支付</span> | ||
| 265 | + <svg className="h-6 w-6 text-green-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 266 | + <path d="M9.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 267 | + <path d="M14.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 268 | + <path d="M9.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 269 | + <path d="M14.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 270 | + <path d="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10s10,-4.5 10,-10S17.5,2 12,2zM19.8,11c-0.2,4.6 -4.1,7.8 -8.8,7.5c-3.8,-0.2 -7.2,-3.2 -7.5,-7.1C3.2,7 6.9,3.1 11.5,3C16.1,3 20,6.9 19.8,11z" /> | ||
| 271 | + </svg> | ||
| 272 | + </label> | ||
| 273 | + | ||
| 274 | + <label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 275 | + <input | ||
| 276 | + type="radio" | ||
| 277 | + name="paymentMethod" | ||
| 278 | + value="alipay" | ||
| 279 | + checked={formData.paymentMethod === 'alipay'} | ||
| 280 | + onChange={handleInputChange} | ||
| 281 | + className="mr-3" | ||
| 282 | + /> | ||
| 283 | + <span className="flex-1">支付宝</span> | ||
| 284 | + <svg className="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 285 | + <path d="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10s10,-4.5 10,-10S17.5,2 12,2zM12,20.5c-4.7,0 -8.5,-3.8 -8.5,-8.5S7.3,3.5 12,3.5s8.5,3.8 8.5,8.5S16.7,20.5 12,20.5z" /> | ||
| 286 | + <path d="M12,6c-3.3,0 -6,2.7 -6,6s2.7,6 6,6s6,-2.7 6,-6S15.3,6 12,6zM16,13h-3v3h-2v-3H8v-2h3V8h2v3h3V13z" /> | ||
| 287 | + </svg> | ||
| 288 | + </label> | ||
| 289 | + | ||
| 290 | + <label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 291 | + <input | ||
| 292 | + type="radio" | ||
| 293 | + name="paymentMethod" | ||
| 294 | + value="bank" | ||
| 295 | + checked={formData.paymentMethod === 'bank'} | ||
| 296 | + onChange={handleInputChange} | ||
| 297 | + className="mr-3" | ||
| 298 | + /> | ||
| 299 | + <span className="flex-1">银行卡</span> | ||
| 300 | + <svg className="h-6 w-6 text-gray-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 301 | + <path d="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.1,4 20,4zM20,18H4V12h16V18zM20,8H4V6h16V8z" /> | ||
| 302 | + <path d="M5,13h2v2h-2z" /> | ||
| 303 | + <path d="M9,13h2v2h-2z" /> | ||
| 304 | + <path d="M13,13h2v2h-2z" /> | ||
| 305 | + <path d="M17,13h2v2h-2z" /> | ||
| 306 | + </svg> | ||
| 307 | + </label> | ||
| 308 | + </div> | ||
| 309 | + </FrostedGlass> | ||
| 310 | + | ||
| 311 | + <div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 shadow-md px-4 py-3"> | ||
| 312 | + <div className="flex justify-between items-center mb-3"> | ||
| 313 | + <div> | ||
| 314 | + <span className="text-sm text-gray-500">总计:</span> | ||
| 315 | + <span className="text-lg font-bold text-green-600">{formatPrice(getTotalPrice())}</span> | ||
| 316 | + </div> | ||
| 317 | + <button | ||
| 318 | + type="button" | ||
| 319 | + onClick={() => clearCart()} | ||
| 320 | + className="text-sm text-red-500" | ||
| 321 | + > | ||
| 322 | + 清空购物车 | ||
| 323 | + </button> | ||
| 324 | + </div> | ||
| 325 | + <button | ||
| 326 | + type="submit" | ||
| 327 | + disabled={isProcessing} | ||
| 328 | + className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium disabled:opacity-70 flex items-center justify-center" | ||
| 329 | + > | ||
| 330 | + {isProcessing ? ( | ||
| 331 | + <> | ||
| 332 | + <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 333 | + 处理中... | ||
| 334 | + </> | ||
| 335 | + ) : ( | ||
| 336 | + '确认支付' | ||
| 337 | + )} | ||
| 338 | + </button> | ||
| 339 | + </div> | ||
| 340 | + </form> | ||
| 341 | + </div> | ||
| 342 | + </div> | ||
| 343 | + </AppLayout> | ||
| 344 | + ); | ||
| 345 | +}; | ||
| 346 | + | ||
| 347 | +export default CheckoutPage; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
src/views/checkout/CheckoutPage.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <AppLayout :title="orderComplete ? '支付成功' : '结账'" :show-back-button="!orderComplete" @back-click="router.back()"> | ||
| 3 | + <div v-if="cartItems.length === 0 && !orderComplete" class="h-screen flex flex-col items-center justify-center px-4"> | ||
| 4 | + <FrostedGlass class="p-6 rounded-xl text-center"> | ||
| 5 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-400 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 6 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" /> | ||
| 7 | + </svg> | ||
| 8 | + <h2 class="text-xl font-bold mb-2">购物车为空</h2> | ||
| 9 | + <p class="text-gray-600 mb-6">您的购物车中没有任何商品</p> | ||
| 10 | + <button | ||
| 11 | + @click="router.push('/courses')" | ||
| 12 | + class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium" | ||
| 13 | + > | ||
| 14 | + 浏览课程 | ||
| 15 | + </button> | ||
| 16 | + </FrostedGlass> | ||
| 17 | + </div> | ||
| 18 | + | ||
| 19 | + <div v-else-if="orderComplete" class="h-screen flex flex-col items-center justify-center px-4"> | ||
| 20 | + <FrostedGlass class="p-6 rounded-xl text-center"> | ||
| 21 | + <div class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> | ||
| 22 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 23 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | ||
| 24 | + </svg> | ||
| 25 | + </div> | ||
| 26 | + <h2 class="text-2xl font-bold mb-2">支付成功!</h2> | ||
| 27 | + <p class="text-gray-600 mb-2">您的订单已经成功提交</p> | ||
| 28 | + <p class="text-gray-500 text-sm mb-6">订单号: {{ orderId }}</p> | ||
| 29 | + <button | ||
| 30 | + @click="handleBackToHome" | ||
| 31 | + class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium" | ||
| 32 | + > | ||
| 33 | + 返回首页 | ||
| 34 | + </button> | ||
| 35 | + </FrostedGlass> | ||
| 36 | + </div> | ||
| 37 | + | ||
| 38 | + <div v-else class="pb-20"> | ||
| 39 | + <!-- Order Summary --> | ||
| 40 | + <div class="p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10"> | ||
| 41 | + <FrostedGlass class="rounded-xl p-4"> | ||
| 42 | + <h3 class="font-medium mb-3">订单摘要</h3> | ||
| 43 | + <div class="space-y-3"> | ||
| 44 | + <div v-for="item in cartItems" :key="`${item.type}-${item.id}`" class="flex justify-between"> | ||
| 45 | + <div class="flex flex-1"> | ||
| 46 | + <div class="w-12 h-12 rounded-lg overflow-hidden mr-3 flex-shrink-0"> | ||
| 47 | + <img | ||
| 48 | + :src="item.imageUrl || '/assets/images/course-placeholder.jpg'" | ||
| 49 | + :alt="item.title" | ||
| 50 | + class="w-full h-full object-cover" | ||
| 51 | + @error="handleImageError" | ||
| 52 | + /> | ||
| 53 | + </div> | ||
| 54 | + <div> | ||
| 55 | + <p class="font-medium text-sm line-clamp-1">{{ item.title }}</p> | ||
| 56 | + <p class="text-xs text-gray-500"> | ||
| 57 | + {{ item.type === 'course' ? '课程' : '活动' }} · {{ item.quantity }} 份 | ||
| 58 | + </p> | ||
| 59 | + </div> | ||
| 60 | + </div> | ||
| 61 | + <div class="ml-2 text-right"> | ||
| 62 | + <p class="font-medium text-sm">{{ formatPrice(item.price * item.quantity) }}</p> | ||
| 63 | + <p class="text-xs text-gray-500">{{ formatPrice(item.price) }} / 份</p> | ||
| 64 | + </div> | ||
| 65 | + </div> | ||
| 66 | + </div> | ||
| 67 | + | ||
| 68 | + <div class="mt-4 pt-3 border-t border-gray-200"> | ||
| 69 | + <div class="flex justify-between items-center text-sm"> | ||
| 70 | + <span class="text-gray-600">小计</span> | ||
| 71 | + <span class="font-medium">{{ formatPrice(getTotalPrice()) }}</span> | ||
| 72 | + </div> | ||
| 73 | + <div class="flex justify-between items-center text-sm mt-1"> | ||
| 74 | + <span class="text-gray-600">优惠</span> | ||
| 75 | + <span class="text-red-500">- ¥0.00</span> | ||
| 76 | + </div> | ||
| 77 | + <div class="flex justify-between items-center mt-2 font-medium"> | ||
| 78 | + <span>总计</span> | ||
| 79 | + <span class="text-lg text-green-600">{{ formatPrice(getTotalPrice()) }}</span> | ||
| 80 | + </div> | ||
| 81 | + </div> | ||
| 82 | + </FrostedGlass> | ||
| 83 | + </div> | ||
| 84 | + | ||
| 85 | + <!-- Checkout Form --> | ||
| 86 | + <div class="px-4 pt-4"> | ||
| 87 | + <form @submit.prevent="handleSubmit"> | ||
| 88 | + <FrostedGlass class="rounded-xl p-4 mb-4"> | ||
| 89 | + <h3 class="font-medium mb-3">个人信息</h3> | ||
| 90 | + | ||
| 91 | + <div class="space-y-3"> | ||
| 92 | + <div> | ||
| 93 | + <label class="block text-sm text-gray-600 mb-1">姓名 *</label> | ||
| 94 | + <input | ||
| 95 | + v-model="formData.name" | ||
| 96 | + type="text" | ||
| 97 | + class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 98 | + placeholder="请输入您的姓名" | ||
| 99 | + required | ||
| 100 | + /> | ||
| 101 | + </div> | ||
| 102 | + | ||
| 103 | + <div> | ||
| 104 | + <label class="block text-sm text-gray-600 mb-1">手机号码 *</label> | ||
| 105 | + <input | ||
| 106 | + v-model="formData.phone" | ||
| 107 | + type="tel" | ||
| 108 | + class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 109 | + placeholder="请输入您的手机号码" | ||
| 110 | + required | ||
| 111 | + /> | ||
| 112 | + </div> | ||
| 113 | + | ||
| 114 | + <div> | ||
| 115 | + <label class="block text-sm text-gray-600 mb-1">电子邮箱</label> | ||
| 116 | + <input | ||
| 117 | + v-model="formData.email" | ||
| 118 | + type="email" | ||
| 119 | + class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 120 | + placeholder="请输入您的邮箱(选填)" | ||
| 121 | + /> | ||
| 122 | + </div> | ||
| 123 | + | ||
| 124 | + <div> | ||
| 125 | + <label class="block text-sm text-gray-600 mb-1">联系地址 *</label> | ||
| 126 | + <input | ||
| 127 | + v-model="formData.address" | ||
| 128 | + type="text" | ||
| 129 | + class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" | ||
| 130 | + placeholder="请输入您的详细地址" | ||
| 131 | + required | ||
| 132 | + /> | ||
| 133 | + </div> | ||
| 134 | + | ||
| 135 | + <div> | ||
| 136 | + <label class="block text-sm text-gray-600 mb-1">备注</label> | ||
| 137 | + <textarea | ||
| 138 | + v-model="formData.notes" | ||
| 139 | + class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none h-20" | ||
| 140 | + placeholder="有什么需要我们注意的事项?(选填)" | ||
| 141 | + /> | ||
| 142 | + </div> | ||
| 143 | + </div> | ||
| 144 | + </FrostedGlass> | ||
| 145 | + | ||
| 146 | + <FrostedGlass class="rounded-xl p-4 mb-6"> | ||
| 147 | + <h3 class="font-medium mb-3">支付方式</h3> | ||
| 148 | + <div class="space-y-2"> | ||
| 149 | + <label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 150 | + <input | ||
| 151 | + v-model="formData.paymentMethod" | ||
| 152 | + type="radio" | ||
| 153 | + value="wechat" | ||
| 154 | + class="mr-3" | ||
| 155 | + /> | ||
| 156 | + <span class="flex-1">微信支付</span> | ||
| 157 | + <svg class="h-6 w-6 text-green-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 158 | + <path d="M9.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 159 | + <path d="M14.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 160 | + <path d="M9.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 161 | + <path d="M14.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" /> | ||
| 162 | + <path d="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10s10,-4.5 10,-10S17.5,2 12,2zM19.8,11c-0.2,4.6 -4.1,7.8 -8.8,7.5c-3.8,-0.2 -7.2,-3.2 -7.5,-7.1C3.2,7 6.9,3.1 11.5,3C16.1,3 20,6.9 19.8,11z" /> | ||
| 163 | + </svg> | ||
| 164 | + </label> | ||
| 165 | + | ||
| 166 | + <label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 167 | + <input | ||
| 168 | + v-model="formData.paymentMethod" | ||
| 169 | + type="radio" | ||
| 170 | + value="alipay" | ||
| 171 | + class="mr-3" | ||
| 172 | + /> | ||
| 173 | + <span class="flex-1">支付宝</span> | ||
| 174 | + <svg class="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 175 | + <path d="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10s10,-4.5 10,-10S17.5,2 12,2zM12,20.5c-4.7,0 -8.5,-3.8 -8.5,-8.5S7.3,3.5 12,3.5s8.5,3.8 8.5,8.5S16.7,20.5 12,20.5z" /> | ||
| 176 | + <path d="M12,6c-3.3,0 -6,2.7 -6,6s2.7,6 6,6s6,-2.7 6,-6S15.3,6 12,6zM16,13h-3v3h-2v-3H8v-2h3V8h2v3h3V13z" /> | ||
| 177 | + </svg> | ||
| 178 | + </label> | ||
| 179 | + | ||
| 180 | + <label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50"> | ||
| 181 | + <input | ||
| 182 | + v-model="formData.paymentMethod" | ||
| 183 | + type="radio" | ||
| 184 | + value="bank" | ||
| 185 | + class="mr-3" | ||
| 186 | + /> | ||
| 187 | + <span class="flex-1">银行卡</span> | ||
| 188 | + <svg class="h-6 w-6 text-gray-500" viewBox="0 0 24 24" fill="currentColor"> | ||
| 189 | + <path d="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.1,4 20,4zM20,18H4V12h16V18zM20,8H4V6h16V8z" /> | ||
| 190 | + <path d="M5,13h2v2h-2z" /> | ||
| 191 | + <path d="M9,13h2v2h-2z" /> | ||
| 192 | + <path d="M13,13h2v2h-2z" /> | ||
| 193 | + <path d="M17,13h2v2h-2z" /> | ||
| 194 | + </svg> | ||
| 195 | + </label> | ||
| 196 | + </div> | ||
| 197 | + </FrostedGlass> | ||
| 198 | + | ||
| 199 | + <div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 shadow-md px-4 py-3"> | ||
| 200 | + <div class="flex justify-between items-center mb-3"> | ||
| 201 | + <div> | ||
| 202 | + <span class="text-sm text-gray-500">总计:</span> | ||
| 203 | + <span class="text-lg font-bold text-green-600">{{ formatPrice(getTotalPrice()) }}</span> | ||
| 204 | + </div> | ||
| 205 | + <button | ||
| 206 | + type="button" | ||
| 207 | + @click="clearCart" | ||
| 208 | + class="text-sm text-red-500" | ||
| 209 | + > | ||
| 210 | + 清空购物车 | ||
| 211 | + </button> | ||
| 212 | + </div> | ||
| 213 | + <button | ||
| 214 | + type="submit" | ||
| 215 | + :disabled="isProcessing" | ||
| 216 | + class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium disabled:opacity-70 flex items-center justify-center" | ||
| 217 | + > | ||
| 218 | + <template v-if="isProcessing"> | ||
| 219 | + <div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div> | ||
| 220 | + 处理中... | ||
| 221 | + </template> | ||
| 222 | + <template v-else> | ||
| 223 | + 确认支付 | ||
| 224 | + </template> | ||
| 225 | + </button> | ||
| 226 | + </div> | ||
| 227 | + </form> | ||
| 228 | + </div> | ||
| 229 | + </div> | ||
| 230 | + </AppLayout> | ||
| 231 | +</template> | ||
| 232 | + | ||
| 233 | +<script setup> | ||
| 234 | +import { ref } from 'vue' | ||
| 235 | +import { useRouter } from 'vue-router' | ||
| 236 | +import AppLayout from '@/components/layout/AppLayout.vue' | ||
| 237 | +import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ||
| 238 | +import { useCart } from '@/contexts/cart' | ||
| 239 | + | ||
| 240 | +const router = useRouter() | ||
| 241 | +const { items: cartItems, getTotalPrice, handleCheckout, clearCart } = useCart() | ||
| 242 | + | ||
| 243 | +// Form state | ||
| 244 | +const formData = ref({ | ||
| 245 | + name: '', | ||
| 246 | + phone: '', | ||
| 247 | + email: '', | ||
| 248 | + address: '', | ||
| 249 | + notes: '', | ||
| 250 | + paymentMethod: 'wechat' | ||
| 251 | +}) | ||
| 252 | + | ||
| 253 | +// Loading and success states | ||
| 254 | +const isProcessing = ref(false) | ||
| 255 | +const orderComplete = ref(false) | ||
| 256 | +const orderId = ref('') | ||
| 257 | + | ||
| 258 | +// Format price with Chinese Yuan symbol | ||
| 259 | +const formatPrice = (price) => { | ||
| 260 | + return `¥${price.toFixed(2)}` | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +// Handle image error | ||
| 264 | +const handleImageError = (e) => { | ||
| 265 | + e.target.src = '/assets/images/course-placeholder.jpg' | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +// Handle form submission | ||
| 269 | +const handleSubmit = async (e) => { | ||
| 270 | + if (!formData.value.name || !formData.value.phone || !formData.value.address) { | ||
| 271 | + alert('请填写必要信息') | ||
| 272 | + return | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + isProcessing.value = true | ||
| 276 | + | ||
| 277 | + try { | ||
| 278 | + // Process checkout | ||
| 279 | + const result = await handleCheckout(formData.value) | ||
| 280 | + | ||
| 281 | + if (result.success) { | ||
| 282 | + orderId.value = result.orderId | ||
| 283 | + orderComplete.value = true | ||
| 284 | + } | ||
| 285 | + } catch (error) { | ||
| 286 | + console.error('Checkout failed:', error) | ||
| 287 | + alert('支付失败,请重试') | ||
| 288 | + } finally { | ||
| 289 | + isProcessing.value = false | ||
| 290 | + } | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +// Handle navigation back to home after order completion | ||
| 294 | +const handleBackToHome = () => { | ||
| 295 | + router.push('/') | ||
| 296 | +} |
-
Please register or login to post a comment