hookehuyr

feat: 添加购物车上下文和结账页面

引入购物车上下文(provideCart)以管理购物车状态,并添加结账页面路由。同时,优化AppLayout组件以支持隐藏底部导航栏,并在课程详情页中实现购买功能。这些改动为结账流程提供了基础支持。
......@@ -8,9 +8,11 @@
<script setup>
import { RouterView } from "vue-router";
import { provideAuth } from '@/contexts/auth';
import { provideCart } from '@/contexts/cart';
// 提供认证上下文
// 提供认证和购物车上下文
provideAuth();
provideCart();
</script>
<template>
......
import React from 'react';
import BottomNav from './BottomNav';
import GradientHeader from '../ui/GradientHeader';
/**
* AppLayout component provides consistent layout across the app
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child elements
* @param {string} props.title - Page title
* @param {boolean} props.showBackButton - Whether to display back button
* @param {Function} props.onBack - Back button click handler
* @param {ReactNode} props.rightContent - Content to display on the right side of header
* @returns {JSX.Element} AppLayout component
*/
const AppLayout = ({ children, title, showBackButton, onBack, rightContent }) => {
const handleBack = () => {
if (onBack) {
onBack();
} else {
window.history.back();
}
};
return (
<div className="bg-gradient-to-br from-green-50 via-teal-50 to-blue-50 min-h-screen pb-16">
<GradientHeader
title={title}
showBackButton={showBackButton}
onBack={handleBack}
rightContent={rightContent}
/>
<main className="pb-16">
{children}
</main>
<BottomNav />
</div>
);
};
export default AppLayout;
\ No newline at end of file
......@@ -9,7 +9,7 @@
<main class="pb-16">
<slot></slot>
</main>
<BottomNav />
<BottomNav v-if="!hideBottomNav" />
</div>
</template>
......@@ -34,6 +34,10 @@ const props = defineProps({
rightContent: {
type: Object,
default: null
},
hideBottomNav: {
type: Boolean,
default: false
}
})
......
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
/**
* BottomNav component for app navigation
*
* @returns {JSX.Element} BottomNav component
*/
const BottomNav = () => {
const location = useLocation();
const navItems = [
{
name: '首页',
path: '/',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
},
{
name: '课程',
path: '/courses',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)
},
{
name: '空间',
path: '/community',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)
},
{
name: '我的',
path: '/profile',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
)
}
];
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white/70 backdrop-blur-lg border-t border-gray-100 z-50">
<div className="flex justify-around items-center h-16">
{navItems.map((item) => {
const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path));
return (
<Link
key={item.name}
to={item.path}
className={`flex flex-col items-center justify-center w-1/4 h-full ${
isActive ? 'text-green-500' : 'text-gray-500'
}`}
>
{React.cloneElement(item.icon, {
className: `${item.icon.props.className} ${isActive ? 'text-green-500' : 'text-gray-500'}`
})}
<span className="text-xs">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
};
export default BottomNav;
\ No newline at end of file
......@@ -86,12 +86,36 @@ export function provideCart() {
// 处理结账流程
function handleCheckout(userData) {
console.warn('Processing order with data:', { items: cartItems.value, userData })
// 构建订单数据
const orderData = {
items: cartItems.value.map(item => ({
id: item.id,
type: item.type,
quantity: item.quantity,
price: item.price,
title: item.title
})),
totalAmount: getTotalPrice(),
userData: userData,
orderDate: new Date().toISOString()
}
// TODO: 替换为实际的API调用
return new Promise((resolve) => {
// 模拟API调用
setTimeout(() => {
// 在实际应用中,这里应该调用后端API
console.warn('提交订单数据:', orderData)
// 订单提交成功后清空购物车
clearCart()
resolve({ success: true, orderId: 'ORD-' + Date.now() })
// 返回订单ID
resolve({
success: true,
orderId: 'ORD-' + Date.now(),
orderData: orderData
})
}, 1500)
})
}
......
......@@ -57,6 +57,13 @@ const routes = [
props: true,
meta: { title: 'Home' }
},
{
path: '/checkout',
name: 'CheckoutPage',
component: () => import('../views/checkout/CheckoutPage.vue'),
props: true,
meta: { title: 'Home' }
},
]
const router = createRouter({
......
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()">
<AppLayout :title="orderComplete ? '支付成功' : '结账'" :show-back-button="!orderComplete" :hide-bottom-nav="true" @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">
......@@ -294,3 +294,4 @@ const handleSubmit = async (e) => {
const handleBackToHome = () => {
router.push('/')
}
</script>
......
......@@ -241,7 +241,10 @@
¥{{ Math.round(course?.price * 1.2) }}
</div>
</div>
<button class="bg-gradient-to-r from-green-500 to-green-600 text-white px-6 py-2 rounded-full text-sm font-medium shadow-md">
<button
@click="handlePurchase"
class="bg-gradient-to-r from-green-500 to-green-600 text-white px-6 py-2 rounded-full text-sm font-medium shadow-md"
>
立即购买
</button>
</div>
......@@ -256,11 +259,13 @@ import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import { courses } from '@/utils/mockData'
import { useCart } from '@/contexts/cart'
const route = useRoute()
const router = useRouter()
const course = ref(null)
const activeTab = ref('课程特色')
const { addToCart, proceedToCheckout } = useCart()
// Curriculum items
const curriculumItems = [
......@@ -301,6 +306,20 @@ const RightContent = defineComponent({
const rightContent = h(RightContent)
// Handle purchase
const handlePurchase = () => {
if (course.value) {
addToCart({
id: course.value.id,
type: 'course',
title: course.value.title,
price: course.value.price,
imageUrl: course.value.imageUrl
})
proceedToCheckout()
}
}
// Fetch course data
onMounted(() => {
const id = route.params.id
......