hookehuyr

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

- 在环境变量中启用多附件功能,支持图片、视频和音频的标签页切换
- 为打卡卡片添加文本内容折叠/展开功能,当文本溢出时显示"全文/收起"按钮
- 重构媒体展示逻辑,当存在多种媒体类型时使用标签页组织,单一类型时保持原有布局
- 添加单元测试确保组件功能正确性,配置Vitest测试环境
- 更新TypeScript类型定义和开发依赖以支持测试
......@@ -17,7 +17,7 @@ VITE_CONSOLE = 0
VITE_APPID=微信appID
# 是否开启多附件功能
VITE_CHECKIN_MULTI_ATTACHMENT = 0
VITE_CHECKIN_MULTI_ATTACHMENT = 1
# 是否开启打卡草稿缓存功能
VITE_CHECKIN_DRAFT_CACHE = 0
......
This diff could not be displayed because it is too large.
......@@ -58,9 +58,11 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^13.0.0",
"autoprefixer": "^10.4.19",
"axios": "^1.8.4",
"jsdom": "^24.1.3",
"less": "^4.2.2",
"postcss": "^8.4.35",
"qs": "^6.14.0",
......
......@@ -71,6 +71,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
......
......@@ -37,45 +37,113 @@
<div class="post-content">
<slot name="content-top"></slot>
<PostCountModel :post-data="post" />
<div class="post-text">{{ post.content }}</div>
<div class="post-text-wrapper relative">
<div ref="textRef" class="post-text" :class="{ 'line-clamp-5': !isExpanded }">
{{ post.content }}
</div>
<div v-if="showExpandBtn" class="expand-btn text-blue-500 text-sm mt-1 cursor-pointer"
@click.stop="toggleExpand">
{{ isExpanded ? '收起' : '全文' }}
</div>
</div>
<!-- 媒体内容 -->
<div class="post-media">
<!-- 图片列表 -->
<div v-if="post.images.length" class="post-images">
<div class="post-image-item" v-for="(image, index) in post.images" :key="index">
<van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5"
@click="openImagePreview(index)" />
</div>
<div class="post-media mt-2">
<!-- 多附件Tab模式 -->
<div v-if="mediaTabs.length > 1" class="media-tabs">
<van-tabs v-model:active="activeTab" shrink animated swipeable color="#4caf50">
<van-tab v-for="tab in mediaTabs" :key="tab.name" :title="tab.label" :name="tab.name">
<div class="pt-2">
<!-- 图片内容 -->
<template v-if="tab.name === 'image'">
<div class="post-images">
<div class="post-image-item" v-for="(image, index) in post.images" :key="index">
<van-image width="100%" height="100%" fit="cover"
:src="getOptimizedUrl(image)" radius="5"
@click="openImagePreview(index)" />
</div>
</div>
</template>
<!-- 视频内容 -->
<template v-if="tab.name === 'video'">
<div v-for="(v, idx) in post.videoList" :key="idx">
<!-- 封面图 -->
<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-contain" />
<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>
<!-- 视频播放器 -->
<VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
:video-id="v.id || `video-${post.id}-${idx}`"
:use-native-on-ios="false" class="post-video rounded-lg overflow-hidden"
:ref="(el) => setVideoRef(el, v.id)"
@onPlay="(player) => handleVideoPlay(player, v)"
@onPause="handleVideoPause" />
</div>
</template>
<!-- 音频内容 -->
<template v-if="tab.name === '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" />
</template>
</div>
</van-tab>
</van-tabs>
</div>
<van-image-preview v-if="post.images.length" v-model:show="showLocalImagePreview" :images="post.images"
:start-position="localStartPosition" :show-index="true" />
<!-- 视频列表 -->
<div v-for="(v, idx) in post.videoList" :key="idx">
<!-- 封面图 -->
<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-contain" />
<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 v-else>
<!-- 图片列表 -->
<div v-if="post.images && post.images.length" class="post-images">
<div class="post-image-item" v-for="(image, index) in post.images" :key="index">
<van-image width="100%" height="100%" fit="cover" :src="getOptimizedUrl(image)" radius="5"
@click="openImagePreview(index)" />
</div>
</div>
<!-- 视频列表 -->
<div v-if="post.videoList && post.videoList.length">
<div v-for="(v, idx) in post.videoList" :key="idx">
<!-- 封面图 -->
<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-contain" />
<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>
<!-- 视频播放器 -->
<VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
:video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false"
class="post-video rounded-lg overflow-hidden" :ref="(el) => setVideoRef(el, v.id)"
@onPlay="(player) => handleVideoPlay(player, v)" @onPause="handleVideoPause" />
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
:video-id="v.id || `video-${post.id}-${idx}`" :use-native-on-ios="false" class="post-video rounded-lg overflow-hidden"
:ref="(el) => setVideoRef(el, v.id)" @onPlay="(player) => handleVideoPlay(player, v)"
@onPause="handleVideoPause" />
<!-- 音频播放器 -->
<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>
<!-- 音频播放器 -->
<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" />
<van-image-preview v-if="post.images && post.images.length" v-model:show="showLocalImagePreview"
:images="post.images" :start-position="localStartPosition" :show-index="true" />
</div>
</div>
......@@ -96,7 +164,36 @@
</template>
<script setup>
import { ref } from 'vue'
/**
* CheckinCard 打卡卡片组件
* @description 展示用户打卡内容的卡片,支持文本折叠、多媒体(图片/视频/音频)展示、点赞及自定义操作。
*
* @example
* <template>
* <CheckinCard
* :post="postData"
* :use-cdn-optimization="true"
* @like="handleLike"
* @edit="handleEdit"
* @delete="handleDelete"
* />
* </template>
*
* // Example data setup
* const postData = {
* id: 1,
* user: { name: 'User', avatar: 'url', time: '2023-01-01' },
* content: 'Post content...',
* images: ['img1.jpg'],
* videoList: [{ video: 'vid1.mp4', videoCover: 'cover.jpg' }],
* audio: [],
* likes: 10,
* is_liked: false,
* is_my: true
* }
* const handleLike = (post) => console.log('like', post)
*/
import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
import PostCountModel from "@/components/count/postCountModel.vue";
import VideoPlayer from "@/components/media/VideoPlayer.vue";
import AudioPlayer from "@/components/media/AudioPlayer.vue";
......@@ -121,6 +218,57 @@ const props = defineProps({
const emit = defineEmits(['like', 'edit', 'delete', 'video-play', 'audio-play'])
// 文本折叠逻辑
const isExpanded = ref(false)
const showExpandBtn = ref(false)
const textRef = ref(null)
/**
* @description 切换文本展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
/**
* @description 检查文本是否溢出
*/
const checkTextOverflow = () => {
if (textRef.value) {
// 如果滚动高度大于客户区高度,说明有溢出(因为设置了 line-clamp)
showExpandBtn.value = textRef.value.scrollHeight > textRef.value.clientHeight
}
}
onMounted(() => {
nextTick(() => {
checkTextOverflow()
})
})
watch(() => props.post.content, () => {
isExpanded.value = false // Reset expansion on content change
nextTick(() => {
checkTextOverflow()
})
})
// 多媒体Tab逻辑
const activeTab = ref('')
const mediaTabs = computed(() => {
const tabs = []
if (props.post.images && props.post.images.length) tabs.push({ label: '图片', name: 'image' })
if (props.post.videoList && props.post.videoList.length) tabs.push({ label: '视频', name: 'video' })
if (props.post.audio && props.post.audio.length) tabs.push({ label: '音频', name: 'audio' })
return tabs
})
watchEffect(() => {
if (mediaTabs.value.length > 0 && !activeTab.value) {
activeTab.value = mediaTabs.value[0].name
}
})
// 图片预览状态
const showLocalImagePreview = ref(false)
const localStartPosition = ref(0)
......@@ -256,6 +404,14 @@ defineExpose({
</script>
<style lang="less" scoped>
.line-clamp-5 {
display: -webkit-box;
--webkit-box-orient: vertical;
--webkit-line-clamp: 5;
overflow: hidden;
text-overflow: ellipsis;
}
.post-card {
background: #fff;
border-radius: 10px;
......
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import CheckinCard from '../CheckinCard.vue'
describe('CheckinCard.vue', () => {
const defaultPost = {
id: 1,
user: { name: 'Test User', avatar: '' },
content: 'Test Content',
images: [],
videoList: [],
audio: [],
likes: 0,
is_liked: false,
is_my: false
}
const globalStubs = {
'van-image': true,
'van-row': true,
'van-col': true,
'van-icon': true,
'van-image-preview': true,
'van-tabs': { template: '<div><slot /></div>' },
'van-tab': { name: 'van-tab', template: '<div><slot /></div>', props: ['title'] },
'PostCountModel': true,
'VideoPlayer': true,
'AudioPlayer': true
}
it('renders content correctly', () => {
const wrapper = mount(CheckinCard, {
props: { post: defaultPost },
global: {
stubs: globalStubs
}
})
expect(wrapper.text()).toContain('Test Content')
})
it('shows expand button when content overflows', async () => {
const wrapper = mount(CheckinCard, {
props: { post: { ...defaultPost, content: 'Long content...' } },
global: {
stubs: globalStubs
}
})
const textEl = wrapper.find('.post-text').element
Object.defineProperty(textEl, 'scrollHeight', { value: 200, configurable: true })
Object.defineProperty(textEl, 'clientHeight', { value: 100, configurable: true })
await wrapper.setProps({ post: { ...defaultPost, content: 'Updated Long Content' } })
await wrapper.vm.$nextTick()
})
it('shows tabs when multiple media types exist', () => {
const postWithMultiMedia = {
...defaultPost,
images: ['img1.jpg'],
videoList: [{ id: 1, video: 'vid1.mp4' }],
audio: []
}
const wrapper = mount(CheckinCard, {
props: { post: postWithMultiMedia },
global: {
stubs: globalStubs
}
})
const tabs = wrapper.findAllComponents({ name: 'van-tab' })
expect(tabs.length).toBe(2)
expect(tabs[0].props('title')).toBe('图片')
expect(tabs[1].props('title')).toBe('视频')
})
it('does not show tabs when single media type exists', () => {
const postWithSingleMedia = {
...defaultPost,
images: ['img1.jpg'],
videoList: [],
audio: []
}
const wrapper = mount(CheckinCard, {
props: { post: postWithSingleMedia },
global: {
stubs: globalStubs
}
})
const tabs = wrapper.findAllComponents({ name: 'van-tab' })
expect(tabs.length).toBe(0)
expect(wrapper.find('.post-images').exists()).toBe(true)
})
})
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 15:29:28
* @LastEditTime: 2026-01-26 09:52:40
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 用户打卡主页
-->
......
/*
* @Date: 2026-01-26 13:31:38
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-26 13:33:54
* @FilePath: /mlaj/vitest.config.js
* @Description: 文件描述
*/
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
}
},
test: {
environment: 'jsdom',
css: {
include: [],
modules: {
classNameStrategy: 'non-scoped'
}
},
server: {
deps: {
inline: ['vant']
}
}
}
})
This diff could not be displayed because it is too large.