hookehuyr

feat(组件): 新增CheckinCard组件并重构多处使用

refactor(视图): 使用CheckinCard组件替换重复的帖子卡片代码
fix(播放器): 修复VideoPlayer和AudioPlayer的禁用状态和错误处理
style(样式): 调整postCountModel的底部边距
docs(类型): 更新组件类型声明文件
......@@ -13,6 +13,7 @@ declare module 'vue' {
AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default']
BottomNav: typeof import('./components/layout/BottomNav.vue')['default']
CheckinCard: typeof import('./components/checkin/CheckinCard.vue')['default']
CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
CheckInList: typeof import('./components/ui/CheckInList.vue')['default']
CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default']
......
<template>
<div class="post-card shadow-md">
<!-- Header -->
<div class="post-header">
<van-row>
<van-col span="4">
<van-image round width="2.5rem" height="2.5rem"
:src="getOptimizedUrl(post.user.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg')"
fit="cover" />
</van-col>
<van-col span="17">
<div class="user-info">
<div class="username">
{{ post.user.name }}
<!-- Makeup Tag -->
<span v-if="post.user.is_makeup"
class="MakeupTag inline-flex items-center ml-2 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-300">补打卡</span>
<slot name="header-tags"></slot>
</div>
<div class="post-time">{{ post.user.time }}</div>
</div>
</van-col>
<van-col span="3">
<div v-if="post.is_my" class="post-menu">
<slot name="menu">
<!-- Default menu items if needed, or left empty for parent to fill -->
<van-icon name="edit" @click="emit('edit', post)" class="mr-2" />
<van-icon name="delete-o" @click="emit('delete', post)" />
</slot>
</div>
<slot name="header-right" v-else></slot>
</van-col>
</van-row>
</div>
<!-- Content -->
<div class="post-content">
<slot name="content-top"></slot>
<PostCountModel :post-data="post" />
<div class="post-text">{{ post.content }}</div>
<!-- Media -->
<div class="post-media">
<!-- Images -->
<div v-if="post.images.length" class="post-images">
<van-image width="30%" fit="cover" v-for="(image, index) in post.images" :key="index"
:src="getOptimizedUrl(image)" radius="5" @click="openImagePreview(index)" />
</div>
<van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images"
:start-position="localStartPosition" :show-index="true" />
<!-- Videos -->
<div v-for="(v, idx) in post.videoList" :key="idx">
<!-- Cover -->
<div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
style="aspect-ratio: 16/9; margin-bottom: 1rem;">
<img :src="getOptimizedUrl(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')"
: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(v)">
<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="40" />
</div>
</div>
</div>
<!-- Video Player -->
<VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
:video-id="v.id || `video-${post.id}-${idx}`" class="post-video rounded-lg overflow-hidden"
:ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)"
@onPause="handleVideoPause" />
</div>
<!-- Audio -->
<AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio" :id="post.id"
:ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" />
</div>
</div>
<!-- Footer -->
<div class="post-footer flex items-center justify-between">
<!-- Left: Like -->
<div class="flex items-center">
<van-icon @click="emit('like', post)" name="good-job" class="like-icon"
:color="post.is_liked ? 'red' : ''" />
<span class="like-count ml-1">{{ post.likes }}</span>
</div>
<!-- Right: Custom Actions -->
<div class="flex items-center">
<slot name="footer-right"></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PostCountModel from "@/components/count/postCountModel.vue";
import VideoPlayer from "@/components/ui/VideoPlayer.vue";
import AudioPlayer from "@/components/ui/AudioPlayer.vue";
const props = defineProps({
post: { type: Object, required: true },
useCdnOptimization: { type: Boolean, default: false }
})
const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play'])
// Image Preview State
const showLocalImagePreview = ref(false)
const localStartPosition = ref(0)
const openImagePreview = (index) => {
localStartPosition.value = index
showLocalImagePreview.value = true
}
// Media Refs
const videoRefs = ref(new Map())
const audioRefs = ref(new Map())
const setVideoRef = (el, id) => {
if (el) {
videoRefs.value.set(id, el)
} else {
videoRefs.value.delete(id)
}
}
const setAudioRef = (el, id) => {
if (el) {
audioRefs.value.set(id, el)
} else {
audioRefs.value.delete(id)
}
}
// Optimization
const getOptimizedUrl = (url) => {
if (!props.useCdnOptimization) return url
if (!url || !url.includes('cdn.ipadbiz.cn')) return url
if (url.includes('?')) return url
return `${url}?imageMogr2/thumbnail/200x/strip/quality/70`
}
// Video Logic
const startPlay = (v) => {
// Pause other videos in this card
props.post.videoList.forEach(item => {
if (item.id !== v.id) item.isPlaying = false
})
v.isPlaying = true
}
const handleVideoPlay = (player, v) => {
// Stop local audio
stopLocalAudio()
// Emit to parent to stop other cards
emit('video-play', { post: props.post, player, videoId: v.id })
}
const handleVideoPause = () => {
// do nothing
}
// Audio Logic
const handleAudioPlay = (player) => {
// Stop local videos
stopLocalVideos()
// Emit to parent
emit('audio-play', { post: props.post, player })
}
const stopLocalVideos = () => {
videoRefs.value.forEach(player => {
if (player && typeof player.pause === 'function') {
player.pause()
}
})
props.post.videoList.forEach(v => v.isPlaying = false)
}
const stopLocalAudio = () => {
audioRefs.value.forEach(player => {
if (player && typeof player.pause === 'function') {
player.pause()
}
})
// Also update isPlaying state if AudioPlayer doesn't handle it fully self-contained
// But AudioPlayer usually manages its own state or we don't track audio isPlaying on post object for audio lists?
// Looking at IndexCheckInPage: post.audio is array. AudioPlayer takes songs.
// So we just pause the player component.
}
// Expose methods for parent
defineExpose({
stopAllMedia: () => {
stopLocalVideos()
stopLocalAudio()
},
// Also expose post id for convenience
id: props.post.id
})
</script>
<style lang="less" scoped>
.post-card {
background: #fff;
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
.post-header {
margin-bottom: 0.5rem;
.user-info {
display: flex;
flex-direction: column;
justify-content: center;
height: 2.5rem;
margin-left: 0.5rem;
.username {
font-weight: bold;
font-size: 0.95rem;
display: flex;
align-items: center;
}
.post-time {
font-size: 0.75rem;
color: #999;
}
}
.post-menu {
display: flex;
justify-content: flex-end;
font-size: 1.2rem;
color: #999;
}
}
.post-content {
.post-text {
color: #666;
margin-bottom: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.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;
}
}
}
</style>
<!--
* @Date: 2025-12-11 17:26:25
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-11 20:19:04
* @LastEditTime: 2025-12-11 20:49:09
* @FilePath: /mlaj/src/components/count/postCountModel.vue
* @Description: 发布作业统计模型
-->
......@@ -45,5 +45,6 @@ const countData = ref({
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-radius: 10px;
font-size: 0.85rem;
margin-bottom: 1rem;
}
</style>
......
......@@ -48,6 +48,8 @@
<div class="flex items-center space-x-12 mt-4" style="justify-content: space-evenly;">
<button
@click="prevSong"
:disabled="songs.length <= 1"
:class="{'opacity-50 cursor-not-allowed': songs.length <= 1}"
class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
>
<font-awesome-icon icon="backward-step" class="text-xl text-gray-600" />
......@@ -66,6 +68,8 @@
</button>
<button
@click="nextSong"
:disabled="songs.length <= 1"
:class="{'opacity-50 cursor-not-allowed': songs.length <= 1}"
class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
>
<font-awesome-icon icon="forward-step" class="text-xl text-gray-600" />
......
......@@ -249,15 +249,23 @@ onBeforeUnmount(() => {
defineExpose({
pause() {
if (player.value && typeof player.value.pause === 'function') {
if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') {
try {
player.value.pause();
emit('onPause', player.value);
} catch (e) {
console.warn('Video pause error:', e);
}
}
},
play() {
if (player.value) {
if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed()) {
try {
player.value?.play();
emit('onPlay', player.value);
} catch (e) {
console.warn('Video play error:', e);
}
}
},
getPlayer() {
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.