feat: 初始化项目结构并添加核心功能
- 初始化项目结构,包括Vue 3、Pinia、Vue Router、TailwindCSS等依赖 - 添加首页、活动详情页、创建活动页等核心页面 - 实现活动分类、搜索、报名等基本功能 - 添加公共组件如按钮、输入框、模态框等 - 配置Vite、TailwindCSS、PostCSS等构建工具
Showing
53 changed files
with
1767 additions
and
0 deletions
.gitignore
0 → 100644
| 1 | +# Logs | ||
| 2 | +logs | ||
| 3 | +*.log | ||
| 4 | +npm-debug.log* | ||
| 5 | +yarn-debug.log* | ||
| 6 | +yarn-error.log* | ||
| 7 | +pnpm-debug.log* | ||
| 8 | +lerna-debug.log* | ||
| 9 | + | ||
| 10 | +node_modules | ||
| 11 | +dist | ||
| 12 | +dist-ssr | ||
| 13 | +*.local | ||
| 14 | + | ||
| 15 | +# Editor directories and files | ||
| 16 | +.vscode/* | ||
| 17 | +!.vscode/extensions.json | ||
| 18 | +.idea | ||
| 19 | +.DS_Store | ||
| 20 | +*.suo | ||
| 21 | +*.ntvs* | ||
| 22 | +*.njsproj | ||
| 23 | +*.sln | ||
| 24 | +*.sw? | ||
| 25 | + | ||
| 26 | +.history | ||
| 27 | +node_modules | ||
| 28 | +.vscode | ||
| 29 | +mlaj-read |
| 1 | +# React + Vite Template | ||
| 2 | + | ||
| 3 | +A modern React template for web applications and games, featuring React 18, Vite, TailwindCSS, and Material UI. | ||
| 4 | + | ||
| 5 | +## Project Structure | ||
| 6 | + | ||
| 7 | +``` | ||
| 8 | +├── src/ | ||
| 9 | +│ ├── App.jsx # Main application component | ||
| 10 | +│ ├── main.jsx # Application entry point | ||
| 11 | +│ └── index.css # Global styles (Tailwind) | ||
| 12 | +├── public/ # Static assets | ||
| 13 | +├── index.html # HTML template | ||
| 14 | +├── vite.config.js # Vite configuration | ||
| 15 | +├── tailwind.config.js # Tailwind configuration | ||
| 16 | +├── postcss.config.js # PostCSS configuration | ||
| 17 | +└── eslint.config.js # ESLint configuration | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +## Development Guidelines | ||
| 21 | + | ||
| 22 | +- Modify `index.html` and `src/App.jsx` as needed | ||
| 23 | +- Create new folders or files in `src/` directory as needed | ||
| 24 | +- Style components using TailwindCSS utility classes | ||
| 25 | +- Avoid modifying `src/main.jsx` and `src/index.css` | ||
| 26 | +- Only modify `vite.config.js` if absolutely necessary | ||
| 27 | + | ||
| 28 | +## Available Scripts | ||
| 29 | +- `pnpm install` - Install dependencies | ||
| 30 | +- `pnpm run dev` - Start development server | ||
| 31 | +- `pnpm run lint` - Lint source files | ||
| 32 | + | ||
| 33 | +## Tech Stack | ||
| 34 | + | ||
| 35 | +- React | ||
| 36 | +- Vite | ||
| 37 | +- TailwindCSS | ||
| 38 | +- ESLint | ||
| 39 | +- Javascript | ... | ... |
index.html
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-17 08:14:25 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 13:26:33 | ||
| 5 | + * @FilePath: /reading-club-app/index.html | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 8 | +<!doctype html> | ||
| 9 | +<html lang="en"> | ||
| 10 | + | ||
| 11 | +<head> | ||
| 12 | + <meta charset="UTF-8" /> | ||
| 13 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 14 | + <title>读书会 - 连接爱读书的人</title> | ||
| 15 | +</head> | ||
| 16 | + | ||
| 17 | +<body> | ||
| 18 | + <div id="root"></div> | ||
| 19 | + <script type="module" src="/src/main.js"></script> | ||
| 20 | +</body> | ||
| 21 | + | ||
| 22 | +</html> |
package.json
0 → 100644
| 1 | +{ | ||
| 2 | + "name": "vue-reading-club", | ||
| 3 | + "private": true, | ||
| 4 | + "version": "0.0.0", | ||
| 5 | + "type": "module", | ||
| 6 | + "scripts": { | ||
| 7 | + "dev": "vite", | ||
| 8 | + "build": "vite build", | ||
| 9 | + "lint": "eslint ./src --quiet", | ||
| 10 | + "preview": "vite preview" | ||
| 11 | + }, | ||
| 12 | + "dependencies": { | ||
| 13 | + "@vitejs/plugin-vue-jsx": "4.1.2", | ||
| 14 | + "pinia": "^2.1.7", | ||
| 15 | + "vue": "^3.4.21", | ||
| 16 | + "vue-router": "^4.3.0" | ||
| 17 | + }, | ||
| 18 | + "devDependencies": { | ||
| 19 | + "@vitejs/plugin-vue": "^5.0.4", | ||
| 20 | + "@vue/compiler-sfc": "^3.4.21", | ||
| 21 | + "autoprefixer": "^10.4.20", | ||
| 22 | + "eslint": "^9.9.0", | ||
| 23 | + "eslint-plugin-vue": "^9.24.0", | ||
| 24 | + "postcss": "^8.4.45", | ||
| 25 | + "tailwindcss": "^3.4.10", | ||
| 26 | + "vite": "^5.4.1" | ||
| 27 | + } | ||
| 28 | +} |
pnpm-lock.yaml
0 → 100644
This diff is collapsed. Click to expand it.
postcss.config.js
0 → 100644
public/assets/images/.gitkeep
0 → 100644
File mode changed
public/assets/images/2RRq1BHPq4E.jpg
0 → 100644
5.47 MB
public/assets/images/CXYPfveiuis.jpg
0 → 100644
2.38 MB
public/assets/images/D5nh6mCW52c.jpg
0 → 100644
1.77 MB
public/assets/images/IHGcwEEhZgo.jpg
0 → 100644
This file is too large to display.
public/assets/images/LEVRaos2Ero.jpg
0 → 100644
2.84 MB
public/assets/images/XqXJJhK-c08.jpg
0 → 100644
890 KB
public/assets/images/activity_3_online.jpg
0 → 100644
2.38 MB
779 KB
public/assets/images/eMJc4eV0rI0.jpg
0 → 100644
2.61 MB
public/assets/images/g827ZOCwt30.jpg
0 → 100644
2.34 MB
public/assets/images/hPKTYwJ4FUo.jpg
0 → 100644
5.31 MB
public/assets/images/k2Kcwkandwg.jpg
0 → 100644
2.39 MB
public/assets/images/t2Sai-AqIpI.jpg
0 → 100644
779 KB
779 KB
public/assets/images/xY55bL5mZAM.jpg
0 → 100644
1.17 MB
public/data/activities.json
0 → 100644
This diff is collapsed. Click to expand it.
public/data/checkins.json
0 → 100644
This diff is collapsed. Click to expand it.
public/data/example.json
0 → 100644
public/data/messages.json
0 → 100644
This diff could not be displayed because it is too large.
public/data/registrations.json
0 → 100644
This diff could not be displayed because it is too large.
public/data/users.json
0 → 100644
This diff is collapsed. Click to expand it.
src/App.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-17 08:14:12 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 13:15:27 | ||
| 5 | + * @FilePath: /reading-club-app/src/App.vue | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 8 | +/* | ||
| 9 | + * @Date: 2025-04-17 08:14:12 | ||
| 10 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 11 | + * @LastEditTime: 2025-04-17 13:15:05 | ||
| 12 | + * @FilePath: /reading-club-app/src/App.jsx | ||
| 13 | + * @Description: 文件描述 | ||
| 14 | + */ | ||
| 15 | +<template> | ||
| 16 | + <div class="flex flex-col min-h-screen"> | ||
| 17 | + <Header /> | ||
| 18 | + <main class="flex-grow"> | ||
| 19 | + <router-view></router-view> | ||
| 20 | + </main> | ||
| 21 | + <Footer /> | ||
| 22 | + </div> | ||
| 23 | +</template> | ||
| 24 | + | ||
| 25 | +<script setup> | ||
| 26 | +import { onMounted } from 'vue' | ||
| 27 | +import Header from './components/layout/Header.vue' | ||
| 28 | +import Footer from './components/layout/Footer.vue' | ||
| 29 | + | ||
| 30 | +// 更新文档标题 | ||
| 31 | +onMounted(() => { | ||
| 32 | + document.title = '读书会 - 连接爱读书的人' | ||
| 33 | +}) | ||
| 34 | +</script> |
src/components/layout/Footer.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-17 13:22:07 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 13:22:09 | ||
| 5 | + * @FilePath: /reading-club-app/src/components/layout/Footer.vue | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <footer class="mt-8 py-6 bg-gradient-to-r from-green-50 to-blue-50"> | ||
| 10 | + <div class="container mx-auto px-4"> | ||
| 11 | + <div class="flex flex-col md:flex-row justify-between items-center"> | ||
| 12 | + <div class="mb-4 md:mb-0"> | ||
| 13 | + <h3 class="text-xl font-bold bg-gradient-to-r from-green-500 to-blue-400 bg-clip-text text-transparent">读书会</h3> | ||
| 14 | + <p class="text-gray-600 mt-2">连接爱读书的人,共享知识的力量</p> | ||
| 15 | + </div> | ||
| 16 | + | ||
| 17 | + <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-8"> | ||
| 18 | + <div> | ||
| 19 | + <h4 class="font-semibold text-gray-800 mb-3">关于我们</h4> | ||
| 20 | + <ul class="space-y-2"> | ||
| 21 | + <li><a href="#" class="text-gray-600 hover:text-green-500">平台介绍</a></li> | ||
| 22 | + <li><a href="#" class="text-gray-600 hover:text-green-500">使用指南</a></li> | ||
| 23 | + <li><a href="#" class="text-gray-600 hover:text-green-500">联系我们</a></li> | ||
| 24 | + </ul> | ||
| 25 | + </div> | ||
| 26 | + | ||
| 27 | + <div> | ||
| 28 | + <h4 class="font-semibold text-gray-800 mb-3">热门分类</h4> | ||
| 29 | + <ul class="space-y-2"> | ||
| 30 | + <li><a href="#" class="text-gray-600 hover:text-green-500">文学小说</a></li> | ||
| 31 | + <li><a href="#" class="text-gray-600 hover:text-green-500">社科人文</a></li> | ||
| 32 | + <li><a href="#" class="text-gray-600 hover:text-green-500">商业财经</a></li> | ||
| 33 | + </ul> | ||
| 34 | + </div> | ||
| 35 | + | ||
| 36 | + <div> | ||
| 37 | + <h4 class="font-semibold text-gray-800 mb-3">关注我们</h4> | ||
| 38 | + <div class="flex space-x-4"> | ||
| 39 | + <a href="#" class="text-gray-600 hover:text-green-500"> | ||
| 40 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 41 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8l-8 8m0-8l8 8" /> | ||
| 42 | + </svg> | ||
| 43 | + </a> | ||
| 44 | + <a href="#" class="text-gray-600 hover:text-green-500"> | ||
| 45 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 46 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||
| 47 | + </svg> | ||
| 48 | + </a> | ||
| 49 | + <a href="#" class="text-gray-600 hover:text-green-500"> | ||
| 50 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 51 | + <path 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" /> | ||
| 52 | + </svg> | ||
| 53 | + </a> | ||
| 54 | + </div> | ||
| 55 | + </div> | ||
| 56 | + </div> | ||
| 57 | + </div> | ||
| 58 | + | ||
| 59 | + <div class="border-t border-gray-200 mt-8 pt-6 text-center"> | ||
| 60 | + <p class="text-gray-500 text-sm">© 2024 读书会平台 All Rights Reserved</p> | ||
| 61 | + </div> | ||
| 62 | + </div> | ||
| 63 | + </footer> | ||
| 64 | +</template> | ||
| 65 | + | ||
| 66 | +<script setup> | ||
| 67 | +// Footer组件不需要任何响应式数据或方法 | ||
| 68 | +</script> |
src/components/layout/Header.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <header class="sticky top-0 z-50"> | ||
| 3 | + <!-- Frosted glass effect background --> | ||
| 4 | + <div class="backdrop-blur-xl bg-white/70 shadow-sm"> | ||
| 5 | + <div class="container mx-auto px-4"> | ||
| 6 | + <div class="flex items-center justify-between h-16"> | ||
| 7 | + <!-- Logo and Title --> | ||
| 8 | + <div class="flex items-center"> | ||
| 9 | + <router-link to="/" class="flex items-center"> | ||
| 10 | + <span class="text-2xl font-bold bg-gradient-to-r from-green-500 to-blue-400 bg-clip-text text-transparent"> | ||
| 11 | + 读书会 | ||
| 12 | + </span> | ||
| 13 | + </router-link> | ||
| 14 | + </div> | ||
| 15 | + | ||
| 16 | + <!-- Navigation - Desktop --> | ||
| 17 | + <nav class="hidden md:flex items-center space-x-6"> | ||
| 18 | + <router-link to="/" class="text-gray-600 hover:text-green-500 font-medium"> | ||
| 19 | + 首页 | ||
| 20 | + </router-link> | ||
| 21 | + <router-link to="/activities" class="text-gray-600 hover:text-green-500 font-medium"> | ||
| 22 | + 全部活动 | ||
| 23 | + </router-link> | ||
| 24 | + <router-link to="/create-activity" class="text-gray-600 hover:text-green-500 font-medium"> | ||
| 25 | + 创建活动 | ||
| 26 | + </router-link> | ||
| 27 | + </nav> | ||
| 28 | + | ||
| 29 | + <!-- User Profile and Actions --> | ||
| 30 | + <div class="flex items-center"> | ||
| 31 | + <!-- Messages Icon --> | ||
| 32 | + <router-link to="/messages" class="relative p-2 mr-4 text-gray-600 hover:text-green-500"> | ||
| 33 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 34 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /> | ||
| 35 | + </svg> | ||
| 36 | + <span class="absolute top-1 right-1 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full"> | ||
| 37 | + 2 | ||
| 38 | + </span> | ||
| 39 | + </router-link> | ||
| 40 | + | ||
| 41 | + <!-- User Profile --> | ||
| 42 | + <div class="relative"> | ||
| 43 | + <button @click="toggleProfileDropdown" class="flex items-center space-x-2"> | ||
| 44 | + <div class="h-8 w-8 rounded-full overflow-hidden border-2 border-green-500"> | ||
| 45 | + <img :src="currentUser?.avatar || '/assets/images/avatars/default_avatar.png'" alt="User Avatar" class="h-full w-full object-cover" /> | ||
| 46 | + </div> | ||
| 47 | + <span class="hidden lg:block text-sm font-medium text-gray-700">{{ currentUser?.name || 'User' }}</span> | ||
| 48 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 49 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> | ||
| 50 | + </svg> | ||
| 51 | + </button> | ||
| 52 | + | ||
| 53 | + <!-- Profile Dropdown --> | ||
| 54 | + <div v-if="isProfileDropdownOpen" class="absolute right-0 mt-2 w-48 py-2 bg-white rounded-md shadow-lg backdrop-blur-xl bg-white/90 z-20"> | ||
| 55 | + <router-link to="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-green-50 hover:text-green-500"> | ||
| 56 | + 个人资料 | ||
| 57 | + </router-link> | ||
| 58 | + <router-link to="/my-activities" class="block px-4 py-2 text-sm text-gray-700 hover:bg-green-50 hover:text-green-500"> | ||
| 59 | + 我的活动 | ||
| 60 | + </router-link> | ||
| 61 | + <router-link to="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-green-50 hover:text-green-500"> | ||
| 62 | + 设置 | ||
| 63 | + </router-link> | ||
| 64 | + <button class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-green-50 hover:text-green-500"> | ||
| 65 | + 退出登录 | ||
| 66 | + </button> | ||
| 67 | + </div> | ||
| 68 | + </div> | ||
| 69 | + | ||
| 70 | + <!-- Mobile Menu Button --> | ||
| 71 | + <div class="ml-4 md:hidden"> | ||
| 72 | + <button @click="toggleMenu" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-green-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-green-500"> | ||
| 73 | + <span class="sr-only">Open main menu</span> | ||
| 74 | + <svg :class="{ 'hidden': isMenuOpen, 'block': !isMenuOpen }" class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 75 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> | ||
| 76 | + </svg> | ||
| 77 | + <svg :class="{ 'block': isMenuOpen, 'hidden': !isMenuOpen }" class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 78 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | ||
| 79 | + </svg> | ||
| 80 | + </button> | ||
| 81 | + </div> | ||
| 82 | + </div> | ||
| 83 | + </div> | ||
| 84 | + | ||
| 85 | + <!-- Mobile Menu --> | ||
| 86 | + <div :class="{ 'block': isMenuOpen, 'hidden': !isMenuOpen }" class="md:hidden"> | ||
| 87 | + <div class="px-2 pt-2 pb-3 space-y-1 sm:px-3"> | ||
| 88 | + <router-link to="/" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-green-500 hover:bg-green-50"> | ||
| 89 | + 首页 | ||
| 90 | + </router-link> | ||
| 91 | + <router-link to="/activities" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-green-500 hover:bg-green-50"> | ||
| 92 | + 全部活动 | ||
| 93 | + </router-link> | ||
| 94 | + <router-link to="/create-activity" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-green-500 hover:bg-green-50"> | ||
| 95 | + 创建活动 | ||
| 96 | + </router-link> | ||
| 97 | + <router-link to="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-green-500 hover:bg-green-50"> | ||
| 98 | + 个人资料 | ||
| 99 | + </router-link> | ||
| 100 | + <router-link to="/messages" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-green-500 hover:bg-green-50"> | ||
| 101 | + 消息通知 | ||
| 102 | + </router-link> | ||
| 103 | + </div> | ||
| 104 | + </div> | ||
| 105 | + </div> | ||
| 106 | + </div> | ||
| 107 | + </header> | ||
| 108 | +</template> | ||
| 109 | + | ||
| 110 | +<script setup> | ||
| 111 | +import { ref } from 'vue' | ||
| 112 | +import { useAppStore } from '../../stores/app' | ||
| 113 | + | ||
| 114 | +const appStore = useAppStore() | ||
| 115 | +const currentUser = appStore.currentUser | ||
| 116 | + | ||
| 117 | +const isMenuOpen = ref(false) | ||
| 118 | +const isProfileDropdownOpen = ref(false) | ||
| 119 | + | ||
| 120 | +const toggleMenu = () => { | ||
| 121 | + isMenuOpen.value = !isMenuOpen.value | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +const toggleProfileDropdown = () => { | ||
| 125 | + isProfileDropdownOpen.value = !isProfileDropdownOpen.value | ||
| 126 | +} | ||
| 127 | +</script> |
src/components/shared/ActivityCard.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-17 13:16:20 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 13:16:22 | ||
| 5 | + * @FilePath: /reading-club-app/src/components/shared/ActivityCard.vue | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <router-link | ||
| 10 | + :to="`/activity/${activity.id}`" | ||
| 11 | + class="block bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition duration-200" | ||
| 12 | + > | ||
| 13 | + <div class="relative pb-48 overflow-hidden"> | ||
| 14 | + <img | ||
| 15 | + :src="activity.cover_image" | ||
| 16 | + :alt="activity.title" | ||
| 17 | + class="absolute inset-0 h-full w-full object-cover" | ||
| 18 | + /> | ||
| 19 | + <div | ||
| 20 | + v-if="activity.tags && activity.tags.length" | ||
| 21 | + class="absolute top-0 left-0 p-4 flex flex-wrap gap-2" | ||
| 22 | + > | ||
| 23 | + <span | ||
| 24 | + v-for="tag in activity.tags" | ||
| 25 | + :key="tag" | ||
| 26 | + class="px-3 py-1 text-sm bg-green-500 text-white rounded-full" | ||
| 27 | + > | ||
| 28 | + {{ tag }} | ||
| 29 | + </span> | ||
| 30 | + </div> | ||
| 31 | + </div> | ||
| 32 | + | ||
| 33 | + <div class="p-6"> | ||
| 34 | + <h3 class="text-xl font-semibold text-gray-800 mb-2">{{ activity.title }}</h3> | ||
| 35 | + <p class="text-gray-600 text-sm mb-4 line-clamp-2">{{ activity.description }}</p> | ||
| 36 | + | ||
| 37 | + <div class="flex items-center text-sm text-gray-500 mb-4"> | ||
| 38 | + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| 39 | + <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" /> | ||
| 40 | + </svg> | ||
| 41 | + <span>{{ formatDateTime(activity.start_time) }}</span> | ||
| 42 | + </div> | ||
| 43 | + | ||
| 44 | + <div class="flex items-center text-sm text-gray-500 mb-4"> | ||
| 45 | + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| 46 | + <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" /> | ||
| 47 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> | ||
| 48 | + </svg> | ||
| 49 | + <span>{{ activity.location }}</span> | ||
| 50 | + </div> | ||
| 51 | + | ||
| 52 | + <div class="flex items-center justify-between"> | ||
| 53 | + <div class="flex items-center"> | ||
| 54 | + <img | ||
| 55 | + :src="activity.organizer_avatar" | ||
| 56 | + :alt="activity.organizer_name" | ||
| 57 | + class="w-8 h-8 rounded-full mr-2" | ||
| 58 | + /> | ||
| 59 | + <span class="text-sm text-gray-600">{{ activity.organizer_name }}</span> | ||
| 60 | + </div> | ||
| 61 | + <span class="text-sm font-medium text-green-500"> | ||
| 62 | + {{ activity.participant_count }}人参与 | ||
| 63 | + </span> | ||
| 64 | + </div> | ||
| 65 | + </div> | ||
| 66 | + </router-link> | ||
| 67 | +</template> | ||
| 68 | + | ||
| 69 | +<script setup> | ||
| 70 | +import { defineProps } from 'vue' | ||
| 71 | + | ||
| 72 | +const props = defineProps({ | ||
| 73 | + activity: { | ||
| 74 | + type: Object, | ||
| 75 | + required: true | ||
| 76 | + } | ||
| 77 | +}) | ||
| 78 | + | ||
| 79 | +// 格式化日期时间 | ||
| 80 | +const formatDateTime = (dateTimeStr) => { | ||
| 81 | + const date = new Date(dateTimeStr.replace(' ', 'T')) | ||
| 82 | + return new Intl.DateTimeFormat('zh-CN', { | ||
| 83 | + year: 'numeric', | ||
| 84 | + month: 'long', | ||
| 85 | + day: 'numeric', | ||
| 86 | + hour: '2-digit', | ||
| 87 | + minute: '2-digit' | ||
| 88 | + }).format(date) | ||
| 89 | +} | ||
| 90 | +</script> |
src/components/shared/Button.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <button | ||
| 3 | + :type="type" | ||
| 4 | + :class="[baseClasses, variantClass, sizeClass, roundedClass, block ? buttonBlock : '', className]" | ||
| 5 | + :disabled="disabled" | ||
| 6 | + @click="$emit('click', $event)" | ||
| 7 | + > | ||
| 8 | + <span v-if="leftIcon" class="mr-2"> | ||
| 9 | + <slot name="leftIcon"> | ||
| 10 | + {{ leftIcon }} | ||
| 11 | + </slot> | ||
| 12 | + </span> | ||
| 13 | + <slot></slot> | ||
| 14 | + <span v-if="rightIcon" class="ml-2"> | ||
| 15 | + <slot name="rightIcon"> | ||
| 16 | + {{ rightIcon }} | ||
| 17 | + </slot> | ||
| 18 | + </span> | ||
| 19 | + </button> | ||
| 20 | +</template> | ||
| 21 | + | ||
| 22 | +<script setup> | ||
| 23 | +import { computed } from 'vue' | ||
| 24 | + | ||
| 25 | +const buttonVariants = { | ||
| 26 | + primary: 'bg-gradient-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 text-white border-transparent', | ||
| 27 | + secondary: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300', | ||
| 28 | + success: 'bg-green-600 hover:bg-green-700 text-white border-transparent', | ||
| 29 | + danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent', | ||
| 30 | + warning: 'bg-yellow-500 hover:bg-yellow-600 text-white border-transparent', | ||
| 31 | + info: 'bg-blue-500 hover:bg-blue-600 text-white border-transparent', | ||
| 32 | + ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 hover:text-gray-900 border-transparent' | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +const buttonSizes = { | ||
| 36 | + xs: 'px-2 py-1 text-xs', | ||
| 37 | + sm: 'px-2 py-1 text-sm', | ||
| 38 | + md: 'px-4 py-2', | ||
| 39 | + lg: 'px-6 py-3 text-lg', | ||
| 40 | + xl: 'px-8 py-4 text-xl' | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +const buttonRounded = { | ||
| 44 | + none: 'rounded-none', | ||
| 45 | + sm: 'rounded-sm', | ||
| 46 | + md: 'rounded-md', | ||
| 47 | + lg: 'rounded-lg', | ||
| 48 | + full: 'rounded-full' | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +const buttonBlock = 'w-full flex justify-center' | ||
| 52 | + | ||
| 53 | +const props = defineProps({ | ||
| 54 | + variant: { | ||
| 55 | + type: String, | ||
| 56 | + default: 'primary', | ||
| 57 | + validator: (value) => Object.keys(buttonVariants).includes(value) | ||
| 58 | + }, | ||
| 59 | + size: { | ||
| 60 | + type: String, | ||
| 61 | + default: 'md', | ||
| 62 | + validator: (value) => Object.keys(buttonSizes).includes(value) | ||
| 63 | + }, | ||
| 64 | + rounded: { | ||
| 65 | + type: String, | ||
| 66 | + default: 'md', | ||
| 67 | + validator: (value) => Object.keys(buttonRounded).includes(value) | ||
| 68 | + }, | ||
| 69 | + block: { | ||
| 70 | + type: Boolean, | ||
| 71 | + default: false | ||
| 72 | + }, | ||
| 73 | + disabled: { | ||
| 74 | + type: Boolean, | ||
| 75 | + default: false | ||
| 76 | + }, | ||
| 77 | + className: { | ||
| 78 | + type: String, | ||
| 79 | + default: '' | ||
| 80 | + }, | ||
| 81 | + leftIcon: { | ||
| 82 | + type: [String, Object], | ||
| 83 | + default: null | ||
| 84 | + }, | ||
| 85 | + rightIcon: { | ||
| 86 | + type: [String, Object], | ||
| 87 | + default: null | ||
| 88 | + }, | ||
| 89 | + type: { | ||
| 90 | + type: String, | ||
| 91 | + default: 'button' | ||
| 92 | + } | ||
| 93 | +}) | ||
| 94 | + | ||
| 95 | +defineEmits(['click']) | ||
| 96 | + | ||
| 97 | +const baseClasses = 'flex items-center justify-center font-medium border shadow-sm transition duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed' | ||
| 98 | + | ||
| 99 | +const variantClass = computed(() => buttonVariants[props.variant] || buttonVariants.primary) | ||
| 100 | +const sizeClass = computed(() => buttonSizes[props.size] || buttonSizes.md) | ||
| 101 | +const roundedClass = computed(() => buttonRounded[props.rounded] || buttonRounded.md) | ||
| 102 | +</script> |
src/components/shared/Input.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div :class="['w-full', className]"> | ||
| 3 | + <label v-if="label" :for="inputId" class="block text-sm font-medium text-gray-700 mb-1"> | ||
| 4 | + {{ label }} | ||
| 5 | + <span v-if="required" class="text-red-500 ml-1">*</span> | ||
| 6 | + </label> | ||
| 7 | + | ||
| 8 | + <div class="relative rounded-md shadow-sm"> | ||
| 9 | + <div v-if="leftIcon" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||
| 10 | + <span class="text-gray-500 sm:text-sm"> | ||
| 11 | + <slot name="leftIcon"> | ||
| 12 | + {{ leftIcon }} | ||
| 13 | + </slot> | ||
| 14 | + </span> | ||
| 15 | + </div> | ||
| 16 | + | ||
| 17 | + <input | ||
| 18 | + :id="inputId" | ||
| 19 | + :type="type" | ||
| 20 | + :name="name" | ||
| 21 | + :placeholder="placeholder" | ||
| 22 | + :value="modelValue" | ||
| 23 | + :required="required" | ||
| 24 | + :disabled="disabled" | ||
| 25 | + @input="$emit('update:modelValue', $event.target.value)" | ||
| 26 | + @blur="$emit('blur', $event)" | ||
| 27 | + :class="[ | ||
| 28 | + 'block w-full border-gray-300 rounded-md shadow-sm', | ||
| 29 | + 'focus:ring-green-500 focus:border-green-500 sm:text-sm', | ||
| 30 | + 'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed', | ||
| 31 | + sizeClass, | ||
| 32 | + error ? 'border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500' : '', | ||
| 33 | + leftIcon ? 'pl-10' : '', | ||
| 34 | + rightIcon ? 'pr-10' : '' | ||
| 35 | + ]" | ||
| 36 | + v-bind="$attrs" | ||
| 37 | + /> | ||
| 38 | + | ||
| 39 | + <div v-if="rightIcon" class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> | ||
| 40 | + <span class="text-gray-500 sm:text-sm"> | ||
| 41 | + <slot name="rightIcon"> | ||
| 42 | + {{ rightIcon }} | ||
| 43 | + </slot> | ||
| 44 | + </span> | ||
| 45 | + </div> | ||
| 46 | + </div> | ||
| 47 | + | ||
| 48 | + <p v-if="error || helperText" :class="['mt-1 text-sm', error ? 'text-red-600' : 'text-gray-500']"> | ||
| 49 | + {{ error || helperText }} | ||
| 50 | + </p> | ||
| 51 | + </div> | ||
| 52 | +</template> | ||
| 53 | + | ||
| 54 | +<script setup> | ||
| 55 | +import { computed } from 'vue' | ||
| 56 | + | ||
| 57 | +const inputSizes = { | ||
| 58 | + sm: 'px-2 py-1 text-sm', | ||
| 59 | + md: 'px-3 py-2', | ||
| 60 | + lg: 'px-4 py-3 text-lg' | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +const props = defineProps({ | ||
| 64 | + modelValue: { | ||
| 65 | + type: [String, Number], | ||
| 66 | + default: '' | ||
| 67 | + }, | ||
| 68 | + label: { | ||
| 69 | + type: String, | ||
| 70 | + default: '' | ||
| 71 | + }, | ||
| 72 | + name: { | ||
| 73 | + type: String, | ||
| 74 | + required: true | ||
| 75 | + }, | ||
| 76 | + type: { | ||
| 77 | + type: String, | ||
| 78 | + default: 'text' | ||
| 79 | + }, | ||
| 80 | + placeholder: { | ||
| 81 | + type: String, | ||
| 82 | + default: '' | ||
| 83 | + }, | ||
| 84 | + error: { | ||
| 85 | + type: String, | ||
| 86 | + default: '' | ||
| 87 | + }, | ||
| 88 | + size: { | ||
| 89 | + type: String, | ||
| 90 | + default: 'md', | ||
| 91 | + validator: (value) => Object.keys(inputSizes).includes(value) | ||
| 92 | + }, | ||
| 93 | + required: { | ||
| 94 | + type: Boolean, | ||
| 95 | + default: false | ||
| 96 | + }, | ||
| 97 | + disabled: { | ||
| 98 | + type: Boolean, | ||
| 99 | + default: false | ||
| 100 | + }, | ||
| 101 | + className: { | ||
| 102 | + type: String, | ||
| 103 | + default: '' | ||
| 104 | + }, | ||
| 105 | + helperText: { | ||
| 106 | + type: String, | ||
| 107 | + default: '' | ||
| 108 | + }, | ||
| 109 | + leftIcon: { | ||
| 110 | + type: [String, Object], | ||
| 111 | + default: null | ||
| 112 | + }, | ||
| 113 | + rightIcon: { | ||
| 114 | + type: [String, Object], | ||
| 115 | + default: null | ||
| 116 | + } | ||
| 117 | +}) | ||
| 118 | + | ||
| 119 | +defineEmits(['update:modelValue', 'blur']) | ||
| 120 | + | ||
| 121 | +const inputId = computed(() => `input-${props.name}`) | ||
| 122 | +const sizeClass = computed(() => inputSizes[props.size] || inputSizes.md) | ||
| 123 | +</script> |
src/components/shared/Modal.vue
0 → 100644
| 1 | +<script setup> | ||
| 2 | +import { ref, onMounted, onUnmounted, watch } from 'vue'; | ||
| 3 | + | ||
| 4 | +const props = defineProps({ | ||
| 5 | + isOpen: { | ||
| 6 | + type: Boolean, | ||
| 7 | + required: true | ||
| 8 | + }, | ||
| 9 | + title: { | ||
| 10 | + type: String, | ||
| 11 | + required: true | ||
| 12 | + }, | ||
| 13 | + size: { | ||
| 14 | + type: String, | ||
| 15 | + default: 'md' | ||
| 16 | + }, | ||
| 17 | + closeOnClickOutside: { | ||
| 18 | + type: Boolean, | ||
| 19 | + default: true | ||
| 20 | + }, | ||
| 21 | + showCloseButton: { | ||
| 22 | + type: Boolean, | ||
| 23 | + default: true | ||
| 24 | + }, | ||
| 25 | + contentClassName: { | ||
| 26 | + type: String, | ||
| 27 | + default: '' | ||
| 28 | + } | ||
| 29 | +}); | ||
| 30 | + | ||
| 31 | +const emit = defineEmits(['close']); | ||
| 32 | + | ||
| 33 | +const modalRef = ref(null); | ||
| 34 | +const isMounted = ref(false); | ||
| 35 | + | ||
| 36 | +// Set sizes based on the size prop | ||
| 37 | +const sizeClasses = { | ||
| 38 | + sm: 'max-w-md', | ||
| 39 | + md: 'max-w-lg', | ||
| 40 | + lg: 'max-w-2xl', | ||
| 41 | + xl: 'max-w-4xl', | ||
| 42 | + full: 'max-w-full mx-4' | ||
| 43 | +}; | ||
| 44 | + | ||
| 45 | +const modalSize = computed(() => sizeClasses[props.size] || sizeClasses.md); | ||
| 46 | + | ||
| 47 | +// Handle ESC key press | ||
| 48 | +const handleKeyDown = (event) => { | ||
| 49 | + if (event.key === 'Escape' && props.isOpen) { | ||
| 50 | + emit('close'); | ||
| 51 | + } | ||
| 52 | +}; | ||
| 53 | + | ||
| 54 | +// Handle click outside | ||
| 55 | +const handleClickOutside = (event) => { | ||
| 56 | + if (modalRef.value && !modalRef.value.contains(event.target) && props.closeOnClickOutside) { | ||
| 57 | + emit('close'); | ||
| 58 | + } | ||
| 59 | +}; | ||
| 60 | + | ||
| 61 | +// Handle mounting/unmounting and event listeners | ||
| 62 | +onMounted(() => { | ||
| 63 | + isMounted.value = true; | ||
| 64 | + document.addEventListener('keydown', handleKeyDown); | ||
| 65 | +}); | ||
| 66 | + | ||
| 67 | +onUnmounted(() => { | ||
| 68 | + document.removeEventListener('keydown', handleKeyDown); | ||
| 69 | + document.removeEventListener('mousedown', handleClickOutside); | ||
| 70 | + document.body.style.overflow = 'auto'; | ||
| 71 | +}); | ||
| 72 | + | ||
| 73 | +// Watch isOpen changes | ||
| 74 | +watch(() => props.isOpen, (newValue) => { | ||
| 75 | + if (newValue) { | ||
| 76 | + document.body.style.overflow = 'hidden'; | ||
| 77 | + document.addEventListener('mousedown', handleClickOutside); | ||
| 78 | + } else { | ||
| 79 | + document.body.style.overflow = 'auto'; | ||
| 80 | + document.removeEventListener('mousedown', handleClickOutside); | ||
| 81 | + } | ||
| 82 | +}); | ||
| 83 | +</script> | ||
| 84 | + | ||
| 85 | +<template> | ||
| 86 | + <Teleport to="body"> | ||
| 87 | + <div v-if="isOpen && isMounted" class="fixed inset-0 z-50 overflow-y-auto"> | ||
| 88 | + <!-- Backdrop with semi-transparent background --> | ||
| 89 | + <div class="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm transition-opacity"></div> | ||
| 90 | + | ||
| 91 | + <!-- Modal container --> | ||
| 92 | + <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center"> | ||
| 93 | + <div | ||
| 94 | + :class="['relative inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle w-full', modalSize]" | ||
| 95 | + ref="modalRef" | ||
| 96 | + > | ||
| 97 | + <!-- Modal header --> | ||
| 98 | + <div class="bg-white px-4 py-4 border-b border-gray-200 sm:px-6"> | ||
| 99 | + <div class="flex items-center justify-between"> | ||
| 100 | + <h3 class="text-lg leading-6 font-medium text-gray-900"> | ||
| 101 | + {{ title }} | ||
| 102 | + </h3> | ||
| 103 | + <button | ||
| 104 | + v-if="showCloseButton" | ||
| 105 | + type="button" | ||
| 106 | + class="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none" | ||
| 107 | + @click="emit('close')" | ||
| 108 | + > | ||
| 109 | + <span class="sr-only">关闭</span> | ||
| 110 | + <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| 111 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | ||
| 112 | + </svg> | ||
| 113 | + </button> | ||
| 114 | + </div> | ||
| 115 | + </div> | ||
| 116 | + | ||
| 117 | + <!-- Modal content --> | ||
| 118 | + <div :class="['bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4', contentClassName]"> | ||
| 119 | + <slot></slot> | ||
| 120 | + </div> | ||
| 121 | + | ||
| 122 | + <!-- Modal footer --> | ||
| 123 | + <div v-if="$slots.footer" class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> | ||
| 124 | + <slot name="footer"></slot> | ||
| 125 | + </div> | ||
| 126 | + </div> | ||
| 127 | + </div> | ||
| 128 | + </div> | ||
| 129 | + </Teleport> | ||
| 130 | +</template> |
src/components/shared/Tabs.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div :class="className"> | ||
| 3 | + <!-- Tab headers --> | ||
| 4 | + <div :class="getContainerClasses()"> | ||
| 5 | + <button | ||
| 6 | + v-for="(tab, index) in tabs" | ||
| 7 | + :key="index" | ||
| 8 | + :class="getTabHeaderClasses(index)" | ||
| 9 | + @click="handleTabChange(index)" | ||
| 10 | + role="tab" | ||
| 11 | + :aria-selected="currentTab === index" | ||
| 12 | + > | ||
| 13 | + {{ tab.label }} | ||
| 14 | + </button> | ||
| 15 | + </div> | ||
| 16 | + | ||
| 17 | + <!-- Tab content --> | ||
| 18 | + <div class="pt-4"> | ||
| 19 | + <component :is="tabs[currentTab]?.content" /> | ||
| 20 | + </div> | ||
| 21 | + </div> | ||
| 22 | +</template> | ||
| 23 | + | ||
| 24 | +<script setup> | ||
| 25 | +import { ref, computed } from 'vue'; | ||
| 26 | + | ||
| 27 | +const props = defineProps({ | ||
| 28 | + tabs: { | ||
| 29 | + type: Array, | ||
| 30 | + required: true | ||
| 31 | + }, | ||
| 32 | + activeTab: { | ||
| 33 | + type: Number, | ||
| 34 | + default: 0 | ||
| 35 | + }, | ||
| 36 | + className: { | ||
| 37 | + type: String, | ||
| 38 | + default: '' | ||
| 39 | + }, | ||
| 40 | + variant: { | ||
| 41 | + type: String, | ||
| 42 | + default: 'underline', // 'underline', 'pills', 'bordered' | ||
| 43 | + validator: (value) => ['underline', 'pills', 'bordered'].includes(value) | ||
| 44 | + }, | ||
| 45 | + size: { | ||
| 46 | + type: String, | ||
| 47 | + default: 'md', // 'sm', 'md', 'lg' | ||
| 48 | + validator: (value) => ['sm', 'md', 'lg'].includes(value) | ||
| 49 | + } | ||
| 50 | +}); | ||
| 51 | + | ||
| 52 | +const emit = defineEmits(['change']); | ||
| 53 | + | ||
| 54 | +const currentTab = ref(props.activeTab); | ||
| 55 | + | ||
| 56 | +const handleTabChange = (index) => { | ||
| 57 | + currentTab.value = index; | ||
| 58 | + emit('change', index); | ||
| 59 | +}; | ||
| 60 | + | ||
| 61 | +const getSizeClasses = () => { | ||
| 62 | + switch (props.size) { | ||
| 63 | + case 'sm': return 'text-sm py-1 px-2'; | ||
| 64 | + case 'lg': return 'text-lg py-3 px-5'; | ||
| 65 | + case 'md': | ||
| 66 | + default: return 'text-base py-2 px-4'; | ||
| 67 | + } | ||
| 68 | +}; | ||
| 69 | + | ||
| 70 | +const getTabHeaderClasses = (index) => { | ||
| 71 | + const isActive = index === currentTab.value; | ||
| 72 | + const sizeClasses = getSizeClasses(); | ||
| 73 | + | ||
| 74 | + const baseClasses = 'font-medium transition-all duration-200 focus:outline-none'; | ||
| 75 | + | ||
| 76 | + switch (props.variant) { | ||
| 77 | + case 'pills': | ||
| 78 | + return `${baseClasses} ${sizeClasses} rounded-md ${ | ||
| 79 | + isActive | ||
| 80 | + ? 'bg-green-500 text-white' | ||
| 81 | + : 'text-gray-700 hover:text-green-500 hover:bg-green-50' | ||
| 82 | + }`; | ||
| 83 | + | ||
| 84 | + case 'bordered': | ||
| 85 | + return `${baseClasses} ${sizeClasses} border-b-2 ${ | ||
| 86 | + isActive | ||
| 87 | + ? 'border-green-500 text-green-600' | ||
| 88 | + : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' | ||
| 89 | + }`; | ||
| 90 | + | ||
| 91 | + case 'underline': | ||
| 92 | + default: | ||
| 93 | + return `${baseClasses} ${sizeClasses} border-b-2 ${ | ||
| 94 | + isActive | ||
| 95 | + ? 'border-green-500 text-green-600' | ||
| 96 | + : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' | ||
| 97 | + }`; | ||
| 98 | + } | ||
| 99 | +}; | ||
| 100 | + | ||
| 101 | +const getContainerClasses = () => { | ||
| 102 | + switch (props.variant) { | ||
| 103 | + case 'pills': | ||
| 104 | + return 'flex p-1 space-x-1 bg-gray-100 rounded-lg'; | ||
| 105 | + case 'bordered': | ||
| 106 | + return 'flex border-b border-gray-200'; | ||
| 107 | + case 'underline': | ||
| 108 | + default: | ||
| 109 | + return 'flex border-b border-gray-200'; | ||
| 110 | + } | ||
| 111 | +}; | ||
| 112 | +</script> |
src/context/AppContext.jsx
0 → 100644
| 1 | +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; | ||
| 2 | + | ||
| 3 | +const AppContext = createContext(); | ||
| 4 | + | ||
| 5 | +export const AppProvider = ({ children }) => { | ||
| 6 | + const [currentUser, setCurrentUser] = useState(null); | ||
| 7 | + const [activities, setActivities] = useState([]); | ||
| 8 | + const [loading, setLoading] = useState(true); | ||
| 9 | + const [error, setError] = useState(null); | ||
| 10 | + const [registrations, setRegistrations] = useState([]); | ||
| 11 | + const [userMessages, setUserMessages] = useState([]); | ||
| 12 | + | ||
| 13 | + // Initialize app data | ||
| 14 | + useEffect(() => { | ||
| 15 | + const fetchInitialData = async () => { | ||
| 16 | + try { | ||
| 17 | + setLoading(true); | ||
| 18 | + | ||
| 19 | + // Fetch users data - In a real app, this would be a real API call | ||
| 20 | + const usersResponse = await fetch('/data/users.json'); | ||
| 21 | + const usersData = await usersResponse.json(); | ||
| 22 | + | ||
| 23 | + // For demo purposes, set the first user as current user | ||
| 24 | + setCurrentUser(usersData[0]); | ||
| 25 | + | ||
| 26 | + // Fetch activities | ||
| 27 | + const activitiesResponse = await fetch('/data/activities.json'); | ||
| 28 | + const activitiesData = await activitiesResponse.json(); | ||
| 29 | + setActivities(activitiesData); | ||
| 30 | + | ||
| 31 | + // Fetch registrations | ||
| 32 | + const registrationsResponse = await fetch('/data/registrations.json'); | ||
| 33 | + const registrationsData = await registrationsResponse.json(); | ||
| 34 | + setRegistrations(registrationsData); | ||
| 35 | + | ||
| 36 | + // Fetch messages | ||
| 37 | + const messagesResponse = await fetch('/data/messages.json'); | ||
| 38 | + const messagesData = await messagesResponse.json(); | ||
| 39 | + setUserMessages(messagesData); | ||
| 40 | + | ||
| 41 | + setLoading(false); | ||
| 42 | + } catch (err) { | ||
| 43 | + console.error('Failed to fetch initial data:', err); | ||
| 44 | + setError('Failed to load application data. Please try again later.'); | ||
| 45 | + setLoading(false); | ||
| 46 | + } | ||
| 47 | + }; | ||
| 48 | + | ||
| 49 | + fetchInitialData(); | ||
| 50 | + }, []); | ||
| 51 | + | ||
| 52 | + // Get activity by ID | ||
| 53 | + const getActivityById = useCallback((activityId) => { | ||
| 54 | + return activities.find(activity => activity.id === activityId) || null; | ||
| 55 | + }, [activities]); | ||
| 56 | + | ||
| 57 | + // Get user's registrations | ||
| 58 | + const getUserRegistrations = useCallback(() => { | ||
| 59 | + if (!currentUser) return []; | ||
| 60 | + return registrations.filter(reg => reg.user_id === currentUser.id); | ||
| 61 | + }, [currentUser, registrations]); | ||
| 62 | + | ||
| 63 | + // Register for an activity | ||
| 64 | + const registerForActivity = useCallback((activityId, formData) => { | ||
| 65 | + if (!currentUser) return { success: false, error: 'User not logged in' }; | ||
| 66 | + | ||
| 67 | + // In a real app, this would make an API call | ||
| 68 | + const newRegistration = { | ||
| 69 | + id: `R${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 70 | + activity_id: activityId, | ||
| 71 | + user_id: currentUser.id, | ||
| 72 | + registration_time: new Date().toISOString().replace('T', ' ').substring(0, 19), | ||
| 73 | + status: 'pending', | ||
| 74 | + custom_fields: formData.fields, | ||
| 75 | + custom_answers: formData.answers | ||
| 76 | + }; | ||
| 77 | + | ||
| 78 | + setRegistrations(prev => [...prev, newRegistration]); | ||
| 79 | + return { success: true, registrationId: newRegistration.id }; | ||
| 80 | + }, [currentUser]); | ||
| 81 | + | ||
| 82 | + // Create a new activity | ||
| 83 | + const createActivity = useCallback((activityData) => { | ||
| 84 | + if (!currentUser) return { success: false, error: 'User not logged in' }; | ||
| 85 | + | ||
| 86 | + // In a real app, this would make an API call | ||
| 87 | + const newActivity = { | ||
| 88 | + id: `A${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 89 | + organizer_id: currentUser.id, | ||
| 90 | + organizer_name: currentUser.name, | ||
| 91 | + ...activityData, | ||
| 92 | + participant_count: 0, | ||
| 93 | + is_published: activityData.is_published || true, | ||
| 94 | + is_public: activityData.is_public || true, | ||
| 95 | + }; | ||
| 96 | + | ||
| 97 | + setActivities(prev => [...prev, newActivity]); | ||
| 98 | + return { success: true, activityId: newActivity.id }; | ||
| 99 | + }, [currentUser]); | ||
| 100 | + | ||
| 101 | + // Check in for an activity | ||
| 102 | + const checkInForActivity = useCallback((activityId, registrationId, method = 'manual') => { | ||
| 103 | + if (!currentUser) return { success: false, error: 'User not logged in' }; | ||
| 104 | + | ||
| 105 | + // In a real app, this would make an API call | ||
| 106 | + const checkinData = { | ||
| 107 | + id: `C${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 108 | + activity_id: activityId, | ||
| 109 | + user_id: currentUser.id, | ||
| 110 | + registration_id: registrationId, | ||
| 111 | + checkin_time: new Date().toISOString().replace('T', ' ').substring(0, 19), | ||
| 112 | + checkin_type: method, | ||
| 113 | + status: 'successful', | ||
| 114 | + is_late: false | ||
| 115 | + }; | ||
| 116 | + | ||
| 117 | + // In a real app, we would update the backend | ||
| 118 | + return { success: true, checkin: checkinData }; | ||
| 119 | + }, [currentUser]); | ||
| 120 | + | ||
| 121 | + // Get user messages | ||
| 122 | + const getUserMessages = useCallback(() => { | ||
| 123 | + if (!currentUser) return []; | ||
| 124 | + return userMessages.filter(msg => msg.recipient_id === currentUser.id); | ||
| 125 | + }, [currentUser, userMessages]); | ||
| 126 | + | ||
| 127 | + // Toggle read status of a message | ||
| 128 | + const toggleMessageReadStatus = useCallback((messageId) => { | ||
| 129 | + setUserMessages(prev => | ||
| 130 | + prev.map(msg => | ||
| 131 | + msg.id === messageId | ||
| 132 | + ? { ...msg, read_status: !msg.read_status } | ||
| 133 | + : msg | ||
| 134 | + ) | ||
| 135 | + ); | ||
| 136 | + }, []); | ||
| 137 | + | ||
| 138 | + const contextValue = { | ||
| 139 | + currentUser, | ||
| 140 | + activities, | ||
| 141 | + loading, | ||
| 142 | + error, | ||
| 143 | + registrations, | ||
| 144 | + userMessages, | ||
| 145 | + getActivityById, | ||
| 146 | + getUserRegistrations, | ||
| 147 | + registerForActivity, | ||
| 148 | + createActivity, | ||
| 149 | + checkInForActivity, | ||
| 150 | + getUserMessages, | ||
| 151 | + toggleMessageReadStatus | ||
| 152 | + }; | ||
| 153 | + | ||
| 154 | + return ( | ||
| 155 | + <AppContext.Provider value={contextValue}> | ||
| 156 | + {children} | ||
| 157 | + </AppContext.Provider> | ||
| 158 | + ); | ||
| 159 | +}; | ||
| 160 | + | ||
| 161 | +export const useApp = () => { | ||
| 162 | + const context = useContext(AppContext); | ||
| 163 | + if (!context) { | ||
| 164 | + throw new Error('useApp must be used within an AppProvider'); | ||
| 165 | + } | ||
| 166 | + return context; | ||
| 167 | +}; | ||
| 168 | + | ||
| 169 | +export default AppContext; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
src/hooks/useAuth.js
0 → 100644
| 1 | +import { useState, useEffect } from 'react'; | ||
| 2 | +import { fetchUsers } from '../utils/api'; | ||
| 3 | + | ||
| 4 | +// A custom hook to manage authentication state | ||
| 5 | +const useAuth = () => { | ||
| 6 | + const [user, setUser] = useState(null); | ||
| 7 | + const [loading, setLoading] = useState(true); | ||
| 8 | + const [error, setError] = useState(null); | ||
| 9 | + | ||
| 10 | + useEffect(() => { | ||
| 11 | + // In a real app, this would check if the user is logged in via JWT, cookies, etc. | ||
| 12 | + // For this demo, we'll just load the first user from the mock data | ||
| 13 | + const checkAuth = async () => { | ||
| 14 | + try { | ||
| 15 | + const users = await fetchUsers(); | ||
| 16 | + // For demo purposes, use the first user as the logged-in user | ||
| 17 | + if (users && users.length > 0) { | ||
| 18 | + // Check if there's a stored user ID in local storage (for persistence) | ||
| 19 | + const storedUserId = localStorage.getItem('currentUserId'); | ||
| 20 | + if (storedUserId) { | ||
| 21 | + const storedUser = users.find(u => u.id === storedUserId); | ||
| 22 | + if (storedUser) { | ||
| 23 | + setUser(storedUser); | ||
| 24 | + } else { | ||
| 25 | + setUser(users[0]); // Fallback to first user | ||
| 26 | + } | ||
| 27 | + } else { | ||
| 28 | + setUser(users[0]); | ||
| 29 | + } | ||
| 30 | + } | ||
| 31 | + setLoading(false); | ||
| 32 | + } catch (err) { | ||
| 33 | + console.error('Authentication error:', err); | ||
| 34 | + setError('Failed to authenticate. Please try again.'); | ||
| 35 | + setLoading(false); | ||
| 36 | + } | ||
| 37 | + }; | ||
| 38 | + | ||
| 39 | + checkAuth(); | ||
| 40 | + }, []); | ||
| 41 | + | ||
| 42 | + // Login function - in a real app, this would validate credentials | ||
| 43 | + const login = async (userId) => { | ||
| 44 | + try { | ||
| 45 | + setLoading(true); | ||
| 46 | + const users = await fetchUsers(); | ||
| 47 | + const foundUser = users.find(u => u.id === userId); | ||
| 48 | + | ||
| 49 | + if (foundUser) { | ||
| 50 | + setUser(foundUser); | ||
| 51 | + localStorage.setItem('currentUserId', userId); | ||
| 52 | + setLoading(false); | ||
| 53 | + return { success: true }; | ||
| 54 | + } else { | ||
| 55 | + setError('User not found'); | ||
| 56 | + setLoading(false); | ||
| 57 | + return { success: false, error: 'User not found' }; | ||
| 58 | + } | ||
| 59 | + } catch (err) { | ||
| 60 | + console.error('Login error:', err); | ||
| 61 | + setError('Failed to log in. Please try again.'); | ||
| 62 | + setLoading(false); | ||
| 63 | + return { success: false, error: err.message }; | ||
| 64 | + } | ||
| 65 | + }; | ||
| 66 | + | ||
| 67 | + // Logout function | ||
| 68 | + const logout = () => { | ||
| 69 | + setUser(null); | ||
| 70 | + localStorage.removeItem('currentUserId'); | ||
| 71 | + }; | ||
| 72 | + | ||
| 73 | + // Switch user (for demo purposes) | ||
| 74 | + const switchUser = async (userId) => { | ||
| 75 | + return await login(userId); | ||
| 76 | + }; | ||
| 77 | + | ||
| 78 | + return { | ||
| 79 | + user, | ||
| 80 | + loading, | ||
| 81 | + error, | ||
| 82 | + login, | ||
| 83 | + logout, | ||
| 84 | + switchUser, | ||
| 85 | + isAuthenticated: !!user | ||
| 86 | + }; | ||
| 87 | +}; | ||
| 88 | + | ||
| 89 | +export default useAuth; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
src/index.css
0 → 100644
| 1 | +@tailwind base; | ||
| 2 | +@tailwind components; | ||
| 3 | +@tailwind utilities; | ||
| 4 | + | ||
| 5 | +::-webkit-scrollbar { | ||
| 6 | + width: 5px; | ||
| 7 | + height: 5px; | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +::-webkit-scrollbar-track { | ||
| 11 | + background-color: transparent; | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +::-webkit-scrollbar-thumb { | ||
| 15 | + border-radius: 25px; | ||
| 16 | + transition: all 0.3s; | ||
| 17 | + background-color: rgba(106, 115, 125, 0.2); | ||
| 18 | + &:hover { | ||
| 19 | + background-color: rgba(106, 115, 125, 0.27); | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +::-webkit-scrollbar-corner { | ||
| 24 | + display: none; | ||
| 25 | +} |
src/main.js
0 → 100644
| 1 | +import { createApp } from 'vue' | ||
| 2 | +import { createPinia } from 'pinia' | ||
| 3 | +import { createRouter, createWebHistory } from 'vue-router' | ||
| 4 | +import App from './App.vue' | ||
| 5 | +import './index.css' | ||
| 6 | + | ||
| 7 | +// 创建Vue应用实例 | ||
| 8 | +const app = createApp(App) | ||
| 9 | + | ||
| 10 | +// 创建Pinia状态管理实例 | ||
| 11 | +const pinia = createPinia() | ||
| 12 | +app.use(pinia) | ||
| 13 | + | ||
| 14 | +// 创建路由实例 | ||
| 15 | +const router = createRouter({ | ||
| 16 | + history: createWebHistory(), | ||
| 17 | + routes: [ | ||
| 18 | + { path: '/', component: () => import('./pages/HomePage.vue') }, | ||
| 19 | + { path: '/activity/:activityId', component: () => import('./pages/ActivityDetail.vue') }, | ||
| 20 | + { path: '/create-activity', component: () => import('./pages/CreateActivity.vue') }, | ||
| 21 | + { path: '/profile', component: () => import('./pages/UserProfile.vue') }, | ||
| 22 | + { path: '/registration/:activityId', component: () => import('./pages/Registration.vue') }, | ||
| 23 | + { path: '/check-in/:activityId', component: () => import('./pages/CheckIn.vue') }, | ||
| 24 | + { path: '/messages/:activityId', component: () => import('./pages/Messages.vue') }, | ||
| 25 | + { path: '/:pathMatch(.*)*', redirect: '/' } | ||
| 26 | + ] | ||
| 27 | +}) | ||
| 28 | +app.use(router) | ||
| 29 | + | ||
| 30 | +// 挂载应用 | ||
| 31 | +app.mount('#root') |
src/pages/ActivityDetail.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/pages/CheckIn.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/pages/CreateActivity.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/pages/HomePage.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="min-h-screen"> | ||
| 3 | + <!-- 加载状态 --> | ||
| 4 | + <div v-if="loading" class="min-h-screen flex justify-center items-center"> | ||
| 5 | + <div class="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-green-500"></div> | ||
| 6 | + </div> | ||
| 7 | + | ||
| 8 | + <template v-else> | ||
| 9 | + <!-- Hero Section --> | ||
| 10 | + <section class="relative bg-gradient-to-br from-green-50 to-blue-50 py-16"> | ||
| 11 | + <div class="container mx-auto px-4"> | ||
| 12 | + <div class="max-w-3xl mx-auto text-center"> | ||
| 13 | + <h1 class="text-4xl md:text-5xl font-bold mb-6 text-gray-800"> | ||
| 14 | + 连接爱读书的人,<span | ||
| 15 | + class="bg-gradient-to-r from-green-500 to-blue-400 bg-clip-text text-transparent">共享知识的力量</span> | ||
| 16 | + </h1> | ||
| 17 | + <p class="text-xl text-gray-600 mb-8"> | ||
| 18 | + 发现丰富的读书活动,结识志同道合的朋友,让阅读成为一种享受 | ||
| 19 | + </p> | ||
| 20 | + | ||
| 21 | + <!-- 搜索表单 --> | ||
| 22 | + <form @submit.prevent="handleSearch" class="relative max-w-xl mx-auto mb-8"> | ||
| 23 | + <div class="flex rounded-full overflow-hidden shadow-lg"> | ||
| 24 | + <input type="text" v-model="searchTerm" class="flex-grow px-6 py-4 focus:outline-none" | ||
| 25 | + placeholder="搜索读书会活动、主题或城市..." /> | ||
| 26 | + <button type="submit" | ||
| 27 | + class="px-8 py-4 bg-green-500 text-white font-semibold hover:bg-green-600 transition duration-200"> | ||
| 28 | + 搜索 | ||
| 29 | + </button> | ||
| 30 | + </div> | ||
| 31 | + </form> | ||
| 32 | + </div> | ||
| 33 | + </div> | ||
| 34 | + </section> | ||
| 35 | + | ||
| 36 | + <!-- 搜索结果 --> | ||
| 37 | + <section v-if="isSearching" class="container mx-auto px-4 py-12"> | ||
| 38 | + <div class="flex justify-between items-center mb-8"> | ||
| 39 | + <h2 class="text-2xl font-bold text-gray-800">搜索结果</h2> | ||
| 40 | + <button @click="clearSearch" class="text-gray-600 hover:text-gray-800 transition duration-200"> | ||
| 41 | + 清除搜索 | ||
| 42 | + </button> | ||
| 43 | + </div> | ||
| 44 | + <div v-if="searchResults.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 45 | + <ActivityCard v-for="activity in searchResults" :key="activity.id" :activity="activity" /> | ||
| 46 | + </div> | ||
| 47 | + <div v-else class="text-center py-12 text-gray-600"> | ||
| 48 | + 未找到相关活动 | ||
| 49 | + </div> | ||
| 50 | + </section> | ||
| 51 | + | ||
| 52 | + <!-- 活动列表 --> | ||
| 53 | + <template v-else> | ||
| 54 | + <!-- 进行中的活动 --> | ||
| 55 | + <section v-if="ongoingActivities.length > 0" class="container mx-auto px-4 py-12"> | ||
| 56 | + <h2 class="text-2xl font-bold text-gray-800 mb-8">正在进行的活动</h2> | ||
| 57 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 58 | + <ActivityCard v-for="activity in ongoingActivities" :key="activity.id" :activity="activity" /> | ||
| 59 | + </div> | ||
| 60 | + </section> | ||
| 61 | + | ||
| 62 | + <!-- 即将开始的活动 --> | ||
| 63 | + <section class="container mx-auto px-4 py-12"> | ||
| 64 | + <div class="flex justify-between items-center mb-8"> | ||
| 65 | + <h2 class="text-2xl font-bold text-gray-800">即将开始的活动</h2> | ||
| 66 | + <router-link to="/create-activity" | ||
| 67 | + class="px-6 py-2 bg-green-500 text-white rounded-full font-semibold hover:bg-green-600 transition duration-200"> | ||
| 68 | + 创建活动 | ||
| 69 | + </router-link> | ||
| 70 | + </div> | ||
| 71 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 72 | + <ActivityCard v-for="activity in upcomingActivities" :key="activity.id" :activity="activity" /> | ||
| 73 | + </div> | ||
| 74 | + </section> | ||
| 75 | + | ||
| 76 | + <!-- 往期活动 --> | ||
| 77 | + <section class="container mx-auto px-4 py-12"> | ||
| 78 | + <h2 class="text-2xl font-bold text-gray-800 mb-8">往期活动</h2> | ||
| 79 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 80 | + <ActivityCard v-for="activity in pastActivities" :key="activity.id" :activity="activity" /> | ||
| 81 | + </div> | ||
| 82 | + </section> | ||
| 83 | + </template> | ||
| 84 | + </template> | ||
| 85 | + </div> | ||
| 86 | +</template> | ||
| 87 | + | ||
| 88 | +<script setup> | ||
| 89 | +import { ref, computed, watchEffect } from 'vue' | ||
| 90 | +import { useAppStore } from '../stores/app' | ||
| 91 | +import ActivityCard from '../components/shared/ActivityCard.vue' | ||
| 92 | + | ||
| 93 | +const store = useAppStore() | ||
| 94 | +const { activities, loading } = store | ||
| 95 | + | ||
| 96 | +const upcomingActivities = ref([]) | ||
| 97 | +const ongoingActivities = ref([]) | ||
| 98 | +const pastActivities = ref([]) | ||
| 99 | +const searchTerm = ref('') | ||
| 100 | +const searchResults = ref([]) | ||
| 101 | +const isSearching = ref(false) | ||
| 102 | + | ||
| 103 | +// 监听活动数据变化并分类 | ||
| 104 | +watchEffect(() => { | ||
| 105 | + if (activities.value.length > 0) { | ||
| 106 | + const now = new Date() | ||
| 107 | + | ||
| 108 | + // 筛选即将开始的活动 | ||
| 109 | + upcomingActivities.value = activities.value | ||
| 110 | + .filter(activity => { | ||
| 111 | + const startTime = new Date(activity.start_time.replace(' ', 'T')) | ||
| 112 | + return startTime > now | ||
| 113 | + }) | ||
| 114 | + .sort((a, b) => { | ||
| 115 | + return new Date(a.start_time.replace(' ', 'T')) - new Date(b.start_time.replace(' ', 'T')) | ||
| 116 | + }) | ||
| 117 | + .slice(0, 6) | ||
| 118 | + | ||
| 119 | + // 筛选进行中的活动 | ||
| 120 | + ongoingActivities.value = activities.value | ||
| 121 | + .filter(activity => { | ||
| 122 | + const startTime = new Date(activity.start_time.replace(' ', 'T')) | ||
| 123 | + const endTime = new Date(activity.end_time.replace(' ', 'T')) | ||
| 124 | + return startTime <= now && endTime >= now | ||
| 125 | + }) | ||
| 126 | + | ||
| 127 | + // 筛选往期活动 | ||
| 128 | + pastActivities.value = activities.value | ||
| 129 | + .filter(activity => { | ||
| 130 | + const endTime = new Date(activity.end_time.replace(' ', 'T')) | ||
| 131 | + return endTime < now | ||
| 132 | + }) | ||
| 133 | + .sort((a, b) => { | ||
| 134 | + return new Date(b.end_time.replace(' ', 'T')) - new Date(a.end_time.replace(' ', 'T')) | ||
| 135 | + }) | ||
| 136 | + .slice(0, 6) | ||
| 137 | + } | ||
| 138 | +}) | ||
| 139 | + | ||
| 140 | +// 搜索处理 | ||
| 141 | +const handleSearch = () => { | ||
| 142 | + if (!searchTerm.value.trim()) return | ||
| 143 | + | ||
| 144 | + isSearching.value = true | ||
| 145 | + searchResults.value = activities.value.filter(activity => | ||
| 146 | + activity.title.toLowerCase().includes(searchTerm.value.toLowerCase()) || | ||
| 147 | + activity.description.toLowerCase().includes(searchTerm.value.toLowerCase()) || | ||
| 148 | + (activity.tags && activity.tags.some(tag => tag.toLowerCase().includes(searchTerm.value.toLowerCase()))) | ||
| 149 | + ) | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +// 清除搜索 | ||
| 153 | +const clearSearch = () => { | ||
| 154 | + searchTerm.value = '' | ||
| 155 | + isSearching.value = false | ||
| 156 | +} | ||
| 157 | +</script> |
src/pages/Messages.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/pages/Registration.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/pages/UserProfile.vue
0 → 100644
This diff is collapsed. Click to expand it.
src/providers/AppProvider.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2025-04-17 13:41:17 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 13:41:18 | ||
| 5 | + * @FilePath: /reading-club-app/src/providers/AppProvider.vue | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <slot></slot> | ||
| 10 | +</template> | ||
| 11 | + | ||
| 12 | +<script setup> | ||
| 13 | +import { onMounted } from 'vue' | ||
| 14 | +import { useAppStore } from '../stores/app' | ||
| 15 | + | ||
| 16 | +const store = useAppStore() | ||
| 17 | + | ||
| 18 | +onMounted(() => { | ||
| 19 | + store.fetchInitialData() | ||
| 20 | +}) | ||
| 21 | +</script> |
src/stores/app.js
0 → 100644
| 1 | +import { defineStore } from 'pinia' | ||
| 2 | +import { ref, computed } from 'vue' | ||
| 3 | + | ||
| 4 | +export const useAppStore = defineStore('app', () => { | ||
| 5 | + const currentUser = ref(null) | ||
| 6 | + const activities = ref([]) | ||
| 7 | + const loading = ref(true) | ||
| 8 | + const error = ref(null) | ||
| 9 | + const registrations = ref([]) | ||
| 10 | + const userMessages = ref([]) | ||
| 11 | + | ||
| 12 | + // 初始化应用数据 | ||
| 13 | + async function fetchInitialData() { | ||
| 14 | + try { | ||
| 15 | + loading.value = true | ||
| 16 | + | ||
| 17 | + // 获取用户数据 | ||
| 18 | + const usersResponse = await fetch('/data/users.json') | ||
| 19 | + const usersData = await usersResponse.json() | ||
| 20 | + currentUser.value = usersData[0] | ||
| 21 | + | ||
| 22 | + // 获取活动数据 | ||
| 23 | + const activitiesResponse = await fetch('/data/activities.json') | ||
| 24 | + const activitiesData = await activitiesResponse.json() | ||
| 25 | + activities.value = activitiesData | ||
| 26 | + | ||
| 27 | + // 获取报名数据 | ||
| 28 | + const registrationsResponse = await fetch('/data/registrations.json') | ||
| 29 | + const registrationsData = await registrationsResponse.json() | ||
| 30 | + registrations.value = registrationsData | ||
| 31 | + | ||
| 32 | + // 获取消息数据 | ||
| 33 | + const messagesResponse = await fetch('/data/messages.json') | ||
| 34 | + const messagesData = await messagesResponse.json() | ||
| 35 | + userMessages.value = messagesData | ||
| 36 | + | ||
| 37 | + loading.value = false | ||
| 38 | + } catch (err) { | ||
| 39 | + console.error('Failed to fetch initial data:', err) | ||
| 40 | + error.value = '加载应用数据失败,请稍后重试。' | ||
| 41 | + loading.value = false | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + // 根据ID获取活动 | ||
| 46 | + const getActivityById = computed(() => { | ||
| 47 | + return (activityId) => activities.value.find(activity => activity.id === activityId) || null | ||
| 48 | + }) | ||
| 49 | + | ||
| 50 | + // 获取用户的报名记录 | ||
| 51 | + const getUserRegistrations = computed(() => { | ||
| 52 | + return () => currentUser.value ? registrations.value.filter(reg => reg.user_id === currentUser.value.id) : [] | ||
| 53 | + }) | ||
| 54 | + | ||
| 55 | + // 报名活动 | ||
| 56 | + function registerForActivity(activityId, formData) { | ||
| 57 | + if (!currentUser.value) return { success: false, error: '用户未登录' } | ||
| 58 | + | ||
| 59 | + const newRegistration = { | ||
| 60 | + id: `R${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 61 | + activity_id: activityId, | ||
| 62 | + user_id: currentUser.value.id, | ||
| 63 | + registration_time: new Date().toISOString().replace('T', ' ').substring(0, 19), | ||
| 64 | + status: 'pending', | ||
| 65 | + custom_fields: formData.fields, | ||
| 66 | + custom_answers: formData.answers | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + registrations.value.push(newRegistration) | ||
| 70 | + return { success: true, registrationId: newRegistration.id } | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + // 创建新活动 | ||
| 74 | + function createActivity(activityData) { | ||
| 75 | + if (!currentUser.value) return { success: false, error: '用户未登录' } | ||
| 76 | + | ||
| 77 | + const newActivity = { | ||
| 78 | + id: `A${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 79 | + organizer_id: currentUser.value.id, | ||
| 80 | + organizer_name: currentUser.value.name, | ||
| 81 | + ...activityData, | ||
| 82 | + participant_count: 0, | ||
| 83 | + is_published: activityData.is_published || true, | ||
| 84 | + is_public: activityData.is_public || true, | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + activities.value.push(newActivity) | ||
| 88 | + return { success: true, activityId: newActivity.id } | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + // 活动签到 | ||
| 92 | + function checkInForActivity(activityId, registrationId, method = 'manual') { | ||
| 93 | + if (!currentUser.value) return { success: false, error: '用户未登录' } | ||
| 94 | + | ||
| 95 | + const checkinData = { | ||
| 96 | + id: `C${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 97 | + activity_id: activityId, | ||
| 98 | + user_id: currentUser.value.id, | ||
| 99 | + registration_id: registrationId, | ||
| 100 | + checkin_time: new Date().toISOString().replace('T', ' ').substring(0, 19), | ||
| 101 | + checkin_type: method, | ||
| 102 | + status: 'successful', | ||
| 103 | + is_late: false | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + return { success: true, checkin: checkinData } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + // 获取用户消息 | ||
| 110 | + const getUserMessages = computed(() => { | ||
| 111 | + return () => currentUser.value ? userMessages.value.filter(msg => msg.recipient_id === currentUser.value.id) : [] | ||
| 112 | + }) | ||
| 113 | + | ||
| 114 | + // 切换消息已读状态 | ||
| 115 | + function toggleMessageReadStatus(messageId) { | ||
| 116 | + userMessages.value = userMessages.value.map(msg => | ||
| 117 | + msg.id === messageId | ||
| 118 | + ? { ...msg, read_status: !msg.read_status } | ||
| 119 | + : msg | ||
| 120 | + ) | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + return { | ||
| 124 | + currentUser, | ||
| 125 | + activities, | ||
| 126 | + loading, | ||
| 127 | + error, | ||
| 128 | + registrations, | ||
| 129 | + userMessages, | ||
| 130 | + fetchInitialData, | ||
| 131 | + getActivityById, | ||
| 132 | + getUserRegistrations, | ||
| 133 | + registerForActivity, | ||
| 134 | + createActivity, | ||
| 135 | + checkInForActivity, | ||
| 136 | + getUserMessages, | ||
| 137 | + toggleMessageReadStatus | ||
| 138 | + } | ||
| 139 | +}) |
src/utils/api.js
0 → 100644
| 1 | +// Mock API functions to fetch data from JSON files | ||
| 2 | + | ||
| 3 | +// Fetch activities from mock data | ||
| 4 | +export const fetchActivities = async () => { | ||
| 5 | + try { | ||
| 6 | + const response = await fetch('/data/activities.json'); | ||
| 7 | + if (!response.ok) throw new Error('Failed to fetch activities'); | ||
| 8 | + const data = await response.json(); | ||
| 9 | + return data; | ||
| 10 | + } catch (error) { | ||
| 11 | + console.error('Error fetching activities:', error); | ||
| 12 | + throw error; | ||
| 13 | + } | ||
| 14 | +}; | ||
| 15 | + | ||
| 16 | +// Fetch a single activity by ID | ||
| 17 | +export const fetchActivityById = async (activityId) => { | ||
| 18 | + try { | ||
| 19 | + const response = await fetch('/data/activities.json'); | ||
| 20 | + if (!response.ok) throw new Error('Failed to fetch activity'); | ||
| 21 | + const activities = await response.json(); | ||
| 22 | + const activity = activities.find(act => act.id === activityId); | ||
| 23 | + | ||
| 24 | + if (!activity) throw new Error('Activity not found'); | ||
| 25 | + return activity; | ||
| 26 | + } catch (error) { | ||
| 27 | + console.error(`Error fetching activity ${activityId}:`, error); | ||
| 28 | + throw error; | ||
| 29 | + } | ||
| 30 | +}; | ||
| 31 | + | ||
| 32 | +// Fetch users data | ||
| 33 | +export const fetchUsers = async () => { | ||
| 34 | + try { | ||
| 35 | + const response = await fetch('/data/users.json'); | ||
| 36 | + if (!response.ok) throw new Error('Failed to fetch users'); | ||
| 37 | + const data = await response.json(); | ||
| 38 | + return data; | ||
| 39 | + } catch (error) { | ||
| 40 | + console.error('Error fetching users:', error); | ||
| 41 | + throw error; | ||
| 42 | + } | ||
| 43 | +}; | ||
| 44 | + | ||
| 45 | +// Fetch a single user by ID | ||
| 46 | +export const fetchUserById = async (userId) => { | ||
| 47 | + try { | ||
| 48 | + const response = await fetch('/data/users.json'); | ||
| 49 | + if (!response.ok) throw new Error('Failed to fetch user'); | ||
| 50 | + const users = await response.json(); | ||
| 51 | + const user = users.find(u => u.id === userId); | ||
| 52 | + | ||
| 53 | + if (!user) throw new Error('User not found'); | ||
| 54 | + return user; | ||
| 55 | + } catch (error) { | ||
| 56 | + console.error(`Error fetching user ${userId}:`, error); | ||
| 57 | + throw error; | ||
| 58 | + } | ||
| 59 | +}; | ||
| 60 | + | ||
| 61 | +// Fetch registrations data | ||
| 62 | +export const fetchRegistrations = async (activityId = null, userId = null) => { | ||
| 63 | + try { | ||
| 64 | + const response = await fetch('/data/registrations.json'); | ||
| 65 | + if (!response.ok) throw new Error('Failed to fetch registrations'); | ||
| 66 | + let registrations = await response.json(); | ||
| 67 | + | ||
| 68 | + // Filter by activity ID if provided | ||
| 69 | + if (activityId) { | ||
| 70 | + registrations = registrations.filter(reg => reg.activity_id === activityId); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + // Filter by user ID if provided | ||
| 74 | + if (userId) { | ||
| 75 | + registrations = registrations.filter(reg => reg.user_id === userId); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + return registrations; | ||
| 79 | + } catch (error) { | ||
| 80 | + console.error('Error fetching registrations:', error); | ||
| 81 | + throw error; | ||
| 82 | + } | ||
| 83 | +}; | ||
| 84 | + | ||
| 85 | +// Fetch check-in data | ||
| 86 | +export const fetchCheckins = async (activityId = null, userId = null) => { | ||
| 87 | + try { | ||
| 88 | + const response = await fetch('/data/checkins.json'); | ||
| 89 | + if (!response.ok) throw new Error('Failed to fetch check-ins'); | ||
| 90 | + let checkins = await response.json(); | ||
| 91 | + | ||
| 92 | + // Filter by activity ID if provided | ||
| 93 | + if (activityId) { | ||
| 94 | + checkins = checkins.filter(check => check.activity_id === activityId); | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + // Filter by user ID if provided | ||
| 98 | + if (userId) { | ||
| 99 | + checkins = checkins.filter(check => check.user_id === userId); | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + return checkins; | ||
| 103 | + } catch (error) { | ||
| 104 | + console.error('Error fetching check-ins:', error); | ||
| 105 | + throw error; | ||
| 106 | + } | ||
| 107 | +}; | ||
| 108 | + | ||
| 109 | +// Fetch messages data | ||
| 110 | +export const fetchMessages = async (recipientId = null) => { | ||
| 111 | + try { | ||
| 112 | + const response = await fetch('/data/messages.json'); | ||
| 113 | + if (!response.ok) throw new Error('Failed to fetch messages'); | ||
| 114 | + let messages = await response.json(); | ||
| 115 | + | ||
| 116 | + // Filter by recipient ID if provided | ||
| 117 | + if (recipientId) { | ||
| 118 | + messages = messages.filter(msg => msg.recipient_id === recipientId); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + return messages; | ||
| 122 | + } catch (error) { | ||
| 123 | + console.error('Error fetching messages:', error); | ||
| 124 | + throw error; | ||
| 125 | + } | ||
| 126 | +}; | ||
| 127 | + | ||
| 128 | +// Mock function for creating an activity (would be an API POST in a real app) | ||
| 129 | +export const createActivity = async (activityData) => { | ||
| 130 | + // In a real app, this would send a POST request | ||
| 131 | + console.log('Creating activity with data:', activityData); | ||
| 132 | + return { | ||
| 133 | + success: true, | ||
| 134 | + id: `A${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 135 | + ...activityData | ||
| 136 | + }; | ||
| 137 | +}; | ||
| 138 | + | ||
| 139 | +// Mock function for registering to an activity | ||
| 140 | +export const registerForActivity = async (activityId, userData) => { | ||
| 141 | + // In a real app, this would send a POST request | ||
| 142 | + console.log(`Registering for activity ${activityId} with data:`, userData); | ||
| 143 | + return { | ||
| 144 | + success: true, | ||
| 145 | + id: `R${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 146 | + activity_id: activityId, | ||
| 147 | + ...userData | ||
| 148 | + }; | ||
| 149 | +}; | ||
| 150 | + | ||
| 151 | +// Mock function for checking in to an activity | ||
| 152 | +export const checkInForActivity = async (registrationId, checkinData) => { | ||
| 153 | + // In a real app, this would send a POST request | ||
| 154 | + console.log(`Checking in for registration ${registrationId} with data:`, checkinData); | ||
| 155 | + return { | ||
| 156 | + success: true, | ||
| 157 | + id: `C${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, | ||
| 158 | + registration_id: registrationId, | ||
| 159 | + ...checkinData | ||
| 160 | + }; | ||
| 161 | +}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
tailwind.config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-04-16 22:22:18 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 14:09:27 | ||
| 5 | + * @FilePath: /reading-club-app/tailwind.config.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +/** @type {import('tailwindcss').Config} */ | ||
| 9 | +export default { | ||
| 10 | + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], | ||
| 11 | + theme: { | ||
| 12 | + extend: { | ||
| 13 | + colors: { | ||
| 14 | + primary: '#4caf50', | ||
| 15 | + secondary: '#2196f3', | ||
| 16 | + 'background-start': '#f0f9ef', | ||
| 17 | + 'background-end': '#e6f4ff', | ||
| 18 | + 'text-base': '#333333', | ||
| 19 | + 'text-light': '#6e6e6e' | ||
| 20 | + } | ||
| 21 | + }, | ||
| 22 | + }, | ||
| 23 | + plugins: [], | ||
| 24 | +}; |
template_config.json
0 → 100644
| 1 | +{ | ||
| 2 | + "description": " - This is a template for general web app, game, common application,client,tool,component development. It is based on React framework with Tailwind CSS. The template includes the basic structure of a React project, including an index.html file and a src directory with an App.jsx file.\n - Modify index.html to change web title, create new jsx files under src if needed, and rewrite src/App.jsx to meet the user's requirements. You should NOT modify src/main.jsx and src/index.css. Modify vite.config.js only if extra config is absolutely necessary.\n - Style your elements with Tailwind CSS classes directly in the jsx files.\n - If the user's requirements do not specify a technology stack, development language, development tools, components, etc., this template will be used by default. For example, developing an XXX application, XXX client, etc. all use this template.", | ||
| 3 | + "required_fields": [], | ||
| 4 | + "required_files": [ | ||
| 5 | + "index.html", | ||
| 6 | + "src/main.jsx", | ||
| 7 | + "src/App.jsx", | ||
| 8 | + "src/index.css", | ||
| 9 | + "vite.config.js" | ||
| 10 | + ], | ||
| 11 | + "lang": "JavaScript", | ||
| 12 | + "framework": "React", | ||
| 13 | + "style": "react_template", | ||
| 14 | + "scene": "default_web_project" | ||
| 15 | +} |
vite.config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-04-16 22:22:18 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-04-17 14:11:13 | ||
| 5 | + * @FilePath: /reading-club-app/vite.config.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +import { defineConfig } from 'vite' | ||
| 9 | +import vue from '@vitejs/plugin-vue' | ||
| 10 | +import vueJsx from '@vitejs/plugin-vue-jsx' | ||
| 11 | + | ||
| 12 | +// https://vitejs.dev/config/ | ||
| 13 | +export default defineConfig({ | ||
| 14 | + plugins: [ | ||
| 15 | + vue(), | ||
| 16 | + vueJsx(), | ||
| 17 | + ], | ||
| 18 | +}) |
-
Please register or login to post a comment