hookehuyr

feat: 添加认证上下文、购物车上下文及个人主页功能

添加了认证上下文 `auth.js` 和 `AuthContext.jsx`,用于管理用户登录和登出状态。同时引入了购物车上下文 `CartContext.jsx`,支持购物车商品的增删改查操作。新增了个人主页 `ProfilePage.vue`,展示用户信息、打卡统计及菜单选项。
......@@ -7,6 +7,10 @@
-->
<script setup>
import { RouterView } from "vue-router";
import { provideAuth } from '@/contexts/auth';
// 提供认证上下文
provideAuth();
</script>
<template>
......
<template>
<div
class="flex items-center justify-between p-4 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-white/30 active:bg-white/50 transition-colors"
@click="$emit('click')"
>
<div class="flex items-center">
<div class="text-gray-500 mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
v-if="icon === 'clock'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
v-if="icon === 'user'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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"
/>
<path
v-if="icon === 'book'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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"
/>
<path
v-if="icon === 'heart'"
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"
/>
<path
v-if="icon === 'wallet'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
<path
v-if="icon === 'document'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
<path
v-if="icon === 'chat'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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"
/>
<path
v-if="icon === 'email'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
<path
v-if="icon === 'question'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
v-if="icon === 'settings'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<span class="font-medium">{{ title }}</span>
<div
v-if="badge"
class="ml-2 px-1.5 py-0.5 bg-red-500 rounded-full text-white text-xs font-medium min-w-[18px] text-center"
>
{{ badge }}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
path: {
type: String,
required: true
},
badge: {
type: String,
default: ''
}
})
defineEmits(['click'])
</script>
import React, { createContext, useContext, useState, useEffect } from 'react';
/**
* Authentication Context for user login/logout functionality
*/
const AuthContext = createContext();
/**
* AuthProvider component to manage authentication state
*
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child elements
* @returns {JSX.Element} AuthProvider component
*/
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
// Check for saved user on mount
useEffect(() => {
const savedUser = localStorage.getItem('currentUser');
if (savedUser) {
setCurrentUser(JSON.parse(savedUser));
}
setLoading(false);
}, []);
// Login function
const login = (userData) => {
setCurrentUser(userData);
localStorage.setItem('currentUser', JSON.stringify(userData));
return true;
};
// Logout function
const logout = () => {
setCurrentUser(null);
localStorage.removeItem('currentUser');
};
// Context value
const value = {
currentUser,
loading,
login,
logout
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
};
/**
* Hook to use auth functionality throughout the app
* @returns {Object} Auth context value
*/
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export default AuthContext;
\ No newline at end of file
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, onMounted } from 'vue'
// 创建认证上下文的key
const authKey = Symbol('auth')
/**
* 提供认证功能的组合式函数
*/
export function provideAuth() {
const currentUser = ref(null)
const loading = ref(true)
// 初始化时检查本地存储的用户信息
onMounted(() => {
const savedUser = localStorage.getItem('currentUser')
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
loading.value = false
})
// 登录函数
const login = (userData) => {
currentUser.value = userData
localStorage.setItem('currentUser', JSON.stringify(userData))
return true
}
// 登出函数
const logout = () => {
currentUser.value = null
localStorage.removeItem('currentUser')
}
// 提供认证上下文
provide(authKey, {
currentUser,
loading,
login,
logout
})
return {
currentUser,
loading,
login,
logout
}
}
/**
* 使用认证功能的组合式函数
* @returns {Object} 认证上下文值
*/
export function useAuth() {
const auth = inject(authKey)
if (!auth) {
throw new Error('useAuth 必须在 provideAuth 的范围内使用')
}
return auth
}
/*
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-20 20:50:41
* @LastEditTime: 2025-03-20 21:04:41
* @FilePath: /mlaj/src/router/index.js
* @Description: 文件描述
*/
......@@ -26,6 +26,12 @@ const routes = [
component: () => import('../views/courses/CourseDetailPage.vue'),
meta: { title: 'CourseDetail' },
},
{
path: '/profile',
name: 'Profile',
component: () => import('../views/profile/ProfilePage.vue'),
meta: { title: 'Profile' },
},
]
const router = createRouter({
......
<template>
<AppLayout title="我的" :right-content="rightContent">
<div class="bg-gradient-to-br from-green-50 via-green-100/30 to-blue-50/30 min-h-screen">
<!-- User Profile Header with Enhanced Design -->
<div class="pt-6 pb-8 relative">
<div class="absolute inset-0 bg-gradient-to-r from-green-500 to-blue-500 opacity-15"></div>
<div class="relative z-10 flex flex-col items-center">
<div class="w-24 h-24 rounded-full overflow-hidden border-4 border-white shadow-lg mb-4">
<img
:src="profile.avatar || '/assets/images/user-avatar-1.jpg'"
:alt="profile.name"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
<h2 class="text-2xl font-bold mb-1">{{ profile.name }}</h2>
<div class="flex items-center text-sm text-gray-600">
<span>会员等级: 普通会员</span>
<span class="mx-2">|</span>
<span>ID: 88361425</span>
</div>
</div>
</div>
<!-- Check-in Statistics -->
<div class="px-4 mb-5">
<FrostedGlass class="p-4 rounded-xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-base">打卡统计</h3>
<span class="text-xs text-blue-500">查看更多</span>
</div>
<div class="flex justify-between">
<div class="flex flex-col items-center">
<div class="text-2xl font-bold text-gray-800">{{ profile.checkIns?.totalDays || 0 }}</div>
<div class="text-xs text-gray-500 mt-1">累计打卡</div>
</div>
<div class="flex flex-col items-center">
<div class="text-2xl font-bold text-green-600">{{ profile.checkIns?.currentStreak || 0 }}</div>
<div class="text-xs text-gray-500 mt-1">连续打卡</div>
</div>
<div class="flex flex-col items-center">
<div class="text-2xl font-bold text-blue-600">{{ profile.checkIns?.longestStreak || 0 }}</div>
<div class="text-xs text-gray-500 mt-1">最长连续</div>
</div>
<div>
<button class="bg-gradient-to-r from-green-500 to-green-600 text-white py-2 px-6 rounded-full text-sm shadow-sm">
立即打卡
</button>
</div>
</div>
</FrostedGlass>
</div>
<!-- Check-in Types -->
<div class="px-4 mb-5">
<FrostedGlass class="p-4 rounded-xl">
<h3 class="font-semibold text-base mb-4">打卡项目</h3>
<div class="grid grid-cols-4 gap-2">
<div v-for="type in checkInTypes" :key="type.id" class="flex flex-col items-center">
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mb-1">
<svg v-if="type.icon === 'book'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<svg v-if="type.icon === 'running'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-if="type.icon === 'graduation-cap'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path d="M12 14l9-5-9-5-9 5 9 5z" />
<path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222" />
</svg>
<svg v-if="type.icon === 'pencil-alt'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<span class="text-xs">{{ type.name }}</span>
</div>
</div>
</FrostedGlass>
</div>
<!-- User Menu Options -->
<div class="px-4 pb-16">
<FrostedGlass class="rounded-xl overflow-hidden mb-5">
<MenuItem
v-for="item in menuItems1"
:key="item.path"
v-bind="item"
@click="handleMenuClick(item.path)"
/>
</FrostedGlass>
<FrostedGlass class="rounded-xl overflow-hidden mb-5">
<MenuItem
v-for="item in menuItems2"
:key="item.path"
v-bind="item"
@click="handleMenuClick(item.path)"
/>
</FrostedGlass>
<FrostedGlass class="rounded-xl overflow-hidden mb-5">
<MenuItem
v-for="item in menuItems3"
:key="item.path"
v-bind="item"
@click="handleMenuClick(item.path)"
/>
</FrostedGlass>
<!-- Version Info -->
<div class="text-center text-xs text-gray-400 mb-4">
亲子教育 App v1.2.0
</div>
<!-- Logout Button -->
<button
@click="handleLogout"
class="w-full bg-white/70 backdrop-blur-sm text-red-500 py-3 rounded-xl mb-6 font-medium shadow-sm"
>
退出登录
</button>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, h } from 'vue'
import { useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import FrostedGlass from '@/components/ui/FrostedGlass.vue'
import MenuItem from '@/components/ui/MenuItem.vue'
import { useAuth } from '@/contexts/auth'
import { userProfile, checkInTypes } from '@/utils/mockData'
const router = useRouter()
const { currentUser, logout } = useAuth()
const profile = ref(userProfile)
// Handle logout
const handleLogout = () => {
logout()
router.push('/login')
}
// Handle menu item click
const handleMenuClick = (path) => {
router.push(path)
}
// Handle image error
const handleImageError = (e) => {
e.target.onerror = null
e.target.src = '/assets/images/user-avatar-1.jpg'
}
// Right content component
const rightContent = h('div', { class: 'flex items-center' }, [
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: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9'
})
])
])
])
// Menu items
const menuItems1 = [
{
icon: 'clock',
title: '学习记录',
path: '/profile/learning-records',
badge: 'NEW'
},
{
icon: 'user',
title: '我的活动',
path: '/profile/activities'
},
{
icon: 'book',
title: '我的课程',
path: '/profile/courses'
},
{
icon: 'heart',
title: '我的收藏',
path: '/profile/favorites'
}
]
const menuItems2 = [
{
icon: 'wallet',
title: '我的钱包',
path: '/profile/wallet'
},
{
icon: 'document',
title: '我的订单',
path: '/profile/orders'
},
{
icon: 'chat',
title: '我的圈子',
path: '/profile/community'
}
]
const menuItems3 = [
{
icon: 'email',
title: '消息中心',
path: '/profile/messages',
badge: '3'
},
{
icon: 'question',
title: '帮助中心',
path: '/profile/help'
},
{
icon: 'settings',
title: '设置',
path: '/profile/settings'
}
]
</script>