hookehuyr

feat(打卡): 新增打卡首页功能

- 添加打卡首页路由和页面组件
- 实现日历打卡展示、目标进度和团队动态功能
- 扩展AppLayout组件支持无标题模式
- 新增Vant组件类型声明
```

这个提交消息遵循了以下原则:
1. 使用`feat`类型表示新增功能
2. 添加了`(打卡)`范围明确修改领域
3. 简要描述主要变更内容
4. 在消息体中列出关键修改点,保持简洁
5. 使用中文简体符合要求
6. 每个条目使用动词开头保持一致性
...@@ -30,8 +30,11 @@ declare module 'vue' { ...@@ -30,8 +30,11 @@ declare module 'vue' {
30 UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default'] 30 UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default']
31 VanActionSheet: typeof import('vant/es')['ActionSheet'] 31 VanActionSheet: typeof import('vant/es')['ActionSheet']
32 VanButton: typeof import('vant/es')['Button'] 32 VanButton: typeof import('vant/es')['Button']
33 + VanCalendar: typeof import('vant/es')['Calendar']
33 VanCellGroup: typeof import('vant/es')['CellGroup'] 34 VanCellGroup: typeof import('vant/es')['CellGroup']
34 VanCheckbox: typeof import('vant/es')['Checkbox'] 35 VanCheckbox: typeof import('vant/es')['Checkbox']
36 + VanCol: typeof import('vant/es')['Col']
37 + VanConfigProvider: typeof import('vant/es')['ConfigProvider']
35 VanDatePicker: typeof import('vant/es')['DatePicker'] 38 VanDatePicker: typeof import('vant/es')['DatePicker']
36 VanDialog: typeof import('vant/es')['Dialog'] 39 VanDialog: typeof import('vant/es')['Dialog']
37 VanEmpty: typeof import('vant/es')['Empty'] 40 VanEmpty: typeof import('vant/es')['Empty']
...@@ -47,6 +50,7 @@ declare module 'vue' { ...@@ -47,6 +50,7 @@ declare module 'vue' {
47 VanPopup: typeof import('vant/es')['Popup'] 50 VanPopup: typeof import('vant/es')['Popup']
48 VanProgress: typeof import('vant/es')['Progress'] 51 VanProgress: typeof import('vant/es')['Progress']
49 VanRate: typeof import('vant/es')['Rate'] 52 VanRate: typeof import('vant/es')['Rate']
53 + VanRow: typeof import('vant/es')['Row']
50 VanSwipe: typeof import('vant/es')['Swipe'] 54 VanSwipe: typeof import('vant/es')['Swipe']
51 VanSwipeItem: typeof import('vant/es')['SwipeItem'] 55 VanSwipeItem: typeof import('vant/es')['SwipeItem']
52 VanTab: typeof import('vant/es')['Tab'] 56 VanTab: typeof import('vant/es')['Tab']
......
1 <!-- 1 <!--
2 * @Date: 2025-03-20 20:36:36 2 * @Date: 2025-03-20 20:36:36
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-03-24 14:05:01 4 + * @LastEditTime: 2025-05-29 15:46:57
5 * @FilePath: /mlaj/src/components/layout/AppLayout.vue 5 * @FilePath: /mlaj/src/components/layout/AppLayout.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
14 :onBack="handleBack" 14 :onBack="handleBack"
15 :rightContent="rightContent" 15 :rightContent="rightContent"
16 /> 16 />
17 - <main :class="{ 'pb-16': title, 'py-4': !title && route.path !== '/profile' }"> 17 + <main :class="{ 'pb-16': title, 'py-4': (!title && route.path !== '/profile') && hasTitle }">
18 <slot></slot> 18 <slot></slot>
19 </main> 19 </main>
20 <BottomNav v-if="!hideBottomNav" /> 20 <BottomNav v-if="!hideBottomNav" />
...@@ -48,6 +48,10 @@ const props = defineProps({ ...@@ -48,6 +48,10 @@ const props = defineProps({
48 hideBottomNav: { 48 hideBottomNav: {
49 type: Boolean, 49 type: Boolean,
50 default: false 50 default: false
51 + },
52 + hasTitle: {
53 + type: Boolean,
54 + default: true
51 } 55 }
52 }) 56 })
53 57
......
1 /* 1 /*
2 * @Date: 2025-03-21 13:28:30 2 * @Date: 2025-03-21 13:28:30
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-03-21 13:33:06 4 + * @LastEditTime: 2025-05-29 15:36:20
5 * @FilePath: /mlaj/src/router/checkin.js 5 * @FilePath: /mlaj/src/router/checkin.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -41,5 +41,14 @@ export default [ ...@@ -41,5 +41,14 @@ export default [
41 title: '反思打卡', 41 title: '反思打卡',
42 requiresAuth: true 42 requiresAuth: true
43 } 43 }
44 + },
45 + {
46 + path: '/checkin/index',
47 + name: 'IndexCheckIn',
48 + component: () => import('@/views/checkin/IndexCheckInPage.vue'),
49 + meta: {
50 + title: '打卡首页',
51 + requiresAuth: true
52 + }
44 } 53 }
45 ] 54 ]
......
1 +<!--
2 + * @Date: 2025-05-29 15:34:17
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-05-30 15:07:29
5 + * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 + * @Description: 文件描述
7 +-->
8 +<template>
9 + <AppLayout :hasTitle="false">
10 + <van-config-provider :theme-vars="themeVars">
11 + <van-calendar
12 + title="每日打卡"
13 + :poppable="false"
14 + :show-confirm="false"
15 + :style="{ height: '24rem' }"
16 + switch-mode="year-month"
17 + color="#4caf50"
18 + :formatter="formatter"
19 + row-height="42"
20 + :show-mark="false"
21 + @click-subtitle="onClickSubtitle"
22 + >
23 + </van-calendar>
24 +
25 + <div class="text-wrapper">
26 + <div class="text-header">目标进度</div>
27 + <div class="grade-percentage-main">
28 + <van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
29 + <van-col span="12">
30 + <span>年级目标</span>
31 + </van-col>
32 + <van-col span="12" style="text-align: right;">
33 + <span style="font-weight: bold;">{{ progress1 }}%</span>
34 + </van-col>
35 + </van-row>
36 + <van-progress :percentage="progress1" color="#4caf50" :show-pivot="false" />
37 + </div>
38 + <div class="class-percentage-main">
39 + <van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
40 + <van-col span="12">
41 + <span>班级目标</span>
42 + </van-col>
43 + <van-col span="12" style="text-align: right;">
44 + <span style="font-weight: bold;">{{ progress2 }}%</span>
45 + </van-col>
46 + </van-row>
47 + <van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
48 + </div>
49 + <div style="padding: 0.75rem 1rem;">
50 + <van-image round width="2.8rem" height="2.8rem" src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg" v-for="(item, index) in teamAvatars" :key="index" :style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #FFF' }" />
51 + </div>
52 + </div>
53 +
54 + <div class="text-wrapper">
55 + <div class="text-header">上传附件</div>
56 + <div style="display: flex; margin: 1rem 0; gap: 1rem;">
57 + <div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
58 + <div><van-icon name="photo" size="2.5rem" /></div>
59 + <div style="font-size: 0.85rem;">图文上传</div>
60 + </div>
61 + <div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
62 + <div><van-icon name="video" size="2.5rem" /></div>
63 + <div style="font-size: 0.85rem;">视频/语音</div>
64 + </div>
65 + </div>
66 + </div>
67 +
68 + <div class="text-wrapper">
69 + <div class="text-header">团队动态</div>
70 + <div class="post-card" v-for="post in mockPosts" :key="post.id">
71 + <div class="post-header">
72 + <van-row>
73 + <van-col span="3">
74 + <van-image round width="2.5rem" height="2.5rem" :src="post.user.avatar" />
75 + </van-col>
76 + <van-col span="20">
77 + <div class="user-info">
78 + <div class="username">{{ post.user.name }}</div>
79 + <div class="post-time">{{ post.user.time }}</div>
80 + </div>
81 + </van-col>
82 + </van-row>
83 + </div>
84 + <div class="post-content">
85 + <div class="post-text">{{ post.content }}</div>
86 + <div class="post-media">
87 + <div v-if="post.images.length" class="post-images">
88 + <van-image
89 + width="100"
90 + height="100"
91 + v-for="(image, index) in post.images"
92 + :key="index"
93 + :src="image"
94 + @click="openImagePreview(index, post)"
95 + />
96 + </div>
97 + <van-image-preview
98 + v-if="currentPost"
99 + v-model:show="showImagePreview"
100 + :images="currentPost.images"
101 + :start-position="startPosition"
102 + :show-index="true"
103 + @change="onChange"
104 + />
105 + <!-- 视频封面和播放按钮 -->
106 + <div v-if="post.video && !post.isPlaying" class="relative w-full rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
107 + <img :src="post.videoCover || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'" :alt="post.content" class="w-full h-full object-cover" />
108 + <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
109 + @click="startPlay(post)">
110 + <div class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
111 + <van-icon name="play-circle-o" class="text-white" size="30" />
112 + </div>
113 + </div>
114 + </div>
115 + <!-- 视频播放器 -->
116 + <VideoPlayer
117 + v-if="post.video && post.isPlaying"
118 + :video-url="post.video"
119 + class="post-video rounded-lg overflow-hidden"
120 + ref="(el) => { if(el) videoPlayers.value.push(el) }"
121 + @onPlay="(player) => handleVideoPlay(player, post)"
122 + @onPause="() => handleVideoPause(post)"
123 + />
124 + <AudioPlayer v-if="post.audio.length" :songs="post.audio" class="post-audio" />
125 + </div>
126 + </div>
127 + <div class="post-footer">
128 + <van-icon name="like" class="like-icon" />
129 + <span class="like-count">{{ post.likes }}</span>
130 + </div>
131 + </div>
132 + </div>
133 +
134 + <div style="height: 5rem;"></div>
135 + </van-config-provider>
136 + </AppLayout>
137 +</template>
138 +
139 +<script setup>
140 +import { ref } from 'vue'
141 +import { useRoute, useRouter } from 'vue-router'
142 +import AppLayout from "@/components/layout/AppLayout.vue";
143 +import FrostedGlass from "@/components/ui/FrostedGlass.vue";
144 +import VideoPlayer from "@/components/ui/VideoPlayer.vue";
145 +import AudioPlayer from "@/components/ui/AudioPlayer.vue";
146 +
147 +// 存储所有视频播放器的引用
148 +const videoPlayers = ref([]);
149 +
150 +/**
151 + * 开始播放指定帖子的视频
152 + * @param {Object} post - 要播放视频的帖子对象
153 + */
154 +const startPlay = (post) => {
155 + // 先暂停所有其他视频
156 + mockPosts.value.forEach(p => {
157 + if (p.id !== post.id) {
158 + p.isPlaying = false;
159 + }
160 + });
161 +
162 + // 设置当前视频为播放状态
163 + post.isPlaying = true;
164 +};
165 +
166 +/**
167 + * 处理视频播放事件
168 + * @param {Object} player - 视频播放器实例
169 + * @param {Object} post - 包含视频的帖子对象
170 + */
171 +const handleVideoPlay = (player, post) => {
172 + // 停止其他视频播放
173 + stopOtherVideos(player, post);
174 +};
175 +
176 +/**
177 + * 处理视频暂停事件
178 + * @param {Object} post - 包含视频的帖子对象
179 + */
180 +const handleVideoPause = (post) => {
181 + // 视频暂停时不改变isPlaying状态,保持播放器可见
182 + // 这样用户可以继续从暂停处播放
183 +};
184 +
185 +/**
186 + * 停止除当前播放器外的所有其他视频
187 + * @param {Object} currentPlayer - 当前播放的视频播放器实例
188 + * @param {Object} currentPost - 当前播放的帖子对象
189 + */
190 +const stopOtherVideos = (currentPlayer, currentPost) => {
191 + // 暂停其他视频播放器
192 + videoPlayers.value.forEach(player => {
193 + if (player !== currentPlayer && player.pause) {
194 + player.pause();
195 + }
196 + });
197 +
198 + // 更新其他帖子的播放状态
199 + mockPosts.value.forEach(post => {
200 + if (post.id !== currentPost.id) {
201 + post.isPlaying = false;
202 + }
203 + });
204 +};
205 +
206 +// Mock数据
207 +const mockPosts = ref([
208 + {
209 + id: 1,
210 + user: {
211 + name: '小林',
212 + avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
213 + time: '2小时前'
214 + },
215 + content: '今天完成了React基础课程的学习,收获满满!',
216 + images: [
217 + 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
218 + 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
219 + 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
220 + 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
221 + ],
222 + video: '',
223 + videoCover: '',
224 + isPlaying: false,
225 + audio: [],
226 + likes: 12
227 + },
228 + {
229 + id: 2,
230 + user: {
231 + name: '小林',
232 + avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
233 + time: '2小时前'
234 + },
235 + content: '今天完成了React基础课程的学习,收获满满!',
236 + images: [],
237 + video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
238 + videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
239 + isPlaying: false,
240 + audio: [],
241 + likes: 12
242 + },
243 + {
244 + id: 3,
245 + user: {
246 + name: '小林',
247 + avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
248 + time: '2小时前'
249 + },
250 + content: '今天完成了React基础课程的学习,收获满满!',
251 + images: [],
252 + video: '',
253 + videoCover: '',
254 + isPlaying: false,
255 + audio: [
256 + {
257 + title: '学习心得分享',
258 + artist: '小林',
259 + url: 'https://example.com/audio.mp3',
260 + cover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
261 + }
262 + ],
263 + likes: 12
264 + },
265 + {
266 + id: 4,
267 + user: {
268 + name: '小林',
269 + avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
270 + time: '2小时前'
271 + },
272 + content: '今天完成了React基础课程的学习,收获满满!',
273 + images: [],
274 + video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
275 + videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
276 + isPlaying: false,
277 + audio: [],
278 + likes: 12
279 + },
280 +]);
281 +
282 +const themeVars = {
283 + calendarSelectedDayBackground: '#4caf50'
284 +}
285 +
286 +const progress1 = ref(50);
287 +const progress2 = ref(76);
288 +
289 +const teamAvatars = ref([
290 + 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
291 + 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
292 + 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
293 + 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
294 +])
295 +
296 +// 图片预览相关
297 +const showImagePreview = ref(false);
298 +const startPosition = ref(0);
299 +const currentPost = ref(null);
300 +
301 +// 打开图片预览
302 +const openImagePreview = (index, post) => {
303 + currentPost.value = post;
304 + startPosition.value = index;
305 + showImagePreview.value = true;
306 +}
307 +
308 +// 图片切换事件处理
309 +const onChange = (index) => {
310 + startPosition.value = index;
311 +}
312 +const formatter = (day) => {
313 + const month = day.date.getMonth() + 1;
314 + const date = day.date.getDate();
315 +
316 + let checkin_days = [1, 3, 5, 7];
317 +
318 + if (month === 5) {
319 + if (checkin_days.includes(date)) {
320 + day.className = 'calendar-checkin';
321 + day.type ='selected';
322 + }
323 + }
324 +
325 + return day;
326 +}
327 +
328 +const onClickSubtitle = (evt) => {
329 + console.warn('点击了日期标题');
330 +}
331 +</script>
332 +
333 +<style lang="less">
334 + .calendar-checkin {
335 + .van-calendar__selected-day {
336 + background: #a2d8a3 !important;
337 + }
338 + }
339 +
340 + .text-wrapper {
341 + padding: 1rem;
342 + color: #4caf50;
343 + .text-header {
344 + font-size: 1.15rem;
345 + }
346 +
347 + .grade-percentage-main {
348 + padding: 0.75rem 1rem;
349 + }
350 + .class-percentage-main {
351 + padding: 0.75rem 1rem;
352 + }
353 + }
354 +</style>
355 +
356 +.post-card {
357 + margin: 1rem 0;
358 + padding: 1rem;
359 + background-color: #FFF;
360 + border-radius: 5px;
361 +
362 + .post-header {
363 + margin-bottom: 1rem;
364 + }
365 +
366 + .user-info {
367 + margin-left: 0.5rem;
368 +
369 + .username {
370 + font-weight: 500;
371 + }
372 +
373 + .post-time {
374 + color: gray;
375 + font-size: 0.8rem;
376 + }
377 + }
378 +
379 + .post-content {
380 + .post-text {
381 + margin-bottom: 1rem;
382 + }
383 +
384 + .post-media {
385 + .post-images {
386 + display: flex;
387 + flex-wrap: wrap;
388 + gap: 0.5rem;
389 + }
390 +
391 + .post-video {
392 + margin: 1rem 0;
393 + width: 100%;
394 + border-radius: 8px;
395 + overflow: hidden;
396 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
397 + }
398 +
399 + .post-audio {
400 + margin: 1rem 0;
401 + }
402 + }
403 + }
404 +
405 + .post-footer {
406 + margin-top: 1rem;
407 + color: #666;
408 +
409 + .like-icon {
410 + margin-right: 0.25rem;
411 + }
412 +
413 + .like-count {
414 + font-size: 0.9rem;
415 + }
416 + }
417 +}