hookehuyr

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

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

这个提交消息遵循了以下原则:
1. 使用`feat`类型表示新增功能
2. 添加了`(打卡)`范围明确修改领域
3. 简要描述主要变更内容
4. 在消息体中列出关键修改点,保持简洁
5. 使用中文简体符合要求
6. 每个条目使用动词开头保持一致性
......@@ -30,8 +30,11 @@ declare module 'vue' {
UploadVideoPopup: typeof import('./components/ui/UploadVideoPopup.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanButton: typeof import('vant/es')['Button']
VanCalendar: typeof import('vant/es')['Calendar']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox']
VanCol: typeof import('vant/es')['Col']
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
VanDatePicker: typeof import('vant/es')['DatePicker']
VanDialog: typeof import('vant/es')['Dialog']
VanEmpty: typeof import('vant/es')['Empty']
......@@ -47,6 +50,7 @@ declare module 'vue' {
VanPopup: typeof import('vant/es')['Popup']
VanProgress: typeof import('vant/es')['Progress']
VanRate: typeof import('vant/es')['Rate']
VanRow: typeof import('vant/es')['Row']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanTab: typeof import('vant/es')['Tab']
......
<!--
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-24 14:05:01
* @LastEditTime: 2025-05-29 15:46:57
* @FilePath: /mlaj/src/components/layout/AppLayout.vue
* @Description: 文件描述
-->
......@@ -14,7 +14,7 @@
:onBack="handleBack"
:rightContent="rightContent"
/>
<main :class="{ 'pb-16': title, 'py-4': !title && route.path !== '/profile' }">
<main :class="{ 'pb-16': title, 'py-4': (!title && route.path !== '/profile') && hasTitle }">
<slot></slot>
</main>
<BottomNav v-if="!hideBottomNav" />
......@@ -48,6 +48,10 @@ const props = defineProps({
hideBottomNav: {
type: Boolean,
default: false
},
hasTitle: {
type: Boolean,
default: true
}
})
......
/*
* @Date: 2025-03-21 13:28:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-21 13:33:06
* @LastEditTime: 2025-05-29 15:36:20
* @FilePath: /mlaj/src/router/checkin.js
* @Description: 文件描述
*/
......@@ -41,5 +41,14 @@ export default [
title: '反思打卡',
requiresAuth: true
}
},
{
path: '/checkin/index',
name: 'IndexCheckIn',
component: () => import('@/views/checkin/IndexCheckInPage.vue'),
meta: {
title: '打卡首页',
requiresAuth: true
}
}
]
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-30 15:07:29
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
<template>
<AppLayout :hasTitle="false">
<van-config-provider :theme-vars="themeVars">
<van-calendar
title="每日打卡"
:poppable="false"
:show-confirm="false"
:style="{ height: '24rem' }"
switch-mode="year-month"
color="#4caf50"
:formatter="formatter"
row-height="42"
:show-mark="false"
@click-subtitle="onClickSubtitle"
>
</van-calendar>
<div class="text-wrapper">
<div class="text-header">目标进度</div>
<div class="grade-percentage-main">
<van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
<van-col span="12">
<span>年级目标</span>
</van-col>
<van-col span="12" style="text-align: right;">
<span style="font-weight: bold;">{{ progress1 }}%</span>
</van-col>
</van-row>
<van-progress :percentage="progress1" color="#4caf50" :show-pivot="false" />
</div>
<div class="class-percentage-main">
<van-row justify="space-between" style="margin: 0.5rem 0; font-size: 0.9rem;">
<van-col span="12">
<span>班级目标</span>
</van-col>
<van-col span="12" style="text-align: right;">
<span style="font-weight: bold;">{{ progress2 }}%</span>
</van-col>
</van-row>
<van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
</div>
<div style="padding: 0.75rem 1rem;">
<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' }" />
</div>
</div>
<div class="text-wrapper">
<div class="text-header">上传附件</div>
<div style="display: flex; margin: 1rem 0; gap: 1rem;">
<div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div><van-icon name="photo" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">图文上传</div>
</div>
<div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div><van-icon name="video" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">视频/语音</div>
</div>
</div>
</div>
<div class="text-wrapper">
<div class="text-header">团队动态</div>
<div class="post-card" v-for="post in mockPosts" :key="post.id">
<div class="post-header">
<van-row>
<van-col span="3">
<van-image round width="2.5rem" height="2.5rem" :src="post.user.avatar" />
</van-col>
<van-col span="20">
<div class="user-info">
<div class="username">{{ post.user.name }}</div>
<div class="post-time">{{ post.user.time }}</div>
</div>
</van-col>
</van-row>
</div>
<div class="post-content">
<div class="post-text">{{ post.content }}</div>
<div class="post-media">
<div v-if="post.images.length" class="post-images">
<van-image
width="100"
height="100"
v-for="(image, index) in post.images"
:key="index"
:src="image"
@click="openImagePreview(index, post)"
/>
</div>
<van-image-preview
v-if="currentPost"
v-model:show="showImagePreview"
:images="currentPost.images"
:start-position="startPosition"
:show-index="true"
@change="onChange"
/>
<!-- 视频封面和播放按钮 -->
<div v-if="post.video && !post.isPlaying" class="relative w-full rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<img :src="post.videoCover || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'" :alt="post.content" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
@click="startPlay(post)">
<div class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
<van-icon name="play-circle-o" class="text-white" size="30" />
</div>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer
v-if="post.video && post.isPlaying"
:video-url="post.video"
class="post-video rounded-lg overflow-hidden"
ref="(el) => { if(el) videoPlayers.value.push(el) }"
@onPlay="(player) => handleVideoPlay(player, post)"
@onPause="() => handleVideoPause(post)"
/>
<AudioPlayer v-if="post.audio.length" :songs="post.audio" class="post-audio" />
</div>
</div>
<div class="post-footer">
<van-icon name="like" class="like-icon" />
<span class="like-count">{{ post.likes }}</span>
</div>
</div>
</div>
<div style="height: 5rem;"></div>
</van-config-provider>
</AppLayout>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from "@/components/layout/AppLayout.vue";
import FrostedGlass from "@/components/ui/FrostedGlass.vue";
import VideoPlayer from "@/components/ui/VideoPlayer.vue";
import AudioPlayer from "@/components/ui/AudioPlayer.vue";
// 存储所有视频播放器的引用
const videoPlayers = ref([]);
/**
* 开始播放指定帖子的视频
* @param {Object} post - 要播放视频的帖子对象
*/
const startPlay = (post) => {
// 先暂停所有其他视频
mockPosts.value.forEach(p => {
if (p.id !== post.id) {
p.isPlaying = false;
}
});
// 设置当前视频为播放状态
post.isPlaying = true;
};
/**
* 处理视频播放事件
* @param {Object} player - 视频播放器实例
* @param {Object} post - 包含视频的帖子对象
*/
const handleVideoPlay = (player, post) => {
// 停止其他视频播放
stopOtherVideos(player, post);
};
/**
* 处理视频暂停事件
* @param {Object} post - 包含视频的帖子对象
*/
const handleVideoPause = (post) => {
// 视频暂停时不改变isPlaying状态,保持播放器可见
// 这样用户可以继续从暂停处播放
};
/**
* 停止除当前播放器外的所有其他视频
* @param {Object} currentPlayer - 当前播放的视频播放器实例
* @param {Object} currentPost - 当前播放的帖子对象
*/
const stopOtherVideos = (currentPlayer, currentPost) => {
// 暂停其他视频播放器
videoPlayers.value.forEach(player => {
if (player !== currentPlayer && player.pause) {
player.pause();
}
});
// 更新其他帖子的播放状态
mockPosts.value.forEach(post => {
if (post.id !== currentPost.id) {
post.isPlaying = false;
}
});
};
// Mock数据
const mockPosts = ref([
{
id: 1,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
],
video: '',
videoCover: '',
isPlaying: false,
audio: [],
likes: 12
},
{
id: 2,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
isPlaying: false,
audio: [],
likes: 12
},
{
id: 3,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: '',
videoCover: '',
isPlaying: false,
audio: [
{
title: '学习心得分享',
artist: '小林',
url: 'https://example.com/audio.mp3',
cover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
}
],
likes: 12
},
{
id: 4,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
isPlaying: false,
audio: [],
likes: 12
},
]);
const themeVars = {
calendarSelectedDayBackground: '#4caf50'
}
const progress1 = ref(50);
const progress2 = ref(76);
const teamAvatars = ref([
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
])
// 图片预览相关
const showImagePreview = ref(false);
const startPosition = ref(0);
const currentPost = ref(null);
// 打开图片预览
const openImagePreview = (index, post) => {
currentPost.value = post;
startPosition.value = index;
showImagePreview.value = true;
}
// 图片切换事件处理
const onChange = (index) => {
startPosition.value = index;
}
const formatter = (day) => {
const month = day.date.getMonth() + 1;
const date = day.date.getDate();
let checkin_days = [1, 3, 5, 7];
if (month === 5) {
if (checkin_days.includes(date)) {
day.className = 'calendar-checkin';
day.type ='selected';
}
}
return day;
}
const onClickSubtitle = (evt) => {
console.warn('点击了日期标题');
}
</script>
<style lang="less">
.calendar-checkin {
.van-calendar__selected-day {
background: #a2d8a3 !important;
}
}
.text-wrapper {
padding: 1rem;
color: #4caf50;
.text-header {
font-size: 1.15rem;
}
.grade-percentage-main {
padding: 0.75rem 1rem;
}
.class-percentage-main {
padding: 0.75rem 1rem;
}
}
</style>
.post-card {
margin: 1rem 0;
padding: 1rem;
background-color: #FFF;
border-radius: 5px;
.post-header {
margin-bottom: 1rem;
}
.user-info {
margin-left: 0.5rem;
.username {
font-weight: 500;
}
.post-time {
color: gray;
font-size: 0.8rem;
}
}
.post-content {
.post-text {
margin-bottom: 1rem;
}
.post-media {
.post-images {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.post-video {
margin: 1rem 0;
width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-audio {
margin: 1rem 0;
}
}
}
.post-footer {
margin-top: 1rem;
color: #666;
.like-icon {
margin-right: 0.25rem;
}
.like-count {
font-size: 0.9rem;
}
}
}