hookehuyr

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

refactor(视图): 使用CheckinCard组件替换重复的帖子卡片代码
fix(播放器): 修复VideoPlayer和AudioPlayer的禁用状态和错误处理
style(样式): 调整postCountModel的底部边距
docs(类型): 更新组件类型声明文件
...@@ -13,6 +13,7 @@ declare module 'vue' { ...@@ -13,6 +13,7 @@ declare module 'vue' {
13 AppLayout: typeof import('./components/layout/AppLayout.vue')['default'] 13 AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
14 AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default'] 14 AudioPlayer: typeof import('./components/ui/AudioPlayer.vue')['default']
15 BottomNav: typeof import('./components/layout/BottomNav.vue')['default'] 15 BottomNav: typeof import('./components/layout/BottomNav.vue')['default']
16 + CheckinCard: typeof import('./components/checkin/CheckinCard.vue')['default']
16 CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default'] 17 CheckInDialog: typeof import('./components/ui/CheckInDialog.vue')['default']
17 CheckInList: typeof import('./components/ui/CheckInList.vue')['default'] 18 CheckInList: typeof import('./components/ui/CheckInList.vue')['default']
18 CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default'] 19 CollapsibleCalendar: typeof import('./components/ui/CollapsibleCalendar.vue')['default']
......
1 +<template>
2 + <div class="post-card shadow-md">
3 + <!-- Header -->
4 + <div class="post-header">
5 + <van-row>
6 + <van-col span="4">
7 + <van-image round width="2.5rem" height="2.5rem"
8 + :src="getOptimizedUrl(post.user.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg')"
9 + fit="cover" />
10 + </van-col>
11 + <van-col span="17">
12 + <div class="user-info">
13 + <div class="username">
14 + {{ post.user.name }}
15 + <!-- Makeup Tag -->
16 + <span v-if="post.user.is_makeup"
17 + 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>
18 + <slot name="header-tags"></slot>
19 + </div>
20 + <div class="post-time">{{ post.user.time }}</div>
21 + </div>
22 + </van-col>
23 + <van-col span="3">
24 + <div v-if="post.is_my" class="post-menu">
25 + <slot name="menu">
26 + <!-- Default menu items if needed, or left empty for parent to fill -->
27 + <van-icon name="edit" @click="emit('edit', post)" class="mr-2" />
28 + <van-icon name="delete-o" @click="emit('delete', post)" />
29 + </slot>
30 + </div>
31 + <slot name="header-right" v-else></slot>
32 + </van-col>
33 + </van-row>
34 + </div>
35 +
36 + <!-- Content -->
37 + <div class="post-content">
38 + <slot name="content-top"></slot>
39 + <PostCountModel :post-data="post" />
40 + <div class="post-text">{{ post.content }}</div>
41 +
42 + <!-- Media -->
43 + <div class="post-media">
44 + <!-- Images -->
45 + <div v-if="post.images.length" class="post-images">
46 + <van-image width="30%" fit="cover" v-for="(image, index) in post.images" :key="index"
47 + :src="getOptimizedUrl(image)" radius="5" @click="openImagePreview(index)" />
48 + </div>
49 + <van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images"
50 + :start-position="localStartPosition" :show-index="true" />
51 +
52 + <!-- Videos -->
53 + <div v-for="(v, idx) in post.videoList" :key="idx">
54 + <!-- Cover -->
55 + <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
56 + style="aspect-ratio: 16/9; margin-bottom: 1rem;">
57 + <img :src="getOptimizedUrl(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')"
58 + :alt="post.content" class="w-full h-full object-cover" />
59 + <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
60 + @click="startPlay(v)">
61 + <div
62 + class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
63 + <van-icon name="play-circle-o" class="text-white" size="40" />
64 + </div>
65 + </div>
66 + </div>
67 + <!-- Video Player -->
68 + <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
69 + :video-id="v.id || `video-${post.id}-${idx}`" class="post-video rounded-lg overflow-hidden"
70 + :ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)"
71 + @onPause="handleVideoPause" />
72 + </div>
73 +
74 + <!-- Audio -->
75 + <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio" :id="post.id"
76 + :ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" />
77 + </div>
78 + </div>
79 +
80 + <!-- Footer -->
81 + <div class="post-footer flex items-center justify-between">
82 + <!-- Left: Like -->
83 + <div class="flex items-center">
84 + <van-icon @click="emit('like', post)" name="good-job" class="like-icon"
85 + :color="post.is_liked ? 'red' : ''" />
86 + <span class="like-count ml-1">{{ post.likes }}</span>
87 + </div>
88 + <!-- Right: Custom Actions -->
89 + <div class="flex items-center">
90 + <slot name="footer-right"></slot>
91 + </div>
92 + </div>
93 + </div>
94 +</template>
95 +
96 +<script setup>
97 +import { ref } from 'vue'
98 +import PostCountModel from "@/components/count/postCountModel.vue";
99 +import VideoPlayer from "@/components/ui/VideoPlayer.vue";
100 +import AudioPlayer from "@/components/ui/AudioPlayer.vue";
101 +
102 +const props = defineProps({
103 + post: { type: Object, required: true },
104 + useCdnOptimization: { type: Boolean, default: false }
105 +})
106 +
107 +const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play'])
108 +
109 +// Image Preview State
110 +const showLocalImagePreview = ref(false)
111 +const localStartPosition = ref(0)
112 +
113 +const openImagePreview = (index) => {
114 + localStartPosition.value = index
115 + showLocalImagePreview.value = true
116 +}
117 +
118 +// Media Refs
119 +const videoRefs = ref(new Map())
120 +const audioRefs = ref(new Map())
121 +
122 +const setVideoRef = (el, id) => {
123 + if (el) {
124 + videoRefs.value.set(id, el)
125 + } else {
126 + videoRefs.value.delete(id)
127 + }
128 +}
129 +const setAudioRef = (el, id) => {
130 + if (el) {
131 + audioRefs.value.set(id, el)
132 + } else {
133 + audioRefs.value.delete(id)
134 + }
135 +}
136 +
137 +// Optimization
138 +const getOptimizedUrl = (url) => {
139 + if (!props.useCdnOptimization) return url
140 + if (!url || !url.includes('cdn.ipadbiz.cn')) return url
141 + if (url.includes('?')) return url
142 + return `${url}?imageMogr2/thumbnail/200x/strip/quality/70`
143 +}
144 +
145 +// Video Logic
146 +const startPlay = (v) => {
147 + // Pause other videos in this card
148 + props.post.videoList.forEach(item => {
149 + if (item.id !== v.id) item.isPlaying = false
150 + })
151 + v.isPlaying = true
152 +}
153 +
154 +const handleVideoPlay = (player, v) => {
155 + // Stop local audio
156 + stopLocalAudio()
157 + // Emit to parent to stop other cards
158 + emit('video-play', { post: props.post, player, videoId: v.id })
159 +}
160 +
161 +const handleVideoPause = () => {
162 + // do nothing
163 +}
164 +
165 +// Audio Logic
166 +const handleAudioPlay = (player) => {
167 + // Stop local videos
168 + stopLocalVideos()
169 + // Emit to parent
170 + emit('audio-play', { post: props.post, player })
171 +}
172 +
173 +const stopLocalVideos = () => {
174 + videoRefs.value.forEach(player => {
175 + if (player && typeof player.pause === 'function') {
176 + player.pause()
177 + }
178 + })
179 + props.post.videoList.forEach(v => v.isPlaying = false)
180 +}
181 +
182 +const stopLocalAudio = () => {
183 + audioRefs.value.forEach(player => {
184 + if (player && typeof player.pause === 'function') {
185 + player.pause()
186 + }
187 + })
188 + // Also update isPlaying state if AudioPlayer doesn't handle it fully self-contained
189 + // But AudioPlayer usually manages its own state or we don't track audio isPlaying on post object for audio lists?
190 + // Looking at IndexCheckInPage: post.audio is array. AudioPlayer takes songs.
191 + // So we just pause the player component.
192 +}
193 +
194 +// Expose methods for parent
195 +defineExpose({
196 + stopAllMedia: () => {
197 + stopLocalVideos()
198 + stopLocalAudio()
199 + },
200 + // Also expose post id for convenience
201 + id: props.post.id
202 +})
203 +</script>
204 +
205 +<style lang="less" scoped>
206 +.post-card {
207 + background: #fff;
208 + border-radius: 10px;
209 + padding: 1rem;
210 + margin-bottom: 1rem;
211 +
212 + .post-header {
213 + margin-bottom: 0.5rem;
214 +
215 + .user-info {
216 + display: flex;
217 + flex-direction: column;
218 + justify-content: center;
219 + height: 2.5rem;
220 + margin-left: 0.5rem;
221 +
222 + .username {
223 + font-weight: bold;
224 + font-size: 0.95rem;
225 + display: flex;
226 + align-items: center;
227 + }
228 +
229 + .post-time {
230 + font-size: 0.75rem;
231 + color: #999;
232 + }
233 + }
234 +
235 + .post-menu {
236 + display: flex;
237 + justify-content: flex-end;
238 + font-size: 1.2rem;
239 + color: #999;
240 + }
241 + }
242 +
243 + .post-content {
244 + .post-text {
245 + color: #666;
246 + margin-bottom: 1rem;
247 + white-space: pre-wrap;
248 + word-wrap: break-word;
249 + }
250 +
251 + .post-media {
252 + .post-images {
253 + display: flex;
254 + flex-wrap: wrap;
255 + gap: 0.5rem;
256 + }
257 +
258 + .post-video {
259 + margin: 1rem 0;
260 + width: 100%;
261 + border-radius: 8px;
262 + overflow: hidden;
263 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
264 + }
265 +
266 + .post-audio {
267 + margin: 1rem 0;
268 + }
269 + }
270 + }
271 +
272 + .post-footer {
273 + margin-top: 1rem;
274 + color: #666;
275 +
276 + .like-icon {
277 + margin-right: 0.25rem;
278 + }
279 +
280 + .like-count {
281 + font-size: 0.9rem;
282 + }
283 + }
284 +}
285 +</style>
1 <!-- 1 <!--
2 * @Date: 2025-12-11 17:26:25 2 * @Date: 2025-12-11 17:26:25
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-12-11 20:19:04 4 + * @LastEditTime: 2025-12-11 20:49:09
5 * @FilePath: /mlaj/src/components/count/postCountModel.vue 5 * @FilePath: /mlaj/src/components/count/postCountModel.vue
6 * @Description: 发布作业统计模型 6 * @Description: 发布作业统计模型
7 --> 7 -->
...@@ -45,5 +45,6 @@ const countData = ref({ ...@@ -45,5 +45,6 @@ const countData = ref({
45 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 45 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
46 border-radius: 10px; 46 border-radius: 10px;
47 font-size: 0.85rem; 47 font-size: 0.85rem;
48 + margin-bottom: 1rem;
48 } 49 }
49 </style> 50 </style>
......
...@@ -48,6 +48,8 @@ ...@@ -48,6 +48,8 @@
48 <div class="flex items-center space-x-12 mt-4" style="justify-content: space-evenly;"> 48 <div class="flex items-center space-x-12 mt-4" style="justify-content: space-evenly;">
49 <button 49 <button
50 @click="prevSong" 50 @click="prevSong"
51 + :disabled="songs.length <= 1"
52 + :class="{'opacity-50 cursor-not-allowed': songs.length <= 1}"
51 class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors" 53 class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
52 > 54 >
53 <font-awesome-icon icon="backward-step" class="text-xl text-gray-600" /> 55 <font-awesome-icon icon="backward-step" class="text-xl text-gray-600" />
...@@ -66,6 +68,8 @@ ...@@ -66,6 +68,8 @@
66 </button> 68 </button>
67 <button 69 <button
68 @click="nextSong" 70 @click="nextSong"
71 + :disabled="songs.length <= 1"
72 + :class="{'opacity-50 cursor-not-allowed': songs.length <= 1}"
69 class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors" 73 class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
70 > 74 >
71 <font-awesome-icon icon="forward-step" class="text-xl text-gray-600" /> 75 <font-awesome-icon icon="forward-step" class="text-xl text-gray-600" />
......
...@@ -249,15 +249,23 @@ onBeforeUnmount(() => { ...@@ -249,15 +249,23 @@ onBeforeUnmount(() => {
249 249
250 defineExpose({ 250 defineExpose({
251 pause() { 251 pause() {
252 - if (player.value && typeof player.value.pause === 'function') { 252 + if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') {
253 - player.value.pause(); 253 + try {
254 - emit('onPause', player.value); 254 + player.value.pause();
255 + emit('onPause', player.value);
256 + } catch (e) {
257 + console.warn('Video pause error:', e);
258 + }
255 } 259 }
256 }, 260 },
257 play() { 261 play() {
258 - if (player.value) { 262 + if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed()) {
259 - player.value?.play(); 263 + try {
260 - emit('onPlay', player.value); 264 + player.value?.play();
265 + emit('onPlay', player.value);
266 + } catch (e) {
267 + console.warn('Video play error:', e);
268 + }
261 } 269 }
262 }, 270 },
263 getPlayer() { 271 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.