hookehuyr

feat(checkin): 支持多类型文件混合上传与显示

重构文件上传逻辑,支持在同一打卡中上传并显示多种类型的文件(图片、视频、音频)。移除按类型切换时清空文件列表的限制,新增文件类型计数显示。更新多个视图中的数据处理函数以支持按文件类型分类。
......@@ -98,12 +98,12 @@ export function useCheckin() {
* 用于记忆不同类型的文件列表,切换类型时恢复
* @type {import('vue').Ref<Object>}
*/
const fileListMemory = ref({
text: [],
image: [],
video: [],
audio: []
})
// const fileListMemory = ref({
// text: [],
// image: [],
// video: [],
// audio: []
// })
/**
* 是否可以提交
......@@ -205,7 +205,17 @@ export function useCheckin() {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
let fileName = ''
if (activeType.value === 'image') {
// 根据当前 activeType 或文件类型判断
let currentFileType = activeType.value
if (!['image', 'video', 'audio'].includes(currentFileType)) {
// 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
if (file.file.type.startsWith('image/')) currentFileType = 'image'
else if (file.file.type.startsWith('video/')) currentFileType = 'video'
else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
else currentFileType = 'file' // 默认
}
if (currentFileType === 'image') {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
} else {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
......@@ -226,13 +236,13 @@ export function useCheckin() {
}
// 图片类型需要保存尺寸信息
if (activeType.value === 'image' && uploadResult.image_info) {
if (currentFileType === 'image' && uploadResult.image_info) {
saveData.height = uploadResult.image_info.height
saveData.width = uploadResult.image_info.width
}
const { data } = await saveFileAPI(saveData)
return data
return { ...data, file_type: currentFileType }
}
}
return null
......@@ -323,6 +333,7 @@ export function useCheckin() {
item.url = result.url
item.meta_id = result.meta_id
item.name = result.name || item.file.name
item.file_type = result.file_type || activeType.value // 记录文件类型
} else {
item.status = 'failed'
item.message = '上传失败'
......@@ -438,20 +449,40 @@ export function useCheckin() {
try {
// 准备提交数据
// 构造 files 数组
const files = fileList.value
.filter(item => item.status === 'done' && item.meta_id)
.map(item => ({
meta_id: item.meta_id,
file_type: item.file_type || activeType.value // 优先使用 item 自己的 type
}))
const submitData = {
note: message.value,
file_type: activeType.value,
meta_id: [],
// file_type: activeType.value, // 不再依赖顶层 file_type
// meta_id: [], // 废弃
files: files,
makeup_time: isMakeup.value ? route.query.date : '',
...extraData
}
// 如果有文件,添加文件ID
if (fileList.value.length > 0) {
submitData.meta_id = fileList.value
.filter(item => item.status === 'done' && item.meta_id)
.map(item => item.meta_id)
// 如果没有 files,尝试推断 file_type (仅为了兼容旧逻辑或文本打卡)
if (files.length === 0 && activeType.value === 'text') {
submitData.file_type = 'text'
} else if (files.length > 0) {
// 如果有文件,file_type 可能是 'mixed' 或者取第一个文件的类型作为主类型
// 这里暂时保留 file_type 字段以防后端必须校验,取第一个文件的类型,或者传 'mixed'
// 如果后端已更新为只看 files 数组,则此字段可能无效
const types = new Set(files.map(f => f.file_type))
if (types.size > 1) {
submitData.file_type = 'mixed' // 假设后端支持 mixed
} else {
submitData.file_type = files[0].file_type
}
}
// 如果有文件,添加文件ID (为了兼容可能还在用 meta_id 的旧接口逻辑,如果确定废弃可删除)
// submitData.meta_id = files.map(f => f.meta_id)
let result
if (route.query.status === 'edit') {
......@@ -460,7 +491,8 @@ export function useCheckin() {
i: route.query.post_id,
subtask_id: submitData.subtask_id || route.query.subtask_id,
note: submitData.note,
meta_id: submitData.meta_id,
// meta_id: submitData.meta_id,
files: submitData.files,
file_type: submitData.file_type,
}
......@@ -503,19 +535,10 @@ export function useCheckin() {
/**
* 切换打卡类型
* @param {string} type - 打卡类型
* @description 切换时会保存当前类型的文件列表,并恢复新类型的文件列表
* @description 切换类型不再清空文件列表,支持多类型混合上传
*/
const switchType = (type) => {
if (activeType.value !== type) {
// 保存当前类型的文件列表到记忆中
fileListMemory.value[activeType.value] = [...fileList.value]
// 切换到新类型
activeType.value = type
// 恢复新类型的文件列表
fileList.value = [...fileListMemory.value[type]]
}
}
/**
......@@ -528,12 +551,12 @@ export function useCheckin() {
uploading.value = false
loading.value = false
// 清空文件列表记忆
fileListMemory.value = {
text: [],
image: [],
video: [],
audio: []
}
// fileListMemory.value = {
// text: [],
// image: [],
// video: [],
// audio: []
// }
}
// 计数打卡相关数据
......@@ -608,11 +631,12 @@ export function useCheckin() {
status: 'done',
message: '已上传',
meta_id: item.meta_id,
name: item.name || ''
name: item.name || '',
file_type: item.file_type || data.file_type || 'image' // 恢复 file_type
}
// 对于图片类型,添加isImage标记确保正确显示
if (activeType.value === 'image') {
if (fileItem.file_type === 'image') {
fileItem.isImage = true
}
......@@ -626,7 +650,7 @@ export function useCheckin() {
// 将文件列表保存到当前类型的记忆中
fileList.value = files
fileListMemory.value[activeType.value] = [...files]
// fileListMemory.value[activeType.value] = [...files]
}
}
} catch (error) {
......
<!--
* @Date: 2025-09-30 17:05
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-22 22:11:29
* @LastEditTime: 2026-01-23 16:59:38
* @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue
* @Description: 用户打卡详情页
-->
......@@ -84,18 +84,21 @@
<div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div>
<div class="tabs-nav">
<div v-for="option in attachmentTypeOptions" :key="option.key"
@click="switchType(option.key)" :class="['tab-item', {
@click="switchType(option.key)" :class="['tab-item', 'relative', {
active: activeType === option.key
}]">
<van-icon :name="getIconName(option.key)" size="1.2rem" />
<span class="tab-text">{{ option.value }}</span>
<div v-if="getTypeCount(option.key) > 0" class="absolute top-1 right-1 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1">
{{ getTypeCount(option.key) }}
</div>
</div>
</div>
</div>
<!-- 文件上传区域 -->
<div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
<van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
<van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
:before-read="beforeRead" :after-read="afterRead" @delete="onDelete"
@click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
:deletable="true" upload-icon="plus" />
......@@ -221,6 +224,67 @@ const {
gratitudeFormList
} = useCheckin()
/**
* 获取指定类型的文件数量
* @param {string} type - 文件类型
* @returns {number} 文件数量
*/
const getTypeCount = (type) => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === type
}
// 处理刚选择但未上传完成的文件(尝试推断类型)
if (item.file && item.file.type) {
if (type === 'image') return item.file.type.startsWith('image/')
if (type === 'video') return item.file.type.startsWith('video/')
if (type === 'audio') return item.file.type.startsWith('audio/')
}
return false
}).length
}
/**
* 当前显示的(经过类型过滤的)文件列表
* @description
* 1. getter: 根据 activeType 过滤 fileList,只显示当前类型的文件
* 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
*/
const displayFileList = computed({
get: () => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return item.file.type.startsWith('image/')
if (activeType.value === 'video') return item.file.type.startsWith('video/')
if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
}
return false
})
},
set: (val) => {
// 找出不属于当前视图的其他文件(保留它们)
const otherFiles = fileList.value.filter(item => {
if (item.file_type) {
return item.file_type !== activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return !item.file.type.startsWith('image/')
if (activeType.value === 'video') return !item.file.type.startsWith('video/')
if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
}
// 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
// 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
return true
})
// 合并其他文件和当前视图的新文件列表
fileList.value = [...otherFiles, ...val]
}
})
// 动态字段文字
const dynamicFieldText = ref('感恩')
......@@ -521,7 +585,7 @@ const isSubmitDisabled = computed(() => {
// 文本打卡:必须填写内容且长度不少于10个字符
return !message.value.trim() || message.value.trim().length < 10
} else {
// 其他类型:必须有文件
// 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
return fileList.value.length === 0
}
})
......@@ -745,18 +809,20 @@ const onClickPreview = (file, detail) => {
}
// 根据打卡类型或文件扩展名判断文件类型
if (activeType.value === 'audio' || isAudioFile(fileName)) {
const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image'))
if (finalFileType === 'audio') {
console.log('准备播放音频:', fileName, fileUrl)
showAudio(fileName, fileUrl)
} else if (activeType.value === 'video' || isVideoFile(fileName)) {
} else if (finalFileType === 'video') {
console.log('准备播放视频:', fileName, fileUrl)
showVideo(fileName, fileUrl)
} else if (activeType.value === 'image' || isImageFile(fileName)) {
} else if (finalFileType === 'image') {
console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览')
// 图片预览由van-uploader的@click-preview事件处理,避免重复弹出
return
} else {
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', activeType.value)
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
showToast('该文件类型不支持预览')
}
}
......
......@@ -716,29 +716,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......
......@@ -702,29 +702,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......
......@@ -841,29 +841,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......