feat: 添加认证上下文、购物车上下文及个人主页功能
添加了认证上下文 `auth.js` 和 `AuthContext.jsx`,用于管理用户登录和登出状态。同时引入了购物车上下文 `CartContext.jsx`,支持购物车商品的增删改查操作。新增了个人主页 `ProfilePage.vue`,展示用户信息、打卡统计及菜单选项。
Showing
7 changed files
with
640 additions
and
1 deletions
| ... | @@ -7,6 +7,10 @@ | ... | @@ -7,6 +7,10 @@ |
| 7 | --> | 7 | --> |
| 8 | <script setup> | 8 | <script setup> |
| 9 | import { RouterView } from "vue-router"; | 9 | import { RouterView } from "vue-router"; |
| 10 | +import { provideAuth } from '@/contexts/auth'; | ||
| 11 | + | ||
| 12 | +// 提供认证上下文 | ||
| 13 | +provideAuth(); | ||
| 10 | </script> | 14 | </script> |
| 11 | 15 | ||
| 12 | <template> | 16 | <template> | ... | ... |
src/components/ui/MenuItem.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div | ||
| 3 | + 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" | ||
| 4 | + @click="$emit('click')" | ||
| 5 | + > | ||
| 6 | + <div class="flex items-center"> | ||
| 7 | + <div class="text-gray-500 mr-3"> | ||
| 8 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 9 | + <path | ||
| 10 | + v-if="icon === 'clock'" | ||
| 11 | + stroke-linecap="round" | ||
| 12 | + stroke-linejoin="round" | ||
| 13 | + stroke-width="2" | ||
| 14 | + d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" | ||
| 15 | + /> | ||
| 16 | + <path | ||
| 17 | + v-if="icon === 'user'" | ||
| 18 | + stroke-linecap="round" | ||
| 19 | + stroke-linejoin="round" | ||
| 20 | + stroke-width="2" | ||
| 21 | + d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" | ||
| 22 | + /> | ||
| 23 | + <path | ||
| 24 | + v-if="icon === 'book'" | ||
| 25 | + stroke-linecap="round" | ||
| 26 | + stroke-linejoin="round" | ||
| 27 | + stroke-width="2" | ||
| 28 | + 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" | ||
| 29 | + /> | ||
| 30 | + <path | ||
| 31 | + v-if="icon === 'heart'" | ||
| 32 | + stroke-linecap="round" | ||
| 33 | + stroke-linejoin="round" | ||
| 34 | + stroke-width="2" | ||
| 35 | + 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" | ||
| 36 | + /> | ||
| 37 | + <path | ||
| 38 | + v-if="icon === 'wallet'" | ||
| 39 | + stroke-linecap="round" | ||
| 40 | + stroke-linejoin="round" | ||
| 41 | + stroke-width="2" | ||
| 42 | + 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" | ||
| 43 | + /> | ||
| 44 | + <path | ||
| 45 | + v-if="icon === 'document'" | ||
| 46 | + stroke-linecap="round" | ||
| 47 | + stroke-linejoin="round" | ||
| 48 | + stroke-width="2" | ||
| 49 | + 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" | ||
| 50 | + /> | ||
| 51 | + <path | ||
| 52 | + v-if="icon === 'chat'" | ||
| 53 | + stroke-linecap="round" | ||
| 54 | + stroke-linejoin="round" | ||
| 55 | + stroke-width="2" | ||
| 56 | + 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" | ||
| 57 | + /> | ||
| 58 | + <path | ||
| 59 | + v-if="icon === 'email'" | ||
| 60 | + stroke-linecap="round" | ||
| 61 | + stroke-linejoin="round" | ||
| 62 | + stroke-width="2" | ||
| 63 | + 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" | ||
| 64 | + /> | ||
| 65 | + <path | ||
| 66 | + v-if="icon === 'question'" | ||
| 67 | + stroke-linecap="round" | ||
| 68 | + stroke-linejoin="round" | ||
| 69 | + stroke-width="2" | ||
| 70 | + 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" | ||
| 71 | + /> | ||
| 72 | + <path | ||
| 73 | + v-if="icon === 'settings'" | ||
| 74 | + stroke-linecap="round" | ||
| 75 | + stroke-linejoin="round" | ||
| 76 | + stroke-width="2" | ||
| 77 | + 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" | ||
| 78 | + /> | ||
| 79 | + </svg> | ||
| 80 | + </div> | ||
| 81 | + <span class="font-medium">{{ title }}</span> | ||
| 82 | + <div | ||
| 83 | + v-if="badge" | ||
| 84 | + 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" | ||
| 85 | + > | ||
| 86 | + {{ badge }} | ||
| 87 | + </div> | ||
| 88 | + </div> | ||
| 89 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor"> | ||
| 90 | + <path | ||
| 91 | + fill-rule="evenodd" | ||
| 92 | + 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" | ||
| 93 | + clip-rule="evenodd" | ||
| 94 | + /> | ||
| 95 | + </svg> | ||
| 96 | + </div> | ||
| 97 | +</template> | ||
| 98 | + | ||
| 99 | +<script setup> | ||
| 100 | +defineProps({ | ||
| 101 | + icon: { | ||
| 102 | + type: String, | ||
| 103 | + required: true | ||
| 104 | + }, | ||
| 105 | + title: { | ||
| 106 | + type: String, | ||
| 107 | + required: true | ||
| 108 | + }, | ||
| 109 | + path: { | ||
| 110 | + type: String, | ||
| 111 | + required: true | ||
| 112 | + }, | ||
| 113 | + badge: { | ||
| 114 | + type: String, | ||
| 115 | + default: '' | ||
| 116 | + } | ||
| 117 | +}) | ||
| 118 | + | ||
| 119 | +defineEmits(['click']) | ||
| 120 | +</script> |
src/contexts/AuthContext.jsx
0 → 100644
| 1 | +import React, { createContext, useContext, useState, useEffect } from 'react'; | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * Authentication Context for user login/logout functionality | ||
| 5 | + */ | ||
| 6 | +const AuthContext = createContext(); | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * AuthProvider component to manage authentication state | ||
| 10 | + * | ||
| 11 | + * @param {Object} props - Component props | ||
| 12 | + * @param {ReactNode} props.children - Child elements | ||
| 13 | + * @returns {JSX.Element} AuthProvider component | ||
| 14 | + */ | ||
| 15 | +export const AuthProvider = ({ children }) => { | ||
| 16 | + const [currentUser, setCurrentUser] = useState(null); | ||
| 17 | + const [loading, setLoading] = useState(true); | ||
| 18 | + | ||
| 19 | + // Check for saved user on mount | ||
| 20 | + useEffect(() => { | ||
| 21 | + const savedUser = localStorage.getItem('currentUser'); | ||
| 22 | + if (savedUser) { | ||
| 23 | + setCurrentUser(JSON.parse(savedUser)); | ||
| 24 | + } | ||
| 25 | + setLoading(false); | ||
| 26 | + }, []); | ||
| 27 | + | ||
| 28 | + // Login function | ||
| 29 | + const login = (userData) => { | ||
| 30 | + setCurrentUser(userData); | ||
| 31 | + localStorage.setItem('currentUser', JSON.stringify(userData)); | ||
| 32 | + return true; | ||
| 33 | + }; | ||
| 34 | + | ||
| 35 | + // Logout function | ||
| 36 | + const logout = () => { | ||
| 37 | + setCurrentUser(null); | ||
| 38 | + localStorage.removeItem('currentUser'); | ||
| 39 | + }; | ||
| 40 | + | ||
| 41 | + // Context value | ||
| 42 | + const value = { | ||
| 43 | + currentUser, | ||
| 44 | + loading, | ||
| 45 | + login, | ||
| 46 | + logout | ||
| 47 | + }; | ||
| 48 | + | ||
| 49 | + return ( | ||
| 50 | + <AuthContext.Provider value={value}> | ||
| 51 | + {!loading && children} | ||
| 52 | + </AuthContext.Provider> | ||
| 53 | + ); | ||
| 54 | +}; | ||
| 55 | + | ||
| 56 | +/** | ||
| 57 | + * Hook to use auth functionality throughout the app | ||
| 58 | + * @returns {Object} Auth context value | ||
| 59 | + */ | ||
| 60 | +export const useAuth = () => { | ||
| 61 | + const context = useContext(AuthContext); | ||
| 62 | + if (!context) { | ||
| 63 | + throw new Error("useAuth must be used within an AuthProvider"); | ||
| 64 | + } | ||
| 65 | + return context; | ||
| 66 | +}; | ||
| 67 | + | ||
| 68 | +export default AuthContext; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
src/contexts/CartContext.jsx
0 → 100644
| 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/auth.js
0 → 100644
| 1 | +import { ref, provide, inject, onMounted } from 'vue' | ||
| 2 | + | ||
| 3 | +// 创建认证上下文的key | ||
| 4 | +const authKey = Symbol('auth') | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * 提供认证功能的组合式函数 | ||
| 8 | + */ | ||
| 9 | +export function provideAuth() { | ||
| 10 | + const currentUser = ref(null) | ||
| 11 | + const loading = ref(true) | ||
| 12 | + | ||
| 13 | + // 初始化时检查本地存储的用户信息 | ||
| 14 | + onMounted(() => { | ||
| 15 | + const savedUser = localStorage.getItem('currentUser') | ||
| 16 | + if (savedUser) { | ||
| 17 | + currentUser.value = JSON.parse(savedUser) | ||
| 18 | + } | ||
| 19 | + loading.value = false | ||
| 20 | + }) | ||
| 21 | + | ||
| 22 | + // 登录函数 | ||
| 23 | + const login = (userData) => { | ||
| 24 | + currentUser.value = userData | ||
| 25 | + localStorage.setItem('currentUser', JSON.stringify(userData)) | ||
| 26 | + return true | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + // 登出函数 | ||
| 30 | + const logout = () => { | ||
| 31 | + currentUser.value = null | ||
| 32 | + localStorage.removeItem('currentUser') | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + // 提供认证上下文 | ||
| 36 | + provide(authKey, { | ||
| 37 | + currentUser, | ||
| 38 | + loading, | ||
| 39 | + login, | ||
| 40 | + logout | ||
| 41 | + }) | ||
| 42 | + | ||
| 43 | + return { | ||
| 44 | + currentUser, | ||
| 45 | + loading, | ||
| 46 | + login, | ||
| 47 | + logout | ||
| 48 | + } | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/** | ||
| 52 | + * 使用认证功能的组合式函数 | ||
| 53 | + * @returns {Object} 认证上下文值 | ||
| 54 | + */ | ||
| 55 | +export function useAuth() { | ||
| 56 | + const auth = inject(authKey) | ||
| 57 | + if (!auth) { | ||
| 58 | + throw new Error('useAuth 必须在 provideAuth 的范围内使用') | ||
| 59 | + } | ||
| 60 | + return auth | ||
| 61 | +} |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-03-20 20:36:36 | 2 | * @Date: 2025-03-20 20:36:36 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-03-20 20:50:41 | 4 | + * @LastEditTime: 2025-03-20 21:04:41 |
| 5 | * @FilePath: /mlaj/src/router/index.js | 5 | * @FilePath: /mlaj/src/router/index.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| ... | @@ -26,6 +26,12 @@ const routes = [ | ... | @@ -26,6 +26,12 @@ const routes = [ |
| 26 | component: () => import('../views/courses/CourseDetailPage.vue'), | 26 | component: () => import('../views/courses/CourseDetailPage.vue'), |
| 27 | meta: { title: 'CourseDetail' }, | 27 | meta: { title: 'CourseDetail' }, |
| 28 | }, | 28 | }, |
| 29 | + { | ||
| 30 | + path: '/profile', | ||
| 31 | + name: 'Profile', | ||
| 32 | + component: () => import('../views/profile/ProfilePage.vue'), | ||
| 33 | + meta: { title: 'Profile' }, | ||
| 34 | + }, | ||
| 29 | ] | 35 | ] |
| 30 | 36 | ||
| 31 | const router = createRouter({ | 37 | const router = createRouter({ | ... | ... |
src/views/profile/ProfilePage.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <AppLayout title="我的" :right-content="rightContent"> | ||
| 3 | + <div class="bg-gradient-to-br from-green-50 via-green-100/30 to-blue-50/30 min-h-screen"> | ||
| 4 | + <!-- User Profile Header with Enhanced Design --> | ||
| 5 | + <div class="pt-6 pb-8 relative"> | ||
| 6 | + <div class="absolute inset-0 bg-gradient-to-r from-green-500 to-blue-500 opacity-15"></div> | ||
| 7 | + <div class="relative z-10 flex flex-col items-center"> | ||
| 8 | + <div class="w-24 h-24 rounded-full overflow-hidden border-4 border-white shadow-lg mb-4"> | ||
| 9 | + <img | ||
| 10 | + :src="profile.avatar || '/assets/images/user-avatar-1.jpg'" | ||
| 11 | + :alt="profile.name" | ||
| 12 | + class="w-full h-full object-cover" | ||
| 13 | + @error="handleImageError" | ||
| 14 | + /> | ||
| 15 | + </div> | ||
| 16 | + <h2 class="text-2xl font-bold mb-1">{{ profile.name }}</h2> | ||
| 17 | + <div class="flex items-center text-sm text-gray-600"> | ||
| 18 | + <span>会员等级: 普通会员</span> | ||
| 19 | + <span class="mx-2">|</span> | ||
| 20 | + <span>ID: 88361425</span> | ||
| 21 | + </div> | ||
| 22 | + </div> | ||
| 23 | + </div> | ||
| 24 | + | ||
| 25 | + <!-- Check-in Statistics --> | ||
| 26 | + <div class="px-4 mb-5"> | ||
| 27 | + <FrostedGlass class="p-4 rounded-xl"> | ||
| 28 | + <div class="flex justify-between items-center mb-4"> | ||
| 29 | + <h3 class="font-semibold text-base">打卡统计</h3> | ||
| 30 | + <span class="text-xs text-blue-500">查看更多</span> | ||
| 31 | + </div> | ||
| 32 | + | ||
| 33 | + <div class="flex justify-between"> | ||
| 34 | + <div class="flex flex-col items-center"> | ||
| 35 | + <div class="text-2xl font-bold text-gray-800">{{ profile.checkIns?.totalDays || 0 }}</div> | ||
| 36 | + <div class="text-xs text-gray-500 mt-1">累计打卡</div> | ||
| 37 | + </div> | ||
| 38 | + <div class="flex flex-col items-center"> | ||
| 39 | + <div class="text-2xl font-bold text-green-600">{{ profile.checkIns?.currentStreak || 0 }}</div> | ||
| 40 | + <div class="text-xs text-gray-500 mt-1">连续打卡</div> | ||
| 41 | + </div> | ||
| 42 | + <div class="flex flex-col items-center"> | ||
| 43 | + <div class="text-2xl font-bold text-blue-600">{{ profile.checkIns?.longestStreak || 0 }}</div> | ||
| 44 | + <div class="text-xs text-gray-500 mt-1">最长连续</div> | ||
| 45 | + </div> | ||
| 46 | + <div> | ||
| 47 | + <button class="bg-gradient-to-r from-green-500 to-green-600 text-white py-2 px-6 rounded-full text-sm shadow-sm"> | ||
| 48 | + 立即打卡 | ||
| 49 | + </button> | ||
| 50 | + </div> | ||
| 51 | + </div> | ||
| 52 | + </FrostedGlass> | ||
| 53 | + </div> | ||
| 54 | + | ||
| 55 | + <!-- Check-in Types --> | ||
| 56 | + <div class="px-4 mb-5"> | ||
| 57 | + <FrostedGlass class="p-4 rounded-xl"> | ||
| 58 | + <h3 class="font-semibold text-base mb-4">打卡项目</h3> | ||
| 59 | + <div class="grid grid-cols-4 gap-2"> | ||
| 60 | + <div v-for="type in checkInTypes" :key="type.id" class="flex flex-col items-center"> | ||
| 61 | + <div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mb-1"> | ||
| 62 | + <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"> | ||
| 63 | + <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" /> | ||
| 64 | + </svg> | ||
| 65 | + <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"> | ||
| 66 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> | ||
| 67 | + </svg> | ||
| 68 | + <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"> | ||
| 69 | + <path d="M12 14l9-5-9-5-9 5 9 5z" /> | ||
| 70 | + <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" /> | ||
| 71 | + <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" /> | ||
| 72 | + </svg> | ||
| 73 | + <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"> | ||
| 74 | + <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" /> | ||
| 75 | + </svg> | ||
| 76 | + </div> | ||
| 77 | + <span class="text-xs">{{ type.name }}</span> | ||
| 78 | + </div> | ||
| 79 | + </div> | ||
| 80 | + </FrostedGlass> | ||
| 81 | + </div> | ||
| 82 | + | ||
| 83 | + <!-- User Menu Options --> | ||
| 84 | + <div class="px-4 pb-16"> | ||
| 85 | + <FrostedGlass class="rounded-xl overflow-hidden mb-5"> | ||
| 86 | + <MenuItem | ||
| 87 | + v-for="item in menuItems1" | ||
| 88 | + :key="item.path" | ||
| 89 | + v-bind="item" | ||
| 90 | + @click="handleMenuClick(item.path)" | ||
| 91 | + /> | ||
| 92 | + </FrostedGlass> | ||
| 93 | + | ||
| 94 | + <FrostedGlass class="rounded-xl overflow-hidden mb-5"> | ||
| 95 | + <MenuItem | ||
| 96 | + v-for="item in menuItems2" | ||
| 97 | + :key="item.path" | ||
| 98 | + v-bind="item" | ||
| 99 | + @click="handleMenuClick(item.path)" | ||
| 100 | + /> | ||
| 101 | + </FrostedGlass> | ||
| 102 | + | ||
| 103 | + <FrostedGlass class="rounded-xl overflow-hidden mb-5"> | ||
| 104 | + <MenuItem | ||
| 105 | + v-for="item in menuItems3" | ||
| 106 | + :key="item.path" | ||
| 107 | + v-bind="item" | ||
| 108 | + @click="handleMenuClick(item.path)" | ||
| 109 | + /> | ||
| 110 | + </FrostedGlass> | ||
| 111 | + | ||
| 112 | + <!-- Version Info --> | ||
| 113 | + <div class="text-center text-xs text-gray-400 mb-4"> | ||
| 114 | + 亲子教育 App v1.2.0 | ||
| 115 | + </div> | ||
| 116 | + | ||
| 117 | + <!-- Logout Button --> | ||
| 118 | + <button | ||
| 119 | + @click="handleLogout" | ||
| 120 | + class="w-full bg-white/70 backdrop-blur-sm text-red-500 py-3 rounded-xl mb-6 font-medium shadow-sm" | ||
| 121 | + > | ||
| 122 | + 退出登录 | ||
| 123 | + </button> | ||
| 124 | + </div> | ||
| 125 | + </div> | ||
| 126 | + </AppLayout> | ||
| 127 | +</template> | ||
| 128 | + | ||
| 129 | +<script setup> | ||
| 130 | +import { ref, h } from 'vue' | ||
| 131 | +import { useRouter } from 'vue-router' | ||
| 132 | +import AppLayout from '@/components/layout/AppLayout.vue' | ||
| 133 | +import FrostedGlass from '@/components/ui/FrostedGlass.vue' | ||
| 134 | +import MenuItem from '@/components/ui/MenuItem.vue' | ||
| 135 | +import { useAuth } from '@/contexts/auth' | ||
| 136 | +import { userProfile, checkInTypes } from '@/utils/mockData' | ||
| 137 | + | ||
| 138 | +const router = useRouter() | ||
| 139 | +const { currentUser, logout } = useAuth() | ||
| 140 | +const profile = ref(userProfile) | ||
| 141 | + | ||
| 142 | +// Handle logout | ||
| 143 | +const handleLogout = () => { | ||
| 144 | + logout() | ||
| 145 | + router.push('/login') | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +// Handle menu item click | ||
| 149 | +const handleMenuClick = (path) => { | ||
| 150 | + router.push(path) | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +// Handle image error | ||
| 154 | +const handleImageError = (e) => { | ||
| 155 | + e.target.onerror = null | ||
| 156 | + e.target.src = '/assets/images/user-avatar-1.jpg' | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +// Right content component | ||
| 160 | +const rightContent = h('div', { class: 'flex items-center' }, [ | ||
| 161 | + h('button', { class: 'p-2' }, [ | ||
| 162 | + h('svg', { | ||
| 163 | + xmlns: 'http://www.w3.org/2000/svg', | ||
| 164 | + class: 'h-6 w-6 text-gray-700', | ||
| 165 | + fill: 'none', | ||
| 166 | + viewBox: '0 0 24 24', | ||
| 167 | + stroke: 'currentColor' | ||
| 168 | + }, [ | ||
| 169 | + h('path', { | ||
| 170 | + 'stroke-linecap': 'round', | ||
| 171 | + 'stroke-linejoin': 'round', | ||
| 172 | + 'stroke-width': '2', | ||
| 173 | + 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' | ||
| 174 | + }) | ||
| 175 | + ]) | ||
| 176 | + ]) | ||
| 177 | +]) | ||
| 178 | + | ||
| 179 | +// Menu items | ||
| 180 | +const menuItems1 = [ | ||
| 181 | + { | ||
| 182 | + icon: 'clock', | ||
| 183 | + title: '学习记录', | ||
| 184 | + path: '/profile/learning-records', | ||
| 185 | + badge: 'NEW' | ||
| 186 | + }, | ||
| 187 | + { | ||
| 188 | + icon: 'user', | ||
| 189 | + title: '我的活动', | ||
| 190 | + path: '/profile/activities' | ||
| 191 | + }, | ||
| 192 | + { | ||
| 193 | + icon: 'book', | ||
| 194 | + title: '我的课程', | ||
| 195 | + path: '/profile/courses' | ||
| 196 | + }, | ||
| 197 | + { | ||
| 198 | + icon: 'heart', | ||
| 199 | + title: '我的收藏', | ||
| 200 | + path: '/profile/favorites' | ||
| 201 | + } | ||
| 202 | +] | ||
| 203 | + | ||
| 204 | +const menuItems2 = [ | ||
| 205 | + { | ||
| 206 | + icon: 'wallet', | ||
| 207 | + title: '我的钱包', | ||
| 208 | + path: '/profile/wallet' | ||
| 209 | + }, | ||
| 210 | + { | ||
| 211 | + icon: 'document', | ||
| 212 | + title: '我的订单', | ||
| 213 | + path: '/profile/orders' | ||
| 214 | + }, | ||
| 215 | + { | ||
| 216 | + icon: 'chat', | ||
| 217 | + title: '我的圈子', | ||
| 218 | + path: '/profile/community' | ||
| 219 | + } | ||
| 220 | +] | ||
| 221 | + | ||
| 222 | +const menuItems3 = [ | ||
| 223 | + { | ||
| 224 | + icon: 'email', | ||
| 225 | + title: '消息中心', | ||
| 226 | + path: '/profile/messages', | ||
| 227 | + badge: '3' | ||
| 228 | + }, | ||
| 229 | + { | ||
| 230 | + icon: 'question', | ||
| 231 | + title: '帮助中心', | ||
| 232 | + path: '/profile/help' | ||
| 233 | + }, | ||
| 234 | + { | ||
| 235 | + icon: 'settings', | ||
| 236 | + title: '设置', | ||
| 237 | + path: '/profile/settings' | ||
| 238 | + } | ||
| 239 | +] | ||
| 240 | +</script> |
-
Please register or login to post a comment