hookehuyr

refactor(cart): 将购物车逻辑从React迁移到Vue

迁移购物车上下文逻辑,从React的CartContext.jsx迁移到Vue的cart.js,以支持Vue项目的需求。新的实现使用Vue的ref和provide/inject API,并保留了原有的功能,如购物车管理、本地存储同步和结账流程。
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// Create a context for the cart
const CartContext = createContext();
// Custom hook to use the cart context
export const useCart = () => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};
// Cart provider component
export const CartProvider = ({ children }) => {
const [cartItems, setCartItems] = useState([]);
const navigate = useNavigate();
// Load cart from localStorage on component mount
useEffect(() => {
const storedCart = localStorage.getItem('cart');
if (storedCart) {
try {
setCartItems(JSON.parse(storedCart));
} catch (error) {
console.error('Failed to parse cart from localStorage:', error);
// Reset cart if there's an error
localStorage.removeItem('cart');
}
}
}, []);
// Save cart to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cartItems));
}, [cartItems]);
// Add an item to the cart
const addToCart = (item) => {
setCartItems(prevItems => {
// Check if item already exists in cart
const existingItemIndex = prevItems.findIndex(i =>
i.id === item.id && i.type === item.type
);
if (existingItemIndex >= 0) {
// Item exists, update the quantity
const updatedItems = [...prevItems];
updatedItems[existingItemIndex] = {
...updatedItems[existingItemIndex],
quantity: updatedItems[existingItemIndex].quantity + 1
};
return updatedItems;
} else {
// Item doesn't exist, add it with quantity 1
return [...prevItems, { ...item, quantity: 1 }];
}
});
};
// Remove an item from the cart
const removeFromCart = (itemId, itemType) => {
setCartItems(prevItems =>
prevItems.filter(item => !(item.id === itemId && item.type === itemType))
);
};
// Update quantity of an item in the cart
const updateQuantity = (itemId, itemType, quantity) => {
if (quantity < 1) return;
setCartItems(prevItems =>
prevItems.map(item =>
(item.id === itemId && item.type === itemType)
? { ...item, quantity }
: item
)
);
};
// Clear the entire cart
const clearCart = () => {
setCartItems([]);
};
// Get the number of items in cart
const getItemCount = () => {
return cartItems.reduce((total, item) => total + item.quantity, 0);
};
// Calculate the total price of items in the cart
const getTotalPrice = () => {
return cartItems.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
};
// Proceed to checkout
const proceedToCheckout = () => {
if (cartItems.length > 0) {
navigate('/checkout');
}
};
// Handle the checkout process
const handleCheckout = (userData) => {
// In a real application, this would send the order to a backend
console.log('Processing order with data:', { items: cartItems, userData });
// Simulating successful checkout
return new Promise((resolve) => {
setTimeout(() => {
clearCart();
resolve({ success: true, orderId: 'ORD-' + Date.now() });
}, 1500);
});
};
// Values to provide in the context
const value = {
cartItems,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
getItemCount,
getTotalPrice,
proceedToCheckout,
handleCheckout
};
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
};
\ No newline at end of file
import { ref, provide, inject, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
const CartSymbol = Symbol()
export function provideCart() {
const router = useRouter()
const cartItems = ref([])
// 从localStorage加载购物车数据
try {
const storedCart = localStorage.getItem('cart')
if (storedCart) {
cartItems.value = JSON.parse(storedCart)
}
} catch (error) {
console.error('Failed to parse cart from localStorage:', error)
localStorage.removeItem('cart')
}
// 监听购物车变化并保存到localStorage
watchEffect(() => {
localStorage.setItem('cart', JSON.stringify(cartItems.value))
})
// 添加商品到购物车
function addToCart(item) {
const existingItemIndex = cartItems.value.findIndex(
i => i.id === item.id && i.type === item.type
)
if (existingItemIndex >= 0) {
const updatedItems = [...cartItems.value]
updatedItems[existingItemIndex] = {
...updatedItems[existingItemIndex],
quantity: updatedItems[existingItemIndex].quantity + 1
}
cartItems.value = updatedItems
} else {
cartItems.value = [...cartItems.value, { ...item, quantity: 1 }]
}
}
// 从购物车移除商品
function removeFromCart(itemId, itemType) {
cartItems.value = cartItems.value.filter(
item => !(item.id === itemId && item.type === itemType)
)
}
// 更新商品数量
function updateQuantity(itemId, itemType, quantity) {
if (quantity < 1) return
cartItems.value = cartItems.value.map(item =>
item.id === itemId && item.type === itemType
? { ...item, quantity }
: item
)
}
// 清空购物车
function clearCart() {
cartItems.value = []
}
// 获取购物车商品总数
function getItemCount() {
return cartItems.value.reduce((total, item) => total + item.quantity, 0)
}
// 计算购物车总价
function getTotalPrice() {
return cartItems.value.reduce(
(total, item) => total + item.price * item.quantity,
0
)
}
// 跳转到结账页面
function proceedToCheckout() {
if (cartItems.value.length > 0) {
router.push('/checkout')
}
}
// 处理结账流程
function handleCheckout(userData) {
console.warn('Processing order with data:', { items: cartItems.value, userData })
return new Promise((resolve) => {
setTimeout(() => {
clearCart()
resolve({ success: true, orderId: 'ORD-' + Date.now() })
}, 1500)
})
}
const cart = {
items: cartItems,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
getItemCount,
getTotalPrice,
proceedToCheckout,
handleCheckout
}
provide(CartSymbol, cart)
return cart
}
export function useCart() {
const cart = inject(CartSymbol)
if (!cart) {
throw new Error('useCart() must be used within a component that has called provideCart()')
}
return cart
}
<script setup>
import { ref, onMounted, defineComponent, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import { activities } from '@/utils/mockData'
import { ref, onMounted, defineComponent, h } from "vue";
import { useRoute, useRouter } from "vue-router";
import AppLayout from "@/components/layout/AppLayout.vue";
import FrostedGlass from "@/components/ui/FrostedGlass.vue";
import { activities } from "@/utils/mockData";
const route = useRoute()
const router = useRouter()
const activity = ref(null)
const activeTab = ref('活动信息')
const route = useRoute();
const router = useRouter();
const activity = ref(null);
const activeTab = ref("活动信息");
// 获取活动数据
onMounted(() => {
const id = route.params.id
const foundActivity = activities.find(a => a.id === id)
const id = route.params.id;
const foundActivity = activities.find((a) => a.id === id);
if (foundActivity) {
activity.value = foundActivity
activity.value = foundActivity;
} else {
// 活动未找到,重定向到活动列表页
router.push('/activities')
router.push("/activities");
}
})
});
// 获取状态颜色类名
const getStatusColorClass = (status) => {
switch (status) {
case '活动中':
return 'bg-blue-100 text-blue-700'
case '进行中':
return 'bg-green-100 text-green-700'
case '即将开始':
return 'bg-orange-100 text-orange-700'
case '已结束':
return 'bg-gray-100 text-gray-700'
case "活动中":
return "bg-blue-100 text-blue-700";
case "进行中":
return "bg-green-100 text-green-700";
case "即将开始":
return "bg-orange-100 text-orange-700";
case "已结束":
return "bg-gray-100 text-gray-700";
default:
return 'bg-gray-100 text-gray-700'
return "bg-gray-100 text-gray-700";
}
}
};
// 相关活动
const relatedActivities = ref([])
const relatedActivities = ref([]);
onMounted(() => {
if (activity.value) {
relatedActivities.value = activities
.filter(a => a.id !== activity.value.id)
.slice(0, 3)
.filter((a) => a.id !== activity.value.id)
.slice(0, 3);
}
})
});
// 页面导航
const navigateTo = (path) => {
router.push(path)
}
router.push(path);
};
// 右侧内容组件
const RightContent = defineComponent({
setup() {
return () => h('div', { class: 'flex' }, [
h('button', { class: 'p-2' }, [
h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
class: 'h-6 w-6 text-gray-700',
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
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'
})
])
return () =>
h("div", { class: "flex" }, [
h("button", { class: "p-2" }, [
h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
class: "h-6 w-6 text-gray-700",
fill: "none",
viewBox: "0 0 24 24",
stroke: "currentColor",
},
[
h("path", {
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2",
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",
}),
]
),
]),
h('button', { class: 'p-2' }, [
h('svg', {
xmlns: 'http://www.w3.org/2000/svg',
class: 'h-6 w-6 text-gray-700',
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
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'
})
])
])
])
}
})
h("button", { class: "p-2" }, [
h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
class: "h-6 w-6 text-gray-700",
fill: "none",
viewBox: "0 0 24 24",
stroke: "currentColor",
},
[
h("path", {
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2",
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",
}),
]
),
]),
]);
},
});
</script>
<template>
......@@ -104,8 +115,12 @@ const RightContent = defineComponent({
:alt="activity?.title"
class="w-full h-full object-cover"
/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<div class="inline-block px-2 py-1 rounded bg-white/20 backdrop-blur-sm text-white text-xs mb-2">
<div
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4"
>
<div
class="inline-block px-2 py-1 rounded bg-white/20 backdrop-blur-sm text-white text-xs mb-2"
>
{{ activity?.subtitle }}
</div>
<h1 class="text-xl text-white font-bold">{{ activity?.title }}</h1>
......@@ -121,14 +136,30 @@ const RightContent = defineComponent({
<span class="px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-xs">
教育
</span>
<span :class="['px-2 py-1 rounded-full text-xs', getStatusColorClass(activity?.status)]">
<span
:class="[
'px-2 py-1 rounded-full text-xs',
getStatusColorClass(activity?.status),
]"
>
{{ activity?.status }}
</span>
</div>
<div>
<button class="p-1">
<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">
<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" />
<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"
>
<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"
/>
</svg>
</button>
</div>
......@@ -145,7 +176,7 @@ const RightContent = defineComponent({
'pb-3 font-medium',
activeTab === tab
? 'text-green-600 border-b-2 border-green-600'
: 'text-gray-500'
: 'text-gray-500',
]"
>
{{ tab }}
......@@ -161,9 +192,25 @@ const RightContent = defineComponent({
<div class="space-y-4">
<div class="flex items-start">
<div class="p-2 rounded-full bg-green-100 mr-3">
<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">
<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" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<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"
>
<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"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class="flex-1">
......@@ -174,8 +221,19 @@ const RightContent = defineComponent({
<div class="flex items-start">
<div class="p-2 rounded-full bg-blue-100 mr-3">
<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">
<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" />
<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"
>
<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"
/>
</svg>
</div>
<div class="flex-1">
......@@ -187,28 +245,64 @@ const RightContent = defineComponent({
<div class="flex items-start">
<div class="p-2 rounded-full bg-purple-100 mr-3">
<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">
<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" />
<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"
>
<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"
/>
</svg>
</div>
<div class="flex-1">
<p class="font-medium">参与人数</p>
<p class="text-gray-600 text-sm mt-1">{{ activity?.participantsCount }}人 / 上限{{ activity?.maxParticipants }}人</p>
<p class="text-gray-600 text-sm mt-1">
{{ activity?.participantsCount }}人 / 上限{{
activity?.maxParticipants
}}人
</p>
<div class="w-full bg-gray-200 rounded-full h-1.5 mt-2">
<div class="bg-green-600 h-1.5 rounded-full" :style="{ width: (activity?.participantsCount / activity?.maxParticipants * 100) + '%' }"></div>
<div
class="bg-green-600 h-1.5 rounded-full"
:style="{
width:
(activity?.participantsCount / activity?.maxParticipants) *
100 +
'%',
}"
></div>
</div>
</div>
</div>
<div class="flex items-start">
<div class="p-2 rounded-full bg-orange-100 mr-3">
<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">
<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" />
<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"
>
<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"
/>
</svg>
</div>
<div class="flex-1">
<p class="font-medium">活动费用</p>
<p class="text-gray-600 text-sm mt-1">¥{{ activity?.price }}/人(包含材料费、场地费)</p>
<p class="text-gray-600 text-sm mt-1">
¥{{ activity?.price }}/人(包含材料费、场地费)
</p>
</div>
</div>
</div>
......@@ -218,15 +312,13 @@ const RightContent = defineComponent({
<FrostedGlass class="p-4 mb-4 rounded-xl">
<h2 class="text-lg font-semibold mb-3">活动介绍</h2>
<p class="text-gray-700 whitespace-pre-line">
{{ activity?.title }} 是一个精心设计的亲子互动活动,旨在增强家长与孩子之间的沟通与理解。
{{
activity?.title
}}
是一个精心设计的亲子互动活动,旨在增强家长与孩子之间的沟通与理解。
通过一系列有趣的游戏和学习环节,让孩子在轻松愉快的氛围中学习知识,培养良好的学习习惯和价值观。同时,也给予家长更多指导和支持,帮助他们更好地理解孩子的需求和成长过程。
活动特色:
- 专业导师全程引导
- 互动性强,参与感高
- 寓教于乐,收获满满
- 结交志同道合的家庭
活动特色: - 专业导师全程引导 - 互动性强,参与感高 - 寓教于乐,收获满满 -
结交志同道合的家庭
</p>
</FrostedGlass>
</div>
......@@ -237,17 +329,26 @@ const RightContent = defineComponent({
<div class="grid grid-cols-4 gap-4">
<div v-for="index in 12" :key="index" class="flex flex-col items-center">
<div class="w-14 h-14 rounded-full bg-gray-200 overflow-hidden mb-1">
<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">
<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" />
<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"
>
<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"
/>
</svg>
</div>
<span class="text-xs text-gray-600">用户{{ index }}</span>
</div>
</div>
<div class="mt-4 flex justify-center">
<button class="text-green-600 text-sm">
查看全部参与者
</button>
<button class="text-green-600 text-sm">查看全部参与者</button>
</div>
</FrostedGlass>
</div>
......@@ -310,6 +411,28 @@ const RightContent = defineComponent({
</div>
</div>
</div>
<!-- Bottom Action Bar -->
<div
class="fixed bottom-16 left-0 right-0 bg-white p-3 shadow-lg flex justify-between items-center"
>
<div class="flex items-end">
<div class="text-red-500 text-lg font-bold">¥366</div>
<div class="text-xs text-gray-500 ml-1 line-through">原价¥468</div>
</div>
<div class="flex space-x-3">
<button
class="px-4 py-2 border border-green-600 text-green-600 rounded-full text-sm"
>
咨询详情
</button>
<button
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"
>
立即报名
</button>
</div>
</div>
</div>
</AppLayout>
</template>
......
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AppLayout from '../../components/layout/AppLayout';
import FrostedGlass from '../../components/ui/FrostedGlass';
import { useCart } from '../../contexts/CartContext';
/**
* CheckoutPage component handles the order checkout process
* with user information collection and payment method selection
*
* @returns {JSX.Element} CheckoutPage component
*/
const CheckoutPage = () => {
const navigate = useNavigate();
const { cartItems, getTotalPrice, handleCheckout, clearCart } = useCart();
// Form state
const [formData, setFormData] = useState({
name: '',
phone: '',
email: '',
address: '',
notes: '',
paymentMethod: 'wechat'
});
// Loading and success states
const [isProcessing, setIsProcessing] = useState(false);
const [orderComplete, setOrderComplete] = useState(false);
const [orderId, setOrderId] = useState('');
// Format price with Chinese Yuan symbol
const formatPrice = (price) => {
return ${price.toFixed(2)}`;
};
// Handle form input changes
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name || !formData.phone || !formData.address) {
alert('请填写必要信息');
return;
}
setIsProcessing(true);
try {
// Process checkout
const result = await handleCheckout(formData);
if (result.success) {
setOrderId(result.orderId);
setOrderComplete(true);
}
} catch (error) {
console.error('Checkout failed:', error);
alert('支付失败,请重试');
} finally {
setIsProcessing(false);
}
};
// Handle navigation back to home after order completion
const handleBackToHome = () => {
navigate('/');
};
// If cart is empty, redirect to home
if (cartItems.length === 0 && !orderComplete) {
return (
<AppLayout title="结账">
<div className="h-screen flex flex-col items-center justify-center px-4">
<FrostedGlass className="p-6 rounded-xl text-center">
<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">
<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" />
</svg>
<h2 className="text-xl font-bold mb-2">购物车为空</h2>
<p className="text-gray-600 mb-6">您的购物车中没有任何商品</p>
<button
onClick={() => navigate('/courses')}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium"
>
浏览课程
</button>
</FrostedGlass>
</div>
</AppLayout>
);
}
// Show order completion screen
if (orderComplete) {
return (
<AppLayout title="支付成功">
<div className="h-screen flex flex-col items-center justify-center px-4">
<FrostedGlass className="p-6 rounded-xl text-center">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold mb-2">支付成功!</h2>
<p className="text-gray-600 mb-2">您的订单已经成功提交</p>
<p className="text-gray-500 text-sm mb-6">订单号: {orderId}</p>
<button
onClick={handleBackToHome}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium"
>
返回首页
</button>
</FrostedGlass>
</div>
</AppLayout>
);
}
return (
<AppLayout title="结账" showBackButton onBackClick={() => navigate(-1)}>
<div className="pb-20">
{/* Order Summary */}
<div className="p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10">
<FrostedGlass className="rounded-xl p-4">
<h3 className="font-medium mb-3">订单摘要</h3>
<div className="space-y-3">
{cartItems.map((item) => (
<div key={`${item.type}-${item.id}`} className="flex justify-between">
<div className="flex flex-1">
<div className="w-12 h-12 rounded-lg overflow-hidden mr-3 flex-shrink-0">
<img
src={item.imageUrl || "/assets/images/course-placeholder.jpg"}
alt={item.title}
className="w-full h-full object-cover"
onError={(e) => {
e.target.onerror = null;
e.target.src = "/assets/images/course-placeholder.jpg";
}}
/>
</div>
<div>
<p className="font-medium text-sm line-clamp-1">{item.title}</p>
<p className="text-xs text-gray-500">
{item.type === 'course' ? '课程' : '活动'} · {item.quantity}
</p>
</div>
</div>
<div className="ml-2 text-right">
<p className="font-medium text-sm">{formatPrice(item.price * item.quantity)}</p>
<p className="text-xs text-gray-500">{formatPrice(item.price)} / 份</p>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600">小计</span>
<span className="font-medium">{formatPrice(getTotalPrice())}</span>
</div>
<div className="flex justify-between items-center text-sm mt-1">
<span className="text-gray-600">优惠</span>
<span className="text-red-500">- ¥0.00</span>
</div>
<div className="flex justify-between items-center mt-2 font-medium">
<span>总计</span>
<span className="text-lg text-green-600">{formatPrice(getTotalPrice())}</span>
</div>
</div>
</FrostedGlass>
</div>
{/* Checkout Form */}
<div className="px-4 pt-4">
<form onSubmit={handleSubmit}>
<FrostedGlass className="rounded-xl p-4 mb-4">
<h3 className="font-medium mb-3">个人信息</h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600 mb-1">姓名 *</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的姓名"
required
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">手机号码 *</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的手机号码"
required
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">电子邮箱</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的邮箱(选填)"
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">联系地址 *</label>
<input
type="text"
name="address"
value={formData.address}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的详细地址"
required
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">备注</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none h-20"
placeholder="有什么需要我们注意的事项?(选填)"
/>
</div>
</div>
</FrostedGlass>
<FrostedGlass className="rounded-xl p-4 mb-6">
<h3 className="font-medium mb-3">支付方式</h3>
<div className="space-y-2">
<label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
type="radio"
name="paymentMethod"
value="wechat"
checked={formData.paymentMethod === 'wechat'}
onChange={handleInputChange}
className="mr-3"
/>
<span className="flex-1">微信支付</span>
<svg className="h-6 w-6 text-green-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M14.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M9.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M14.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<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" />
</svg>
</label>
<label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
type="radio"
name="paymentMethod"
value="alipay"
checked={formData.paymentMethod === 'alipay'}
onChange={handleInputChange}
className="mr-3"
/>
<span className="flex-1">支付宝</span>
<svg className="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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" />
</svg>
</label>
<label className="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
type="radio"
name="paymentMethod"
value="bank"
checked={formData.paymentMethod === 'bank'}
onChange={handleInputChange}
className="mr-3"
/>
<span className="flex-1">银行卡</span>
<svg className="h-6 w-6 text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<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" />
<path d="M5,13h2v2h-2z" />
<path d="M9,13h2v2h-2z" />
<path d="M13,13h2v2h-2z" />
<path d="M17,13h2v2h-2z" />
</svg>
</label>
</div>
</FrostedGlass>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 shadow-md px-4 py-3">
<div className="flex justify-between items-center mb-3">
<div>
<span className="text-sm text-gray-500">总计:</span>
<span className="text-lg font-bold text-green-600">{formatPrice(getTotalPrice())}</span>
</div>
<button
type="button"
onClick={() => clearCart()}
className="text-sm text-red-500"
>
清空购物车
</button>
</div>
<button
type="submit"
disabled={isProcessing}
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"
>
{isProcessing ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
处理中...
</>
) : (
'确认支付'
)}
</button>
</div>
</form>
</div>
</div>
</AppLayout>
);
};
export default CheckoutPage;
\ No newline at end of file
<template>
<AppLayout :title="orderComplete ? '支付成功' : '结账'" :show-back-button="!orderComplete" @back-click="router.back()">
<div v-if="cartItems.length === 0 && !orderComplete" class="h-screen flex flex-col items-center justify-center px-4">
<FrostedGlass class="p-6 rounded-xl text-center">
<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">
<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" />
</svg>
<h2 class="text-xl font-bold mb-2">购物车为空</h2>
<p class="text-gray-600 mb-6">您的购物车中没有任何商品</p>
<button
@click="router.push('/courses')"
class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium"
>
浏览课程
</button>
</FrostedGlass>
</div>
<div v-else-if="orderComplete" class="h-screen flex flex-col items-center justify-center px-4">
<FrostedGlass class="p-6 rounded-xl text-center">
<div class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">支付成功!</h2>
<p class="text-gray-600 mb-2">您的订单已经成功提交</p>
<p class="text-gray-500 text-sm mb-6">订单号: {{ orderId }}</p>
<button
@click="handleBackToHome"
class="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-xl font-medium"
>
返回首页
</button>
</FrostedGlass>
</div>
<div v-else class="pb-20">
<!-- Order Summary -->
<div class="p-4 bg-gradient-to-r from-green-500/10 to-blue-500/10">
<FrostedGlass class="rounded-xl p-4">
<h3 class="font-medium mb-3">订单摘要</h3>
<div class="space-y-3">
<div v-for="item in cartItems" :key="`${item.type}-${item.id}`" class="flex justify-between">
<div class="flex flex-1">
<div class="w-12 h-12 rounded-lg overflow-hidden mr-3 flex-shrink-0">
<img
:src="item.imageUrl || '/assets/images/course-placeholder.jpg'"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<div>
<p class="font-medium text-sm line-clamp-1">{{ item.title }}</p>
<p class="text-xs text-gray-500">
{{ item.type === 'course' ? '课程' : '活动' }} · {{ item.quantity }} 份
</p>
</div>
</div>
<div class="ml-2 text-right">
<p class="font-medium text-sm">{{ formatPrice(item.price * item.quantity) }}</p>
<p class="text-xs text-gray-500">{{ formatPrice(item.price) }} / 份</p>
</div>
</div>
</div>
<div class="mt-4 pt-3 border-t border-gray-200">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">小计</span>
<span class="font-medium">{{ formatPrice(getTotalPrice()) }}</span>
</div>
<div class="flex justify-between items-center text-sm mt-1">
<span class="text-gray-600">优惠</span>
<span class="text-red-500">- ¥0.00</span>
</div>
<div class="flex justify-between items-center mt-2 font-medium">
<span>总计</span>
<span class="text-lg text-green-600">{{ formatPrice(getTotalPrice()) }}</span>
</div>
</div>
</FrostedGlass>
</div>
<!-- Checkout Form -->
<div class="px-4 pt-4">
<form @submit.prevent="handleSubmit">
<FrostedGlass class="rounded-xl p-4 mb-4">
<h3 class="font-medium mb-3">个人信息</h3>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-600 mb-1">姓名 *</label>
<input
v-model="formData.name"
type="text"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的姓名"
required
/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">手机号码 *</label>
<input
v-model="formData.phone"
type="tel"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的手机号码"
required
/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">电子邮箱</label>
<input
v-model="formData.email"
type="email"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的邮箱(选填)"
/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">联系地址 *</label>
<input
v-model="formData.address"
type="text"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
placeholder="请输入您的详细地址"
required
/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">备注</label>
<textarea
v-model="formData.notes"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm resize-none h-20"
placeholder="有什么需要我们注意的事项?(选填)"
/>
</div>
</div>
</FrostedGlass>
<FrostedGlass class="rounded-xl p-4 mb-6">
<h3 class="font-medium mb-3">支付方式</h3>
<div class="space-y-2">
<label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
v-model="formData.paymentMethod"
type="radio"
value="wechat"
class="mr-3"
/>
<span class="flex-1">微信支付</span>
<svg class="h-6 w-6 text-green-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M14.5,8.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M9.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path d="M14.5,14.5m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<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" />
</svg>
</label>
<label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
v-model="formData.paymentMethod"
type="radio"
value="alipay"
class="mr-3"
/>
<span class="flex-1">支付宝</span>
<svg class="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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" />
</svg>
</label>
<label class="flex items-center p-3 border border-gray-200 rounded-lg bg-white/50">
<input
v-model="formData.paymentMethod"
type="radio"
value="bank"
class="mr-3"
/>
<span class="flex-1">银行卡</span>
<svg class="h-6 w-6 text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<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" />
<path d="M5,13h2v2h-2z" />
<path d="M9,13h2v2h-2z" />
<path d="M13,13h2v2h-2z" />
<path d="M17,13h2v2h-2z" />
</svg>
</label>
</div>
</FrostedGlass>
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 shadow-md px-4 py-3">
<div class="flex justify-between items-center mb-3">
<div>
<span class="text-sm text-gray-500">总计:</span>
<span class="text-lg font-bold text-green-600">{{ formatPrice(getTotalPrice()) }}</span>
</div>
<button
type="button"
@click="clearCart"
class="text-sm text-red-500"
>
清空购物车
</button>
</div>
<button
type="submit"
:disabled="isProcessing"
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"
>
<template v-if="isProcessing">
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>
处理中...
</template>
<template v-else>
确认支付
</template>
</button>
</div>
</form>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import { useCart } from '@/contexts/cart'
const router = useRouter()
const { items: cartItems, getTotalPrice, handleCheckout, clearCart } = useCart()
// Form state
const formData = ref({
name: '',
phone: '',
email: '',
address: '',
notes: '',
paymentMethod: 'wechat'
})
// Loading and success states
const isProcessing = ref(false)
const orderComplete = ref(false)
const orderId = ref('')
// Format price with Chinese Yuan symbol
const formatPrice = (price) => {
return `¥${price.toFixed(2)}`
}
// Handle image error
const handleImageError = (e) => {
e.target.src = '/assets/images/course-placeholder.jpg'
}
// Handle form submission
const handleSubmit = async (e) => {
if (!formData.value.name || !formData.value.phone || !formData.value.address) {
alert('请填写必要信息')
return
}
isProcessing.value = true
try {
// Process checkout
const result = await handleCheckout(formData.value)
if (result.success) {
orderId.value = result.orderId
orderComplete.value = true
}
} catch (error) {
console.error('Checkout failed:', error)
alert('支付失败,请重试')
} finally {
isProcessing.value = false
}
}
// Handle navigation back to home after order completion
const handleBackToHome = () => {
router.push('/')
}