hookehuyr

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

重构文件上传逻辑,支持在同一打卡中上传并显示多种类型的文件(图片、视频、音频)。移除按类型切换时清空文件列表的限制,新增文件类型计数显示。更新多个视图中的数据处理函数以支持按文件类型分类。
...@@ -98,12 +98,12 @@ export function useCheckin() { ...@@ -98,12 +98,12 @@ export function useCheckin() {
98 * 用于记忆不同类型的文件列表,切换类型时恢复 98 * 用于记忆不同类型的文件列表,切换类型时恢复
99 * @type {import('vue').Ref<Object>} 99 * @type {import('vue').Ref<Object>}
100 */ 100 */
101 - const fileListMemory = ref({ 101 + // const fileListMemory = ref({
102 - text: [], 102 + // text: [],
103 - image: [], 103 + // image: [],
104 - video: [], 104 + // video: [],
105 - audio: [] 105 + // audio: []
106 - }) 106 + // })
107 107
108 /** 108 /**
109 * 是否可以提交 109 * 是否可以提交
...@@ -205,7 +205,17 @@ export function useCheckin() { ...@@ -205,7 +205,17 @@ export function useCheckin() {
205 const suffix = /.[^.]+$/.exec(file.file.name) || '' 205 const suffix = /.[^.]+$/.exec(file.file.name) || ''
206 let fileName = '' 206 let fileName = ''
207 207
208 - if (activeType.value === 'image') { 208 + // 根据当前 activeType 或文件类型判断
209 + let currentFileType = activeType.value
210 + if (!['image', 'video', 'audio'].includes(currentFileType)) {
211 + // 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
212 + if (file.file.type.startsWith('image/')) currentFileType = 'image'
213 + else if (file.file.type.startsWith('video/')) currentFileType = 'video'
214 + else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
215 + else currentFileType = 'file' // 默认
216 + }
217 +
218 + if (currentFileType === 'image') {
209 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}` 219 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
210 } else { 220 } else {
211 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}` 221 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
...@@ -226,13 +236,13 @@ export function useCheckin() { ...@@ -226,13 +236,13 @@ export function useCheckin() {
226 } 236 }
227 237
228 // 图片类型需要保存尺寸信息 238 // 图片类型需要保存尺寸信息
229 - if (activeType.value === 'image' && uploadResult.image_info) { 239 + if (currentFileType === 'image' && uploadResult.image_info) {
230 saveData.height = uploadResult.image_info.height 240 saveData.height = uploadResult.image_info.height
231 saveData.width = uploadResult.image_info.width 241 saveData.width = uploadResult.image_info.width
232 } 242 }
233 243
234 const { data } = await saveFileAPI(saveData) 244 const { data } = await saveFileAPI(saveData)
235 - return data 245 + return { ...data, file_type: currentFileType }
236 } 246 }
237 } 247 }
238 return null 248 return null
...@@ -323,6 +333,7 @@ export function useCheckin() { ...@@ -323,6 +333,7 @@ export function useCheckin() {
323 item.url = result.url 333 item.url = result.url
324 item.meta_id = result.meta_id 334 item.meta_id = result.meta_id
325 item.name = result.name || item.file.name 335 item.name = result.name || item.file.name
336 + item.file_type = result.file_type || activeType.value // 记录文件类型
326 } else { 337 } else {
327 item.status = 'failed' 338 item.status = 'failed'
328 item.message = '上传失败' 339 item.message = '上传失败'
...@@ -438,21 +449,41 @@ export function useCheckin() { ...@@ -438,21 +449,41 @@ export function useCheckin() {
438 449
439 try { 450 try {
440 // 准备提交数据 451 // 准备提交数据
452 + // 构造 files 数组
453 + const files = fileList.value
454 + .filter(item => item.status === 'done' && item.meta_id)
455 + .map(item => ({
456 + meta_id: item.meta_id,
457 + file_type: item.file_type || activeType.value // 优先使用 item 自己的 type
458 + }))
459 +
441 const submitData = { 460 const submitData = {
442 note: message.value, 461 note: message.value,
443 - file_type: activeType.value, 462 + // file_type: activeType.value, // 不再依赖顶层 file_type
444 - meta_id: [], 463 + // meta_id: [], // 废弃
464 + files: files,
445 makeup_time: isMakeup.value ? route.query.date : '', 465 makeup_time: isMakeup.value ? route.query.date : '',
446 ...extraData 466 ...extraData
447 } 467 }
448 468
449 - // 如果有文件,添加文件ID 469 + // 如果没有 files,尝试推断 file_type (仅为了兼容旧逻辑或文本打卡)
450 - if (fileList.value.length > 0) { 470 + if (files.length === 0 && activeType.value === 'text') {
451 - submitData.meta_id = fileList.value 471 + submitData.file_type = 'text'
452 - .filter(item => item.status === 'done' && item.meta_id) 472 + } else if (files.length > 0) {
453 - .map(item => item.meta_id) 473 + // 如果有文件,file_type 可能是 'mixed' 或者取第一个文件的类型作为主类型
474 + // 这里暂时保留 file_type 字段以防后端必须校验,取第一个文件的类型,或者传 'mixed'
475 + // 如果后端已更新为只看 files 数组,则此字段可能无效
476 + const types = new Set(files.map(f => f.file_type))
477 + if (types.size > 1) {
478 + submitData.file_type = 'mixed' // 假设后端支持 mixed
479 + } else {
480 + submitData.file_type = files[0].file_type
481 + }
454 } 482 }
455 483
484 + // 如果有文件,添加文件ID (为了兼容可能还在用 meta_id 的旧接口逻辑,如果确定废弃可删除)
485 + // submitData.meta_id = files.map(f => f.meta_id)
486 +
456 let result 487 let result
457 if (route.query.status === 'edit') { 488 if (route.query.status === 'edit') {
458 // 编辑打卡 489 // 编辑打卡
...@@ -460,7 +491,8 @@ export function useCheckin() { ...@@ -460,7 +491,8 @@ export function useCheckin() {
460 i: route.query.post_id, 491 i: route.query.post_id,
461 subtask_id: submitData.subtask_id || route.query.subtask_id, 492 subtask_id: submitData.subtask_id || route.query.subtask_id,
462 note: submitData.note, 493 note: submitData.note,
463 - meta_id: submitData.meta_id, 494 + // meta_id: submitData.meta_id,
495 + files: submitData.files,
464 file_type: submitData.file_type, 496 file_type: submitData.file_type,
465 } 497 }
466 498
...@@ -503,19 +535,10 @@ export function useCheckin() { ...@@ -503,19 +535,10 @@ export function useCheckin() {
503 /** 535 /**
504 * 切换打卡类型 536 * 切换打卡类型
505 * @param {string} type - 打卡类型 537 * @param {string} type - 打卡类型
506 - * @description 切换时会保存当前类型的文件列表,并恢复新类型的文件列表 538 + * @description 切换类型不再清空文件列表,支持多类型混合上传
507 */ 539 */
508 const switchType = (type) => { 540 const switchType = (type) => {
509 - if (activeType.value !== type) { 541 + activeType.value = type
510 - // 保存当前类型的文件列表到记忆中
511 - fileListMemory.value[activeType.value] = [...fileList.value]
512 -
513 - // 切换到新类型
514 - activeType.value = type
515 -
516 - // 恢复新类型的文件列表
517 - fileList.value = [...fileListMemory.value[type]]
518 - }
519 } 542 }
520 543
521 /** 544 /**
...@@ -528,12 +551,12 @@ export function useCheckin() { ...@@ -528,12 +551,12 @@ export function useCheckin() {
528 uploading.value = false 551 uploading.value = false
529 loading.value = false 552 loading.value = false
530 // 清空文件列表记忆 553 // 清空文件列表记忆
531 - fileListMemory.value = { 554 + // fileListMemory.value = {
532 - text: [], 555 + // text: [],
533 - image: [], 556 + // image: [],
534 - video: [], 557 + // video: [],
535 - audio: [] 558 + // audio: []
536 - } 559 + // }
537 } 560 }
538 561
539 // 计数打卡相关数据 562 // 计数打卡相关数据
...@@ -608,11 +631,12 @@ export function useCheckin() { ...@@ -608,11 +631,12 @@ export function useCheckin() {
608 status: 'done', 631 status: 'done',
609 message: '已上传', 632 message: '已上传',
610 meta_id: item.meta_id, 633 meta_id: item.meta_id,
611 - name: item.name || '' 634 + name: item.name || '',
635 + file_type: item.file_type || data.file_type || 'image' // 恢复 file_type
612 } 636 }
613 637
614 // 对于图片类型,添加isImage标记确保正确显示 638 // 对于图片类型,添加isImage标记确保正确显示
615 - if (activeType.value === 'image') { 639 + if (fileItem.file_type === 'image') {
616 fileItem.isImage = true 640 fileItem.isImage = true
617 } 641 }
618 642
...@@ -626,7 +650,7 @@ export function useCheckin() { ...@@ -626,7 +650,7 @@ export function useCheckin() {
626 650
627 // 将文件列表保存到当前类型的记忆中 651 // 将文件列表保存到当前类型的记忆中
628 fileList.value = files 652 fileList.value = files
629 - fileListMemory.value[activeType.value] = [...files] 653 + // fileListMemory.value[activeType.value] = [...files]
630 } 654 }
631 } 655 }
632 } catch (error) { 656 } catch (error) {
......
1 <!-- 1 <!--
2 * @Date: 2025-09-30 17:05 2 * @Date: 2025-09-30 17:05
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-22 22:11:29 4 + * @LastEditTime: 2026-01-23 16:59:38
5 * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue 5 * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue
6 * @Description: 用户打卡详情页 6 * @Description: 用户打卡详情页
7 --> 7 -->
...@@ -84,18 +84,21 @@ ...@@ -84,18 +84,21 @@
84 <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div> 84 <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div>
85 <div class="tabs-nav"> 85 <div class="tabs-nav">
86 <div v-for="option in attachmentTypeOptions" :key="option.key" 86 <div v-for="option in attachmentTypeOptions" :key="option.key"
87 - @click="switchType(option.key)" :class="['tab-item', { 87 + @click="switchType(option.key)" :class="['tab-item', 'relative', {
88 - active: activeType === option.key 88 + active: activeType === option.key
89 - }]"> 89 + }]">
90 - <van-icon :name="getIconName(option.key)" size="1.2rem" /> 90 + <van-icon :name="getIconName(option.key)" size="1.2rem" />
91 - <span class="tab-text">{{ option.value }}</span> 91 + <span class="tab-text">{{ option.value }}</span>
92 - </div> 92 + <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">
93 + {{ getTypeCount(option.key) }}
94 + </div>
95 + </div>
93 </div> 96 </div>
94 </div> 97 </div>
95 98
96 <!-- 文件上传区域 --> 99 <!-- 文件上传区域 -->
97 <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area"> 100 <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
98 - <van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes" 101 + <van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
99 :before-read="beforeRead" :after-read="afterRead" @delete="onDelete" 102 :before-read="beforeRead" :after-read="afterRead" @delete="onDelete"
100 @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file" 103 @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
101 :deletable="true" upload-icon="plus" /> 104 :deletable="true" upload-icon="plus" />
...@@ -221,6 +224,67 @@ const { ...@@ -221,6 +224,67 @@ const {
221 gratitudeFormList 224 gratitudeFormList
222 } = useCheckin() 225 } = useCheckin()
223 226
227 +/**
228 + * 获取指定类型的文件数量
229 + * @param {string} type - 文件类型
230 + * @returns {number} 文件数量
231 + */
232 +const getTypeCount = (type) => {
233 + return fileList.value.filter(item => {
234 + if (item.file_type) {
235 + return item.file_type === type
236 + }
237 + // 处理刚选择但未上传完成的文件(尝试推断类型)
238 + if (item.file && item.file.type) {
239 + if (type === 'image') return item.file.type.startsWith('image/')
240 + if (type === 'video') return item.file.type.startsWith('video/')
241 + if (type === 'audio') return item.file.type.startsWith('audio/')
242 + }
243 + return false
244 + }).length
245 +}
246 +
247 +/**
248 + * 当前显示的(经过类型过滤的)文件列表
249 + * @description
250 + * 1. getter: 根据 activeType 过滤 fileList,只显示当前类型的文件
251 + * 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
252 + */
253 +const displayFileList = computed({
254 + get: () => {
255 + return fileList.value.filter(item => {
256 + if (item.file_type) {
257 + return item.file_type === activeType.value
258 + }
259 + if (item.file && item.file.type) {
260 + if (activeType.value === 'image') return item.file.type.startsWith('image/')
261 + if (activeType.value === 'video') return item.file.type.startsWith('video/')
262 + if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
263 + }
264 + return false
265 + })
266 + },
267 + set: (val) => {
268 + // 找出不属于当前视图的其他文件(保留它们)
269 + const otherFiles = fileList.value.filter(item => {
270 + if (item.file_type) {
271 + return item.file_type !== activeType.value
272 + }
273 + if (item.file && item.file.type) {
274 + if (activeType.value === 'image') return !item.file.type.startsWith('image/')
275 + if (activeType.value === 'video') return !item.file.type.startsWith('video/')
276 + if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
277 + }
278 + // 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
279 + // 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
280 + return true
281 + })
282 +
283 + // 合并其他文件和当前视图的新文件列表
284 + fileList.value = [...otherFiles, ...val]
285 + }
286 +})
287 +
224 // 动态字段文字 288 // 动态字段文字
225 const dynamicFieldText = ref('感恩') 289 const dynamicFieldText = ref('感恩')
226 290
...@@ -521,7 +585,7 @@ const isSubmitDisabled = computed(() => { ...@@ -521,7 +585,7 @@ const isSubmitDisabled = computed(() => {
521 // 文本打卡:必须填写内容且长度不少于10个字符 585 // 文本打卡:必须填写内容且长度不少于10个字符
522 return !message.value.trim() || message.value.trim().length < 10 586 return !message.value.trim() || message.value.trim().length < 10
523 } else { 587 } else {
524 - // 其他类型:必须有文件 588 + // 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
525 return fileList.value.length === 0 589 return fileList.value.length === 0
526 } 590 }
527 }) 591 })
...@@ -745,18 +809,20 @@ const onClickPreview = (file, detail) => { ...@@ -745,18 +809,20 @@ const onClickPreview = (file, detail) => {
745 } 809 }
746 810
747 // 根据打卡类型或文件扩展名判断文件类型 811 // 根据打卡类型或文件扩展名判断文件类型
748 - if (activeType.value === 'audio' || isAudioFile(fileName)) { 812 + const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image'))
813 +
814 + if (finalFileType === 'audio') {
749 console.log('准备播放音频:', fileName, fileUrl) 815 console.log('准备播放音频:', fileName, fileUrl)
750 showAudio(fileName, fileUrl) 816 showAudio(fileName, fileUrl)
751 - } else if (activeType.value === 'video' || isVideoFile(fileName)) { 817 + } else if (finalFileType === 'video') {
752 console.log('准备播放视频:', fileName, fileUrl) 818 console.log('准备播放视频:', fileName, fileUrl)
753 showVideo(fileName, fileUrl) 819 showVideo(fileName, fileUrl)
754 - } else if (activeType.value === 'image' || isImageFile(fileName)) { 820 + } else if (finalFileType === 'image') {
755 console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览') 821 console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览')
756 // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出 822 // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出
757 return 823 return
758 } else { 824 } else {
759 - console.log('该文件类型不支持预览,文件名:', fileName, '类型:', activeType.value) 825 + console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
760 showToast('该文件类型不支持预览') 826 showToast('该文件类型不支持预览')
761 } 827 }
762 } 828 }
......
...@@ -716,29 +716,33 @@ const formatData = (data) => { ...@@ -716,29 +716,33 @@ const formatData = (data) => {
716 let images = []; 716 let images = [];
717 let audio = []; 717 let audio = [];
718 let videoList = []; 718 let videoList = [];
719 - if (item.file_type === 'image') { 719 +
720 - images = item.files.map(file => { 720 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
721 - return file.value; 721 + if (item.files && Array.isArray(item.files)) {
722 - }); 722 + item.files.forEach(file => {
723 - } else if (item.file_type === 'video') { 723 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
724 - videoList = item.files.map(file => { 724 + const type = file.file_type || item.file_type;
725 - return { 725 +
726 - id: file.meta_id, 726 + if (type === 'image') {
727 - video: file.value, 727 + images.push(file.value);
728 - videoCover: file.cover, 728 + } else if (type === 'video') {
729 - isPlaying: false, 729 + videoList.push({
730 - } 730 + id: file.meta_id,
731 - }) 731 + video: file.value,
732 - } else if (item.file_type === 'audio') { 732 + videoCover: file.cover,
733 - audio = item.files.map(file => { 733 + isPlaying: false,
734 - return { 734 + });
735 - title: file.name ? file.name : '打卡音频', 735 + } else if (type === 'audio') {
736 - artist: file.artist ? file.artist : '', 736 + audio.push({
737 - url: file.value, 737 + title: file.name ? file.name : '打卡音频',
738 - cover: file.cover ? file.cover : '', 738 + artist: file.artist ? file.artist : '',
739 + url: file.value,
740 + cover: file.cover ? file.cover : '',
741 + });
739 } 742 }
740 - }) 743 + });
741 } 744 }
745 +
742 return { 746 return {
743 id: item.id, 747 id: item.id,
744 task_id: item.task_id, 748 task_id: item.task_id,
......
...@@ -702,29 +702,33 @@ const formatData = (data) => { ...@@ -702,29 +702,33 @@ const formatData = (data) => {
702 let images = []; 702 let images = [];
703 let audio = []; 703 let audio = [];
704 let videoList = []; 704 let videoList = [];
705 - if (item.file_type === 'image') { 705 +
706 - images = item.files.map(file => { 706 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
707 - return file.value; 707 + if (item.files && Array.isArray(item.files)) {
708 - }); 708 + item.files.forEach(file => {
709 - } else if (item.file_type === 'video') { 709 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
710 - videoList = item.files.map(file => { 710 + const type = file.file_type || item.file_type;
711 - return { 711 +
712 - id: file.meta_id, 712 + if (type === 'image') {
713 - video: file.value, 713 + images.push(file.value);
714 - videoCover: file.cover, 714 + } else if (type === 'video') {
715 - isPlaying: false, 715 + videoList.push({
716 - } 716 + id: file.meta_id,
717 - }) 717 + video: file.value,
718 - } else if (item.file_type === 'audio') { 718 + videoCover: file.cover,
719 - audio = item.files.map(file => { 719 + isPlaying: false,
720 - return { 720 + });
721 - title: file.name ? file.name : '打卡音频', 721 + } else if (type === 'audio') {
722 - artist: file.artist ? file.artist : '', 722 + audio.push({
723 - url: file.value, 723 + title: file.name ? file.name : '打卡音频',
724 - cover: file.cover ? file.cover : '', 724 + artist: file.artist ? file.artist : '',
725 + url: file.value,
726 + cover: file.cover ? file.cover : '',
727 + });
725 } 728 }
726 - }) 729 + });
727 } 730 }
731 +
728 return { 732 return {
729 id: item.id, 733 id: item.id,
730 task_id: item.task_id, 734 task_id: item.task_id,
......
...@@ -841,29 +841,33 @@ const formatData = (data) => { ...@@ -841,29 +841,33 @@ const formatData = (data) => {
841 let images = []; 841 let images = [];
842 let audio = []; 842 let audio = [];
843 let videoList = []; 843 let videoList = [];
844 - if (item.file_type === 'image') { 844 +
845 - images = item.files.map(file => { 845 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
846 - return file.value; 846 + if (item.files && Array.isArray(item.files)) {
847 - }); 847 + item.files.forEach(file => {
848 - } else if (item.file_type === 'video') { 848 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
849 - videoList = item.files.map(file => { 849 + const type = file.file_type || item.file_type;
850 - return { 850 +
851 - id: file.meta_id, 851 + if (type === 'image') {
852 - video: file.value, 852 + images.push(file.value);
853 - videoCover: file.cover, 853 + } else if (type === 'video') {
854 - isPlaying: false, 854 + videoList.push({
855 - } 855 + id: file.meta_id,
856 - }) 856 + video: file.value,
857 - } else if (item.file_type === 'audio') { 857 + videoCover: file.cover,
858 - audio = item.files.map(file => { 858 + isPlaying: false,
859 - return { 859 + });
860 - title: file.name ? file.name : '打卡音频', 860 + } else if (type === 'audio') {
861 - artist: file.artist ? file.artist : '', 861 + audio.push({
862 - url: file.value, 862 + title: file.name ? file.name : '打卡音频',
863 - cover: file.cover ? file.cover : '', 863 + artist: file.artist ? file.artist : '',
864 + url: file.value,
865 + cover: file.cover ? file.cover : '',
866 + });
864 } 867 }
865 - }) 868 + });
866 } 869 }
870 +
867 return { 871 return {
868 id: item.id, 872 id: item.id,
869 task_id: item.task_id, 873 task_id: item.task_id,
......