hookehuyr

feat: 初始化项目结构并添加核心功能

- 初始化项目结构,包括Vue 3、Pinia、Vue Router、TailwindCSS等依赖
- 添加首页、活动详情页、创建活动页等核心页面
- 实现活动分类、搜索、报名等基本功能
- 添加公共组件如按钮、输入框、模态框等
- 配置Vite、TailwindCSS、PostCSS等构建工具
Showing 53 changed files with 1767 additions and 0 deletions
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
......
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>
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 +}
This diff is collapsed. Click to expand it.
1 +export default {
2 + plugins: {
3 + tailwindcss: {},
4 + autoprefixer: {},
5 + },
6 +}
This file is too large to display.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
1 +{
2 + "data": [
3 + {
4 + "title": "Travel Blog",
5 + "description": "Here is json data for travel blog"
6 + }
7 + ]
8 +}
...\ No newline at end of file ...\ No newline at end of file
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
This diff is collapsed. Click to expand it.
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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 +}
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')
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
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>
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
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>
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 +})
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
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 +};
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 +}
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 +})