hookehuyr

feat(checkin): 启用多附件功能并添加文本折叠与媒体标签页

- 在环境变量中启用多附件功能,支持图片、视频和音频的标签页切换
- 为打卡卡片添加文本内容折叠/展开功能,当文本溢出时显示"全文/收起"按钮
- 重构媒体展示逻辑,当存在多种媒体类型时使用标签页组织,单一类型时保持原有布局
- 添加单元测试确保组件功能正确性,配置Vitest测试环境
- 更新TypeScript类型定义和开发依赖以支持测试
...@@ -17,7 +17,7 @@ VITE_CONSOLE = 0 ...@@ -17,7 +17,7 @@ VITE_CONSOLE = 0
17 VITE_APPID=微信appID 17 VITE_APPID=微信appID
18 18
19 # 是否开启多附件功能 19 # 是否开启多附件功能
20 -VITE_CHECKIN_MULTI_ATTACHMENT = 0 20 +VITE_CHECKIN_MULTI_ATTACHMENT = 1
21 21
22 # 是否开启打卡草稿缓存功能 22 # 是否开启打卡草稿缓存功能
23 VITE_CHECKIN_DRAFT_CACHE = 0 23 VITE_CHECKIN_DRAFT_CACHE = 0
......
This diff could not be displayed because it is too large.
...@@ -58,9 +58,11 @@ ...@@ -58,9 +58,11 @@
58 "devDependencies": { 58 "devDependencies": {
59 "@vitejs/plugin-vue": "^5.2.1", 59 "@vitejs/plugin-vue": "^5.2.1",
60 "@vitejs/plugin-vue-jsx": "^4.1.2", 60 "@vitejs/plugin-vue-jsx": "^4.1.2",
61 + "@vue/test-utils": "^2.4.6",
61 "@vueuse/core": "^13.0.0", 62 "@vueuse/core": "^13.0.0",
62 "autoprefixer": "^10.4.19", 63 "autoprefixer": "^10.4.19",
63 "axios": "^1.8.4", 64 "axios": "^1.8.4",
65 + "jsdom": "^24.1.3",
64 "less": "^4.2.2", 66 "less": "^4.2.2",
65 "postcss": "^8.4.35", 67 "postcss": "^8.4.35",
66 "qs": "^6.14.0", 68 "qs": "^6.14.0",
......
...@@ -71,6 +71,6 @@ declare global { ...@@ -71,6 +71,6 @@ declare global {
71 // for type re-export 71 // for type re-export
72 declare global { 72 declare global {
73 // @ts-ignore 73 // @ts-ignore
74 - export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 74 + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
75 import('vue') 75 import('vue')
76 } 76 }
......
...@@ -37,21 +37,84 @@ ...@@ -37,21 +37,84 @@
37 <div class="post-content"> 37 <div class="post-content">
38 <slot name="content-top"></slot> 38 <slot name="content-top"></slot>
39 <PostCountModel :post-data="post" /> 39 <PostCountModel :post-data="post" />
40 - <div class="post-text">{{ post.content }}</div> 40 + <div class="post-text-wrapper relative">
41 + <div ref="textRef" class="post-text" :class="{ 'line-clamp-5': !isExpanded }">
42 + {{ post.content }}
43 + </div>
44 + <div v-if="showExpandBtn" class="expand-btn text-blue-500 text-sm mt-1 cursor-pointer"
45 + @click.stop="toggleExpand">
46 + {{ isExpanded ? '收起' : '全文' }}
47 + </div>
48 + </div>
41 49
42 <!-- 媒体内容 --> 50 <!-- 媒体内容 -->
43 - <div class="post-media"> 51 + <div class="post-media mt-2">
52 + <!-- 多附件Tab模式 -->
53 + <div v-if="mediaTabs.length > 1" class="media-tabs">
54 + <van-tabs v-model:active="activeTab" shrink animated swipeable color="#4caf50">
55 + <van-tab v-for="tab in mediaTabs" :key="tab.name" :title="tab.label" :name="tab.name">
56 + <div class="pt-2">
57 + <!-- 图片内容 -->
58 + <template v-if="tab.name === 'image'">
59 + <div class="post-images">
60 + <div class="post-image-item" v-for="(image, index) in post.images" :key="index">
61 + <van-image width="100%" height="100%" fit="cover"
62 + :src="getOptimizedUrl(image)" radius="5"
63 + @click="openImagePreview(index)" />
64 + </div>
65 + </div>
66 + </template>
67 +
68 + <!-- 视频内容 -->
69 + <template v-if="tab.name === 'video'">
70 + <div v-for="(v, idx) in post.videoList" :key="idx">
71 + <!-- 封面图 -->
72 + <div v-if="v.video && !v.isPlaying"
73 + class="relative w-full rounded-lg overflow-hidden"
74 + style="aspect-ratio: 16/9; margin-bottom: 1rem;">
75 + <img :src="getOptimizedUrl(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')"
76 + :alt="post.content" class="w-full h-full object-contain" />
77 + <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
78 + @click="startPlay(v)">
79 + <div
80 + class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
81 + <van-icon name="play-circle-o" class="text-white" size="40" />
82 + </div>
83 + </div>
84 + </div>
85 + <!-- 视频播放器 -->
86 + <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
87 + :video-id="v.id || `video-${post.id}-${idx}`"
88 + :use-native-on-ios="false" class="post-video rounded-lg overflow-hidden"
89 + :ref="(el) => setVideoRef(el, v.id)"
90 + @onPlay="(player) => handleVideoPlay(player, v)"
91 + @onPause="handleVideoPause" />
92 + </div>
93 + </template>
94 +
95 + <!-- 音频内容 -->
96 + <template v-if="tab.name === 'audio'">
97 + <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio"
98 + class="post-audio" :id="post.id" :ref="(el) => setAudioRef(el, post.id)"
99 + @play="handleAudioPlay" />
100 + </template>
101 + </div>
102 + </van-tab>
103 + </van-tabs>
104 + </div>
105 +
106 + <!-- 单一附件模式 (保持原有布局) -->
107 + <div v-else>
44 <!-- 图片列表 --> 108 <!-- 图片列表 -->
45 - <div v-if="post.images.length" class="post-images"> 109 + <div v-if="post.images && post.images.length" class="post-images">
46 <div class="post-image-item" v-for="(image, index) in post.images" :key="index"> 110 <div class="post-image-item" v-for="(image, index) in post.images" :key="index">
47 <van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5" 111 <van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5"
48 @click="openImagePreview(index)" /> 112 @click="openImagePreview(index)" />
49 </div> 113 </div>
50 </div> 114 </div>
51 - <van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images"
52 - :start-position="localStartPosition" :show-index="true" />
53 115
54 <!-- 视频列表 --> 116 <!-- 视频列表 -->
117 + <div v-if="post.videoList && post.videoList.length">
55 <div v-for="(v, idx) in post.videoList" :key="idx"> 118 <div v-for="(v, idx) in post.videoList" :key="idx">
56 <!-- 封面图 --> 119 <!-- 封面图 -->
57 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden" 120 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
...@@ -68,14 +131,19 @@ ...@@ -68,14 +131,19 @@
68 </div> 131 </div>
69 <!-- 视频播放器 --> 132 <!-- 视频播放器 -->
70 <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video" 133 <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
71 - :video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false" class="post-video rounded-lg overflow-hidden" 134 + :video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false"
72 - :ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)" 135 + class="post-video rounded-lg overflow-hidden" :ref="(el) => setVideoRef(el, v.id)"
73 - @onPause="handleVideoPause" /> 136 + @onPlay="(player) => handleVideoPlay(player, v)" @onPause="handleVideoPause" />
137 + </div>
74 </div> 138 </div>
75 139
76 <!-- 音频播放器 --> 140 <!-- 音频播放器 -->
77 - <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio" :id="post.id" 141 + <AudioPlayer v-if="post.audio && post.audio.length" :songs="post.audio" class="post-audio"
78 - :ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" /> 142 + :id="post.id" :ref="(el) => setAudioRef(el, post.id)" @play="handleAudioPlay" />
143 + </div>
144 +
145 + <van-image-preview v-if="post.images && post.images.length" v-model:show="showLocalImagePreview"
146 + :images="post.images" :start-position="localStartPosition" :show-index="true" />
79 </div> 147 </div>
80 </div> 148 </div>
81 149
...@@ -96,7 +164,36 @@ ...@@ -96,7 +164,36 @@
96 </template> 164 </template>
97 165
98 <script setup> 166 <script setup>
99 -import { ref } from 'vue' 167 +/**
168 + * CheckinCard 打卡卡片组件
169 + * @description 展示用户打卡内容的卡片,支持文本折叠、多媒体(图片/视频/音频)展示、点赞及自定义操作。
170 + *
171 + * @example
172 + * <template>
173 + * <CheckinCard
174 + * :post="postData"
175 + * :use-cdn-optimization="true"
176 + * @like="handleLike"
177 + * @edit="handleEdit"
178 + * @delete="handleDelete"
179 + * />
180 + * </template>
181 + *
182 + * // Example data setup
183 + * const postData = {
184 + * id: 1,
185 + * user: { name: 'User', avatar: 'url', time: '2023-01-01' },
186 + * content: 'Post content...',
187 + * images: ['img1.jpg'],
188 + * videoList: [{ video: 'vid1.mp4', videoCover: 'cover.jpg' }],
189 + * audio: [],
190 + * likes: 10,
191 + * is_liked: false,
192 + * is_my: true
193 + * }
194 + * const handleLike = (post) => console.log('like', post)
195 + */
196 +import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
100 import PostCountModel from "@/components/count/postCountModel.vue"; 197 import PostCountModel from "@/components/count/postCountModel.vue";
101 import VideoPlayer from "@/components/media/VideoPlayer.vue"; 198 import VideoPlayer from "@/components/media/VideoPlayer.vue";
102 import AudioPlayer from "@/components/media/AudioPlayer.vue"; 199 import AudioPlayer from "@/components/media/AudioPlayer.vue";
...@@ -121,6 +218,57 @@ const props = defineProps({ ...@@ -121,6 +218,57 @@ const props = defineProps({
121 218
122 const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play']) 219 const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play'])
123 220
221 +// 文本折叠逻辑
222 +const isExpanded = ref(false)
223 +const showExpandBtn = ref(false)
224 +const textRef = ref(null)
225 +
226 +/**
227 + * @description 切换文本展开/收起状态
228 + */
229 +const toggleExpand = () => {
230 + isExpanded.value = !isExpanded.value
231 +}
232 +
233 +/**
234 + * @description 检查文本是否溢出
235 + */
236 +const checkTextOverflow = () => {
237 + if (textRef.value) {
238 + // 如果滚动高度大于客户区高度,说明有溢出(因为设置了 line-clamp)
239 + showExpandBtn.value = textRef.value.scrollHeight > textRef.value.clientHeight
240 + }
241 +}
242 +
243 +onMounted(() => {
244 + nextTick(() => {
245 + checkTextOverflow()
246 + })
247 +})
248 +
249 +watch(() => props.post.content, () => {
250 + isExpanded.value = false // Reset expansion on content change
251 + nextTick(() => {
252 + checkTextOverflow()
253 + })
254 +})
255 +
256 +// 多媒体Tab逻辑
257 +const activeTab = ref('')
258 +const mediaTabs = computed(() => {
259 + const tabs = []
260 + if (props.post.images && props.post.images.length) tabs.push({ label: '图片', name: 'image' })
261 + if (props.post.videoList && props.post.videoList.length) tabs.push({ label: '视频', name: 'video' })
262 + if (props.post.audio && props.post.audio.length) tabs.push({ label: '音频', name: 'audio' })
263 + return tabs
264 +})
265 +
266 +watchEffect(() => {
267 + if (mediaTabs.value.length > 0 && !activeTab.value) {
268 + activeTab.value = mediaTabs.value[0].name
269 + }
270 +})
271 +
124 // 图片预览状态 272 // 图片预览状态
125 const showLocalImagePreview = ref(false) 273 const showLocalImagePreview = ref(false)
126 const localStartPosition = ref(0) 274 const localStartPosition = ref(0)
...@@ -256,6 +404,14 @@ defineExpose({ ...@@ -256,6 +404,14 @@ defineExpose({
256 </script> 404 </script>
257 405
258 <style lang="less" scoped> 406 <style lang="less" scoped>
407 +.line-clamp-5 {
408 + display: -webkit-box;
409 + --webkit-box-orient: vertical;
410 + --webkit-line-clamp: 5;
411 + overflow: hidden;
412 + text-overflow: ellipsis;
413 +}
414 +
259 .post-card { 415 .post-card {
260 background: #fff; 416 background: #fff;
261 border-radius: 10px; 417 border-radius: 10px;
......
1 +import { mount } from '@vue/test-utils'
2 +import { describe, it, expect } from 'vitest'
3 +import CheckinCard from '../CheckinCard.vue'
4 +
5 +describe('CheckinCard.vue', () => {
6 + const defaultPost = {
7 + id: 1,
8 + user: { name: 'Test User', avatar: '' },
9 + content: 'Test Content',
10 + images: [],
11 + videoList: [],
12 + audio: [],
13 + likes: 0,
14 + is_liked: false,
15 + is_my: false
16 + }
17 +
18 + const globalStubs = {
19 + 'van-image': true,
20 + 'van-row': true,
21 + 'van-col': true,
22 + 'van-icon': true,
23 + 'van-image-preview': true,
24 + 'van-tabs': { template: '<div><slot /></div>' },
25 + 'van-tab': { name: 'van-tab', template: '<div><slot /></div>', props: ['title'] },
26 + 'PostCountModel': true,
27 + 'VideoPlayer': true,
28 + 'AudioPlayer': true
29 + }
30 +
31 + it('renders content correctly', () => {
32 + const wrapper = mount(CheckinCard, {
33 + props: { post: defaultPost },
34 + global: {
35 + stubs: globalStubs
36 + }
37 + })
38 + expect(wrapper.text()).toContain('Test Content')
39 + })
40 +
41 + it('shows expand button when content overflows', async () => {
42 + const wrapper = mount(CheckinCard, {
43 + props: { post: { ...defaultPost, content: 'Long content...' } },
44 + global: {
45 + stubs: globalStubs
46 + }
47 + })
48 +
49 + const textEl = wrapper.find('.post-text').element
50 + Object.defineProperty(textEl, 'scrollHeight', { value: 200, configurable: true })
51 + Object.defineProperty(textEl, 'clientHeight', { value: 100, configurable: true })
52 +
53 + await wrapper.setProps({ post: { ...defaultPost, content: 'Updated Long Content' } })
54 + await wrapper.vm.$nextTick()
55 + })
56 +
57 + it('shows tabs when multiple media types exist', () => {
58 + const postWithMultiMedia = {
59 + ...defaultPost,
60 + images: ['img1.jpg'],
61 + videoList: [{ id: 1, video: 'vid1.mp4' }],
62 + audio: []
63 + }
64 +
65 + const wrapper = mount(CheckinCard, {
66 + props: { post: postWithMultiMedia },
67 + global: {
68 + stubs: globalStubs
69 + }
70 + })
71 +
72 + const tabs = wrapper.findAllComponents({ name: 'van-tab' })
73 + expect(tabs.length).toBe(2)
74 + expect(tabs[0].props('title')).toBe('图片')
75 + expect(tabs[1].props('title')).toBe('视频')
76 + })
77 +
78 + it('does not show tabs when single media type exists', () => {
79 + const postWithSingleMedia = {
80 + ...defaultPost,
81 + images: ['img1.jpg'],
82 + videoList: [],
83 + audio: []
84 + }
85 +
86 + const wrapper = mount(CheckinCard, {
87 + props: { post: postWithSingleMedia },
88 + global: {
89 + stubs: globalStubs
90 + }
91 + })
92 +
93 + const tabs = wrapper.findAllComponents({ name: 'van-tab' })
94 + expect(tabs.length).toBe(0)
95 + expect(wrapper.find('.post-images').exists()).toBe(true)
96 + })
97 +})
1 <!-- 1 <!--
2 * @Date: 2025-05-29 15:34:17 2 * @Date: 2025-05-29 15:34:17
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-24 15:29:28 4 + * @LastEditTime: 2026-01-26 09:52:40
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 用户打卡主页 6 * @Description: 用户打卡主页
7 --> 7 -->
......
1 +/*
2 + * @Date: 2026-01-26 13:31:38
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-26 13:33:54
5 + * @FilePath: /mlaj/vitest.config.js
6 + * @Description: 文件描述
7 + */
8 +import { defineConfig } from 'vitest/config'
9 +import vue from '@vitejs/plugin-vue'
10 +import path from 'path'
11 +
12 +export default defineConfig({
13 + plugins: [vue()],
14 + resolve: {
15 + alias: {
16 + "@": path.resolve(__dirname, "src"),
17 + }
18 + },
19 + test: {
20 + environment: 'jsdom',
21 + css: {
22 + include: [],
23 + modules: {
24 + classNameStrategy: 'non-scoped'
25 + }
26 + },
27 + server: {
28 + deps: {
29 + inline: ['vant']
30 + }
31 + }
32 + }
33 +})
This diff could not be displayed because it is too large.