hookehuyr

feat(打卡): 拆分音视频上传组件并实现打卡功能

将原有的file.vue拆分为独立的video.vue和audio.vue组件
添加打卡相关API接口并实现提交功能
更新打卡页面路由和类型选择逻辑
实现打卡动态列表的数据获取和展示
1 /* 1 /*
2 * @Date: 2025-06-06 09:26:16 2 * @Date: 2025-06-06 09:26:16
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-06 15:05:19 4 + * @LastEditTime: 2025-06-06 16:19:13
5 * @FilePath: /mlaj/src/api/checkin.js 5 * @FilePath: /mlaj/src/api/checkin.js
6 * @Description: 签到模块相关接口 6 * @Description: 签到模块相关接口
7 */ 7 */
...@@ -11,6 +11,8 @@ const Api = { ...@@ -11,6 +11,8 @@ const Api = {
11 GET_TASK_LIST: '/srv/?a=task&t=my_list', 11 GET_TASK_LIST: '/srv/?a=task&t=my_list',
12 GET_TASK_DETAIL: '/srv/?a=task&t=detail', 12 GET_TASK_DETAIL: '/srv/?a=task&t=detail',
13 TASK_CHECKIN: '/srv/?a=checkin&t=checkin', 13 TASK_CHECKIN: '/srv/?a=checkin&t=checkin',
14 + TASK_UPLOAD_ADD: '/srv/?a=checkin&t=upload_add',
15 + TASK_UPLOAD_LIST: '/srv/?a=checkin&t=upload_list',
14 } 16 }
15 17
16 /** 18 /**
...@@ -35,3 +37,25 @@ export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, p ...@@ -35,3 +37,25 @@ export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, p
35 * @returns 37 * @returns
36 */ 38 */
37 export const checkinTaskAPI = (params) => fn(fetch.post(Api.TASK_CHECKIN, params)) 39 export const checkinTaskAPI = (params) => fn(fetch.post(Api.TASK_CHECKIN, params))
40 +
41 +/**
42 + * @description: 新增上传打卡
43 + * @param task_id 上传作业ID
44 + * @param note 打卡文字
45 + * @param meta_id[] 附件ID列表
46 + * @param file_type 上传附件的类型 image=上传图片,video=视频,audio=音频
47 + * @returns
48 + */
49 +export const addUploadTaskAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_ADD, params))
50 +
51 +/**
52 + * @description: 获取打卡动态列表
53 + * @param task_id 上传作业ID
54 + * @param date 日期
55 + * @param keyword 搜索
56 + * @param order_by_time asc=正序,desc=倒序。默认为倒序
57 + * @param limit
58 + * @param offset
59 + * @returns
60 + */
61 +export const getUploadTaskListAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_LIST, params))
......
1 /* 1 /*
2 * @Date: 2025-03-21 13:28:30 2 * @Date: 2025-03-21 13:28:30
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-06 14:35:56 4 + * @LastEditTime: 2025-06-06 15:45:36
5 * @FilePath: /mlaj/src/router/checkin.js 5 * @FilePath: /mlaj/src/router/checkin.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -61,11 +61,20 @@ export default [ ...@@ -61,11 +61,20 @@ export default [
61 } 61 }
62 }, 62 },
63 { 63 {
64 - path: '/checkin/file', 64 + path: '/checkin/video',
65 - name: 'FileCheckIn', 65 + name: 'VideoCheckIn',
66 - component: () => import('@/views/checkin/upload/file.vue'), 66 + component: () => import('@root/src/views/checkin/upload/video.vue'),
67 meta: { 67 meta: {
68 - title: '打卡视频/音频', 68 + title: '打卡视频',
69 + requiresAuth: true
70 + }
71 + },
72 + {
73 + path: '/checkin/audio',
74 + name: 'AudioCheckIn',
75 + component: () => import('@root/src/views/checkin/upload/audio.vue'),
76 + meta: {
77 + title: '打卡音频',
69 requiresAuth: true 78 requiresAuth: true
70 } 79 }
71 }, 80 },
......
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: 2025-06-06 14:33:04 4 + * @LastEditTime: 2025-06-06 17:07:25
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -40,25 +40,26 @@ ...@@ -40,25 +40,26 @@
40 <van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" /> 40 <van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
41 </div> --> 41 </div> -->
42 <div style="padding: 0.75rem 1rem;"> 42 <div style="padding: 0.75rem 1rem;">
43 - <van-image round width="2.8rem" height="2.8rem" src="https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg" 43 + <van-image round width="2.8rem" height="2.8rem" :src="item ? item : 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="contain"
44 v-for="(item, index) in teamAvatars" :key="index" 44 v-for="(item, index) in teamAvatars" :key="index"
45 - :style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #FFF' }" /> 45 + :style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #eff6ff', background: '#fff' }" />
46 </div> 46 </div>
47 </div> 47 </div>
48 </div> 48 </div>
49 49
50 - <div v-if="!taskDetail.is_gray" class="text-wrapper"> 50 + <!-- <div v-if="!taskDetail.is_gray" class="text-wrapper"> -->
51 + <div v-if="taskDetail.is_gray" class="text-wrapper">
51 <div class="text-header">打卡类型</div> 52 <div class="text-header">打卡类型</div>
52 <div class="upload-wrapper"> 53 <div class="upload-wrapper">
53 <div @click="goToCheckinImagePage" class="upload-boxer"> 54 <div @click="goToCheckinImagePage" class="upload-boxer">
54 <div><van-icon name="photo" size="2.5rem" /></div> 55 <div><van-icon name="photo" size="2.5rem" /></div>
55 <div style="font-size: 0.85rem;">图文打卡</div> 56 <div style="font-size: 0.85rem;">图文打卡</div>
56 </div> 57 </div>
57 - <div @click="goToCheckinFilePage('video')" class="upload-boxer"> 58 + <div @click="goToCheckinVideoPage()" class="upload-boxer">
58 <div><van-icon name="video" size="2.5rem" /></div> 59 <div><van-icon name="video" size="2.5rem" /></div>
59 <div style="font-size: 0.85rem;">视频打卡</div> 60 <div style="font-size: 0.85rem;">视频打卡</div>
60 </div> 61 </div>
61 - <div @click="goToCheckinFilePage('audio')" class="upload-boxer"> 62 + <div @click="goToCheckinAudioPage()" class="upload-boxer">
62 <div><van-icon name="music" size="2.5rem" /></div> 63 <div><van-icon name="music" size="2.5rem" /></div>
63 <div style="font-size: 0.85rem;">音频打卡</div> 64 <div style="font-size: 0.85rem;">音频打卡</div>
64 </div> 65 </div>
...@@ -67,11 +68,11 @@ ...@@ -67,11 +68,11 @@
67 68
68 <div class="text-wrapper"> 69 <div class="text-wrapper">
69 <div class="text-header">打卡动态</div> 70 <div class="text-header">打卡动态</div>
70 - <div class="post-card" v-for="post in mockPosts" :key="post.id"> 71 + <div class="post-card" v-for="post in checkinDataList" :key="post.id">
71 <div class="post-header"> 72 <div class="post-header">
72 <van-row> 73 <van-row>
73 <van-col span="4"> 74 <van-col span="4">
74 - <van-image round width="2.5rem" height="2.5rem" :src="post.user.avatar" /> 75 + <van-image round width="2.5rem" height="2.5rem" :src="post.user.avatar" fit="cover" />
75 </van-col> 76 </van-col>
76 <van-col span="17"> 77 <van-col span="17">
77 <div class="user-info"> 78 <div class="user-info">
...@@ -98,13 +99,13 @@ ...@@ -98,13 +99,13 @@
98 <div v-for="(v, idx) in post.videoList" :key="idx"> 99 <div v-for="(v, idx) in post.videoList" :key="idx">
99 <!-- 视频封面和播放按钮 --> 100 <!-- 视频封面和播放按钮 -->
100 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden" style="aspect-ratio: 16/9; margin-bottom: 1rem;"> 101 <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden" style="aspect-ratio: 16/9; margin-bottom: 1rem;">
101 - <img :src="v.videoCover || 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'" 102 + <img :src="v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_1.png'"
102 :alt="v.content" class="w-full h-full object-cover" /> 103 :alt="v.content" class="w-full h-full object-cover" />
103 <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20" 104 <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
104 @click="startPlay(v)"> 105 @click="startPlay(v)">
105 <div 106 <div
106 class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors"> 107 class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
107 - <van-icon name="play-circle-o" class="text-white" size="30" /> 108 + <van-icon name="play-circle-o" class="text-white" size="40" />
108 </div> 109 </div>
109 </div> 110 </div>
110 </div> 111 </div>
...@@ -163,7 +164,7 @@ import AudioPlayer from "@/components/ui/AudioPlayer.vue"; ...@@ -163,7 +164,7 @@ import AudioPlayer from "@/components/ui/AudioPlayer.vue";
163 import { useTitle } from '@vueuse/core'; 164 import { useTitle } from '@vueuse/core';
164 import dayjs from 'dayjs'; 165 import dayjs from 'dayjs';
165 166
166 -import { getTaskDetailAPI } from "@/api/checkin"; 167 +import { getTaskDetailAPI, getUploadTaskListAPI } from "@/api/checkin";
167 168
168 const route = useRoute() 169 const route = useRoute()
169 const router = useRouter() 170 const router = useRouter()
...@@ -200,10 +201,10 @@ onBeforeUnmount(() => { ...@@ -200,10 +201,10 @@ onBeforeUnmount(() => {
200 * @param {Object} post - 要播放视频的帖子对象 201 * @param {Object} post - 要播放视频的帖子对象
201 */ 202 */
202 const startPlay = (post) => { 203 const startPlay = (post) => {
203 - // 确保mockPosts.value是一个数组 204 + // 确保checkinDataList.value是一个数组
204 - if (mockPosts.value) { 205 + if (checkinDataList.value) {
205 // 先暂停所有其他视频 206 // 先暂停所有其他视频
206 - mockPosts.value.forEach(p => { 207 + checkinDataList.value.forEach(p => {
207 p.videoList.forEach(v => { 208 p.videoList.forEach(v => {
208 if (v.id !== post.id) { 209 if (v.id !== post.id) {
209 v.isPlaying = false; 210 v.isPlaying = false;
...@@ -251,7 +252,7 @@ const stopOtherVideos = (currentPlayer, currentPost) => { ...@@ -251,7 +252,7 @@ const stopOtherVideos = (currentPlayer, currentPost) => {
251 } 252 }
252 253
253 // 更新其他帖子的播放状态 254 // 更新其他帖子的播放状态
254 - mockPosts.value.forEach(p => { 255 + checkinDataList.value.forEach(p => {
255 p.videoList.forEach(v => { 256 p.videoList.forEach(v => {
256 if (v.id !== currentPost.id) { 257 if (v.id !== currentPost.id) {
257 v.isPlaying = false; 258 v.isPlaying = false;
...@@ -281,7 +282,7 @@ const stopOtherAudio = (currentPlayer, currentPost) => { ...@@ -281,7 +282,7 @@ const stopOtherAudio = (currentPlayer, currentPost) => {
281 }); 282 });
282 } 283 }
283 // 更新其他帖子的播放状态 284 // 更新其他帖子的播放状态
284 - mockPosts.value.forEach(post => { 285 + checkinDataList.value.forEach(post => {
285 if (post.id!== currentPost.id) { 286 if (post.id!== currentPost.id) {
286 post.isPlaying = false; 287 post.isPlaying = false;
287 } 288 }
...@@ -300,7 +301,7 @@ const stopAllAudio = () => { ...@@ -300,7 +301,7 @@ const stopAllAudio = () => {
300 } 301 }
301 }); 302 });
302 // 更新所有帖子的播放状态 303 // 更新所有帖子的播放状态
303 - mockPosts.value.forEach(post => { 304 + checkinDataList.value.forEach(post => {
304 if (post.audio.length) { 305 if (post.audio.length) {
305 post.isPlaying = false; 306 post.isPlaying = false;
306 } 307 }
...@@ -315,7 +316,7 @@ const stopAllVideos = () => { ...@@ -315,7 +316,7 @@ const stopAllVideos = () => {
315 if (!videoPlayers.value) return; 316 if (!videoPlayers.value) return;
316 317
317 // 更新所有帖子的播放状态 318 // 更新所有帖子的播放状态
318 - mockPosts.value.forEach(p => { 319 + checkinDataList.value.forEach(p => {
319 p.videoList.forEach(v => { 320 p.videoList.forEach(v => {
320 v.isPlaying = false; 321 v.isPlaying = false;
321 }); 322 });
...@@ -338,9 +339,7 @@ const mockPosts = ref([ ...@@ -338,9 +339,7 @@ const mockPosts = ref([
338 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg', 339 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
339 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg', 340 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
340 ], 341 ],
341 - video: '',
342 videoList: [], 342 videoList: [],
343 - videoCover: '',
344 isPlaying: false, 343 isPlaying: false,
345 audio: [], 344 audio: [],
346 likes: 12, 345 likes: 12,
...@@ -367,7 +366,6 @@ const mockPosts = ref([ ...@@ -367,7 +366,6 @@ const mockPosts = ref([
367 videoCover: '', 366 videoCover: '',
368 isPlaying: false, 367 isPlaying: false,
369 }], 368 }],
370 - videoCover: '',
371 isPlaying: false, 369 isPlaying: false,
372 audio: [], 370 audio: [],
373 likes: 12, 371 likes: 12,
...@@ -382,9 +380,7 @@ const mockPosts = ref([ ...@@ -382,9 +380,7 @@ const mockPosts = ref([
382 }, 380 },
383 content: '今天完成了React基础课程的学习,收获满满!', 381 content: '今天完成了React基础课程的学习,收获满满!',
384 images: [], 382 images: [],
385 - video: '',
386 videoList: [], 383 videoList: [],
387 - videoCover: '',
388 isPlaying: false, 384 isPlaying: false,
389 audio: [ 385 audio: [
390 { 386 {
...@@ -412,7 +408,6 @@ const mockPosts = ref([ ...@@ -412,7 +408,6 @@ const mockPosts = ref([
412 }, 408 },
413 content: '今天完成了React基础课程的学习,收获满满!', 409 content: '今天完成了React基础课程的学习,收获满满!',
414 images: [], 410 images: [],
415 - video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
416 videoList: [{ 411 videoList: [{
417 id: 3, 412 id: 3,
418 video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4', 413 video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
...@@ -424,7 +419,6 @@ const mockPosts = ref([ ...@@ -424,7 +419,6 @@ const mockPosts = ref([
424 videoCover: '', 419 videoCover: '',
425 isPlaying: false, 420 isPlaying: false,
426 }], 421 }],
427 - videoCover: '',
428 isPlaying: false, 422 isPlaying: false,
429 audio: [], 423 audio: [],
430 likes: 12, 424 likes: 12,
...@@ -439,9 +433,7 @@ const mockPosts = ref([ ...@@ -439,9 +433,7 @@ const mockPosts = ref([
439 }, 433 },
440 content: '今天完成了React基础课程的学习,收获满满!', 434 content: '今天完成了React基础课程的学习,收获满满!',
441 images: [], 435 images: [],
442 - video: '',
443 videoList: [], 436 videoList: [],
444 - videoCover: '',
445 isPlaying: false, 437 isPlaying: false,
446 audio: [ 438 audio: [
447 { 439 {
...@@ -461,7 +453,7 @@ const themeVars = { ...@@ -461,7 +453,7 @@ const themeVars = {
461 } 453 }
462 454
463 const progress1 = ref(0); 455 const progress1 = ref(0);
464 -const progress2 = ref(76); 456 +// const progress2 = ref(76);
465 457
466 const teamAvatars = ref([]) 458 const teamAvatars = ref([])
467 459
...@@ -485,7 +477,7 @@ const formatter = (day) => { ...@@ -485,7 +477,7 @@ const formatter = (day) => {
485 const month = day.date.getMonth() + 1; 477 const month = day.date.getMonth() + 1;
486 const date = day.date.getDate(); 478 const date = day.date.getDate();
487 479
488 - let checkin_days = [1, 2]; 480 + let checkin_days = myCheckinDates.value;
489 481
490 if (month === 6) { 482 if (month === 6) {
491 if (checkin_days.includes(date)) { 483 if (checkin_days.includes(date)) {
...@@ -511,10 +503,32 @@ const onClickSubtitle = (evt) => { ...@@ -511,10 +503,32 @@ const onClickSubtitle = (evt) => {
511 } 503 }
512 504
513 const goToCheckinImagePage = () => { 505 const goToCheckinImagePage = () => {
514 - router.push('/checkin/image'); 506 + router.push({
507 + path: '/checkin/image',
508 + query: {
509 + id: route.query.id,
510 + type: 'image'
511 + }
512 + })
515 } 513 }
516 -const goToCheckinFilePage = (type) => { 514 +const goToCheckinVideoPage = (type) => {
517 - router.push('/checkin/file?type=' + type); 515 + router.push({
516 + path: '/checkin/video',
517 + query: {
518 + id: route.query.id,
519 + type: 'video',
520 + }
521 + })
522 +}
523 +
524 +const goToCheckinAudioPage = (type) => {
525 + router.push({
526 + path: '/checkin/audio',
527 + query: {
528 + id: route.query.id,
529 + type: 'audio',
530 + }
531 + })
518 } 532 }
519 533
520 const handLike = (post) => { 534 const handLike = (post) => {
...@@ -522,15 +536,22 @@ const handLike = (post) => { ...@@ -522,15 +536,22 @@ const handLike = (post) => {
522 // TODO: 调用接口 536 // TODO: 调用接口
523 } 537 }
524 538
525 -const editCheckin = () => { 539 +const editCheckin = (type) => {
526 - let type = 'image';
527 if (type === 'image') { 540 if (type === 'image') {
528 router.push({ 541 router.push({
529 path: '/checkin/image', 542 path: '/checkin/image',
543 + query: {
544 + id: route.query.id,
545 + type,
546 + }
530 }) 547 })
531 } else { 548 } else {
532 router.push({ 549 router.push({
533 path: '/checkin/file', 550 path: '/checkin/file',
551 + query: {
552 + id: route.query.id,
553 + type,
554 + }
534 }) 555 })
535 } 556 }
536 } 557 }
...@@ -554,14 +575,73 @@ const delCheckin = () => { ...@@ -554,14 +575,73 @@ const delCheckin = () => {
554 } 575 }
555 576
556 const taskDetail = ref({}); 577 const taskDetail = ref({});
578 +const myCheckinDates = ref([]);
579 +const checkinDataList = ref([]);
557 580
558 onMounted(async () => { 581 onMounted(async () => {
559 - const { code, data } = await getTaskDetailAPI({ id: route.query.id, month: dayjs().format('YYYY-MM') }); 582 + const { code, data } = await getTaskDetailAPI({ i: route.query.id, month: dayjs().format('YYYY-MM') });
560 if (code) { 583 if (code) {
561 console.warn(data); 584 console.warn(data);
562 taskDetail.value = data; 585 taskDetail.value = data;
563 progress1.value = (data.checkin_number/data.target_number)*100 ; 586 progress1.value = (data.checkin_number/data.target_number)*100 ;
564 teamAvatars.value = data.checkin_avatars; 587 teamAvatars.value = data.checkin_avatars;
588 + // 获取当前用户的打卡日期
589 + myCheckinDates.value = data.my_checkin_dates;
590 + // 把['2025-06-06'] 转化为 [6] 只取日期去掉0
591 + myCheckinDates.value = myCheckinDates.value.map(date => {
592 + return dayjs(date).date();
593 + })
594 + }
595 + // 获取打卡动态列表
596 + const task_list = await getUploadTaskListAPI({ task_id: route.query.id, date: dayjs().format('YYYY-MM-DD'), limit: 999 });
597 + if (task_list.code) {
598 + console.warn(task_list.data?.checkin_list);
599 + // 整理数据结构
600 + task_list.data?.checkin_list.forEach((item, index) => {
601 + let images = [];
602 + let audio = [];
603 + let videoList = [];
604 + if (item.file_type === 'image') {
605 + images = item.files.map(file => {
606 + return file.value;
607 + });
608 + } else if (item.file_type === 'video') {
609 + videoList = item.files.map(file => {
610 + return {
611 + id: file.meta_id,
612 + video: file.value,
613 + videoCover: file.cover,
614 + isPlaying: false,
615 + }
616 + })
617 + } else if (item.file_type === 'audio') {
618 + audio = item.files.map(file => {
619 + return {
620 + title: file.title ? file.title : '打卡音频',
621 + artist: file.artist ? file.artist : '',
622 + url: file.value,
623 + cover: file.cover ? file.cover : '',
624 + }
625 + })
626 + }
627 + checkinDataList.value.push({
628 + id: item.id,
629 + task_id: item.task_id,
630 + user: {
631 + name: item.username,
632 + avatar: item.avatar,
633 + time: item.created_time_desc,
634 + },
635 + content: item.note,
636 + images,
637 + videoList,
638 + audio,
639 + isPlaying: false,
640 + likes: item.like_count,
641 + is_liked: item.is_like,
642 + is_my: item.is_my,
643 + })
644 + })
565 } 645 }
566 }) 646 })
567 </script> 647 </script>
......
1 +<!--
2 + * @Date: 2025-06-03 09:41:41
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-06-06 15:51:31
5 + * @FilePath: /mlaj/src/views/checkin/upload/audio.vue
6 + * @Description: 音视频文件上传组件
7 +-->
8 +<template>
9 + <div class="checkin-upload-file p-4">
10 + <!-- 文件上传区域 -->
11 + <div class="mb-4">
12 + <van-uploader
13 + v-model="fileList"
14 + :max-count="max_count"
15 + :max-size="20 * 1024 * 1024"
16 + :before-read="beforeRead"
17 + :after-read="afterRead"
18 + @delete="onDelete"
19 + multiple
20 + accept="audio/*"
21 + result-type="file"
22 + upload-icon="plus"
23 + >
24 + <template #upload-text>
25 + <!-- :accept="route.query.type === 'video' ? 'video/*' : 'audio/*'" -->
26 + <div class="text-center">
27 + <van-icon name="plus" size="24" />
28 + <div class="mt-1 text-sm text-gray-600">上传文件</div>
29 + </div>
30 + </template>
31 + </van-uploader>
32 + <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
33 + <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;音频文件</div>
34 + </div>
35 +
36 + <!-- 文字留言区域 -->
37 + <div class="mb-4 border">
38 + <van-field
39 + v-model="message"
40 + rows="4"
41 + autosize
42 + type="textarea"
43 + placeholder="请输入打卡留言"
44 + />
45 + </div>
46 +
47 + <!-- 提交按钮 -->
48 + <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
49 + <van-button
50 + type="primary"
51 + block
52 + :loading="uploading"
53 + :disabled="!canSubmit"
54 + @click="onSubmit"
55 + >
56 + 提交
57 + </van-button>
58 + </div>
59 +
60 + <!-- 上传加载遮罩 -->
61 + <van-overlay :show="loading">
62 + <div class="wrapper" @click.stop>
63 + <van-loading vertical color="#FFFFFF">上传中...</van-loading>
64 + </div>
65 + </van-overlay>
66 + </div>
67 +</template>
68 +
69 +<script setup>
70 +import { ref, computed } from 'vue'
71 +import { useRoute, useRouter } from 'vue-router'
72 +import { showToast, showLoadingToast } from 'vant'
73 +import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 +import { addUploadTaskAPI } from "@/api/checkin";
75 +import BMF from 'browser-md5-file'
76 +import _ from 'lodash'
77 +import { useTitle } from '@vueuse/core';
78 +import { useAuth } from '@/contexts/auth'
79 +
80 +const route = useRoute()
81 +const router = useRouter()
82 +const { currentUser } = useAuth()
83 +useTitle(route.meta.title);
84 +
85 +const max_count = ref(5);
86 +
87 +// 文件列表
88 +const fileList = ref([])
89 +// 留言内容
90 +const message = ref('')
91 +// 上传状态
92 +const uploading = ref(false)
93 +// 上传loading
94 +const loading = ref(false)
95 +
96 +// 是否可以提交
97 +const canSubmit = computed(() => {
98 + return fileList.value.length > 0 && message.value.trim() !== ''
99 +})
100 +
101 +// 文件校验
102 +const beforeRead = (file) => {
103 + let flag = true
104 +
105 + if (Array.isArray(file)) {
106 + // 多个文件
107 + const invalidTypes = file.filter(item => {
108 + const fileType = item.type.toLowerCase();
109 + return !fileType.startsWith('audio/');
110 + })
111 + if (invalidTypes.length) {
112 + flag = false
113 + showToast('请上传音频文件')
114 + }
115 + if (fileList.value.length + file.length > max_count.value) {
116 + flag = false
117 + showToast(`最大上传数量为${max_count.value}个`)
118 + }
119 + } else {
120 + const fileType = file.type.toLowerCase();
121 + if (!fileType.startsWith('audio/')) {
122 + showToast('请上传音频文件')
123 + flag = false
124 + }
125 + if (fileList.value.length + 1 > max_count.value) {
126 + flag = false
127 + showToast(`最大上传数量为${max_count.value}个`)
128 + }
129 + if ((file.size / 1024 / 1024).toFixed(2) > 20) {
130 + flag = false
131 + showToast('最大文件体积为20MB')
132 + }
133 + }
134 + return flag
135 +}
136 +
137 +// 获取文件MD5
138 +const getFileMD5 = (file) => {
139 + return new Promise((resolve, reject) => {
140 + const bmf = new BMF()
141 + bmf.md5(file, (err, md5) => {
142 + if (err) {
143 + reject(err)
144 + return
145 + }
146 + resolve(md5)
147 + })
148 + })
149 +}
150 +
151 +// 上传到七牛云
152 +const uploadToQiniu = async (file, token, fileName) => {
153 + const formData = new FormData()
154 + formData.append('file', file)
155 + formData.append('token', token)
156 + formData.append('key', fileName)
157 +
158 + const config = {
159 + headers: { 'Content-Type': 'multipart/form-data' }
160 + }
161 +
162 + // 根据协议选择上传地址
163 + const qiniuUploadUrl = window.location.protocol === 'https:'
164 + ? 'https://up.qbox.me'
165 + : 'http://upload.qiniu.com'
166 +
167 + return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
168 +}
169 +
170 +// 处理单个文件上传
171 +const handleUpload = async (file) => {
172 + loading.value = true
173 + try {
174 + // 获取MD5值
175 + const md5 = await getFileMD5(file.file)
176 +
177 + // 获取七牛token
178 + const tokenResult = await qiniuTokenAPI({
179 + name: file.file.name,
180 + hash: md5
181 + })
182 +
183 + // 文件已存在,直接返回
184 + if (tokenResult.data) {
185 + return tokenResult.data
186 + }
187 +
188 + // 新文件上传
189 + if (tokenResult.token) {
190 + const suffix = /.[^.]+$/.exec(file.file.name) || ''
191 + const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
192 +
193 + const { filekey } = await uploadToQiniu(
194 + file.file,
195 + tokenResult.token,
196 + fileName
197 + )
198 +
199 + if (filekey) {
200 + // 保存文件信息
201 + const { data } = await saveFileAPI({
202 + name: file.file.name,
203 + filekey,
204 + hash: md5
205 + })
206 + return data
207 + }
208 + }
209 + return null
210 + } catch (error) {
211 + console.error('Upload error:', error)
212 + return null
213 + } finally {
214 + loading.value = false
215 + }
216 +}
217 +
218 +// 文件读取后的处理
219 +const afterRead = async (file) => {
220 + if (Array.isArray(file)) {
221 + // 多文件上传
222 + for (const item of file) {
223 + item.status = 'uploading'
224 + item.message = '上传中...'
225 + const result = await handleUpload(item)
226 + if (result) {
227 + item.status = 'done'
228 + item.message = '上传成功'
229 + item.url = result.url
230 + item.meta_id = result.meta_id
231 + } else {
232 + item.status = 'failed'
233 + item.message = '上传失败'
234 + showToast('上传失败,请重试')
235 + }
236 + }
237 + } else {
238 + // 单文件上传
239 + file.status = 'uploading'
240 + file.message = '上传中...'
241 + const result = await handleUpload(file)
242 + if (result) {
243 + file.status = 'done'
244 + file.message = '上传成功'
245 + file.url = result.url
246 + file.meta_id = result.meta_id
247 + } else {
248 + file.status = 'failed'
249 + file.message = '上传失败'
250 + showToast('上传失败,请重试')
251 + }
252 + }
253 +}
254 +
255 +// 删除文件
256 +const onDelete = (file) => {
257 + const index = fileList.value.indexOf(file)
258 + if (index !== -1) {
259 + fileList.value.splice(index, 1)
260 + }
261 +}
262 +
263 +// 提交表单
264 +const onSubmit = async () => {
265 + if (uploading.value) return
266 +
267 + // 检查是否所有文件都上传完成
268 + const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
269 + if (hasUploadingFiles) {
270 + showToast('请等待所有文件上传完成')
271 + return
272 + }
273 +
274 + uploading.value = true
275 + const toast = showLoadingToast({
276 + message: '提交中...',
277 + forbidClick: true,
278 + })
279 +
280 + try {
281 + // 调用提交打卡接口
282 + const { code, data } = await addUploadTaskAPI({
283 + task_id: route.query.id,
284 + note: message.value,
285 + meta_id: fileList.value.map(item => item.meta_id),
286 + file_type: route.query.type,
287 + });
288 + if (code) {
289 + showToast('提交成功')
290 + router.back()
291 + }
292 + } catch (error) {
293 + showToast('提交失败,请重试')
294 + } finally {
295 + toast.close()
296 + uploading.value = false
297 + }
298 +}
299 +</script>
300 +
301 +<style lang="less" scoped>
302 +.checkin-upload-file {
303 + min-height: 100vh;
304 + padding-bottom: 80px;
305 +}
306 +
307 +.wrapper {
308 + display: flex;
309 + align-items: center;
310 + justify-content: center;
311 + height: 100%;
312 +}
313 +</style>
...@@ -56,6 +56,7 @@ import { ref, computed } from 'vue' ...@@ -56,6 +56,7 @@ import { ref, computed } from 'vue'
56 import { useRoute, useRouter } from 'vue-router' 56 import { useRoute, useRouter } from 'vue-router'
57 import { showToast, showLoadingToast } from 'vant' 57 import { showToast, showLoadingToast } from 'vant'
58 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 58 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
59 +import { addUploadTaskAPI } from "@/api/checkin";
59 import BMF from 'browser-md5-file' 60 import BMF from 'browser-md5-file'
60 import _ from 'lodash' 61 import _ from 'lodash'
61 import { useTitle } from '@vueuse/core'; 62 import { useTitle } from '@vueuse/core';
...@@ -274,11 +275,17 @@ const onSubmit = async () => { ...@@ -274,11 +275,17 @@ const onSubmit = async () => {
274 }) 275 })
275 276
276 try { 277 try {
277 - // TODO: 调用提交打卡接口 278 + // 调用提交打卡接口
278 - await new Promise(resolve => setTimeout(resolve, 1000)) 279 + const { code, data } = await addUploadTaskAPI({
279 - console.warn('提交打卡接口', fileList.value, message.value); 280 + task_id: route.query.id,
280 - showToast('提交成功') 281 + note: message.value,
281 - router.back() 282 + meta_id: fileList.value.map(item => item.meta_id),
283 + file_type: route.query.type,
284 + });
285 + if (code) {
286 + showToast('提交成功')
287 + router.back()
288 + }
282 } catch (error) { 289 } catch (error) {
283 showToast('提交失败,请重试') 290 showToast('提交失败,请重试')
284 } finally { 291 } finally {
......
1 <!-- 1 <!--
2 * @Date: 2025-06-03 09:41:41 2 * @Date: 2025-06-03 09:41:41
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-06 14:38:20 4 + * @LastEditTime: 2025-06-06 15:49:37
5 - * @FilePath: /mlaj/src/views/checkin/upload/file.vue 5 + * @FilePath: /mlaj/src/views/checkin/upload/video.vue
6 * @Description: 音视频文件上传组件 6 * @Description: 音视频文件上传组件
7 --> 7 -->
8 <template> 8 <template>
...@@ -17,11 +17,12 @@ ...@@ -17,11 +17,12 @@
17 :after-read="afterRead" 17 :after-read="afterRead"
18 @delete="onDelete" 18 @delete="onDelete"
19 multiple 19 multiple
20 - :accept="route.query.type === 'video' ? 'video/*' : 'audio/*'" 20 + accept="video/*"
21 result-type="file" 21 result-type="file"
22 upload-icon="plus" 22 upload-icon="plus"
23 > 23 >
24 <template #upload-text> 24 <template #upload-text>
25 + <!-- :accept="route.query.type === 'video' ? 'video/*' : 'audio/*'" -->
25 <div class="text-center"> 26 <div class="text-center">
26 <van-icon name="plus" size="24" /> 27 <van-icon name="plus" size="24" />
27 <div class="mt-1 text-sm text-gray-600">上传文件</div> 28 <div class="mt-1 text-sm text-gray-600">上传文件</div>
...@@ -29,7 +30,7 @@ ...@@ -29,7 +30,7 @@
29 </template> 30 </template>
30 </van-uploader> 31 </van-uploader>
31 <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div> 32 <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
32 - <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ route.query.type === 'video' ? "视频文件" : '音频文件' }}</div> 33 + <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;视频文件</div>
33 </div> 34 </div>
34 35
35 <!-- 文字留言区域 --> 36 <!-- 文字留言区域 -->
...@@ -70,6 +71,7 @@ import { ref, computed } from 'vue' ...@@ -70,6 +71,7 @@ import { ref, computed } from 'vue'
70 import { useRoute, useRouter } from 'vue-router' 71 import { useRoute, useRouter } from 'vue-router'
71 import { showToast, showLoadingToast } from 'vant' 72 import { showToast, showLoadingToast } from 'vant'
72 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 73 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 +import { addUploadTaskAPI } from "@/api/checkin";
73 import BMF from 'browser-md5-file' 75 import BMF from 'browser-md5-file'
74 import _ from 'lodash' 76 import _ from 'lodash'
75 import { useTitle } from '@vueuse/core'; 77 import { useTitle } from '@vueuse/core';
...@@ -104,11 +106,11 @@ const beforeRead = (file) => { ...@@ -104,11 +106,11 @@ const beforeRead = (file) => {
104 // 多个文件 106 // 多个文件
105 const invalidTypes = file.filter(item => { 107 const invalidTypes = file.filter(item => {
106 const fileType = item.type.toLowerCase(); 108 const fileType = item.type.toLowerCase();
107 - return !fileType.startsWith('audio/') && !fileType.startsWith('video/'); 109 + return !fileType.startsWith('video/');
108 }) 110 })
109 if (invalidTypes.length) { 111 if (invalidTypes.length) {
110 flag = false 112 flag = false
111 - showToast('请上传音频或视频文件') 113 + showToast('请上传视频文件')
112 } 114 }
113 if (fileList.value.length + file.length > max_count.value) { 115 if (fileList.value.length + file.length > max_count.value) {
114 flag = false 116 flag = false
...@@ -116,8 +118,8 @@ const beforeRead = (file) => { ...@@ -116,8 +118,8 @@ const beforeRead = (file) => {
116 } 118 }
117 } else { 119 } else {
118 const fileType = file.type.toLowerCase(); 120 const fileType = file.type.toLowerCase();
119 - if (!fileType.startsWith('audio/') && !fileType.startsWith('video/')) { 121 + if (!fileType.startsWith('video/')) {
120 - showToast('请上传音频或视频文件') 122 + showToast('请上传视频文件')
121 flag = false 123 flag = false
122 } 124 }
123 if (fileList.value.length + 1 > max_count.value) { 125 if (fileList.value.length + 1 > max_count.value) {
...@@ -276,10 +278,17 @@ const onSubmit = async () => { ...@@ -276,10 +278,17 @@ const onSubmit = async () => {
276 }) 278 })
277 279
278 try { 280 try {
279 - // TODO: 调用提交打卡接口 281 + // 调用提交打卡接口
280 - await new Promise(resolve => setTimeout(resolve, 1000)) 282 + const { code, data } = await addUploadTaskAPI({
281 - showToast('提交成功') 283 + task_id: route.query.id,
282 - router.back() 284 + note: message.value,
285 + meta_id: fileList.value.map(item => item.meta_id),
286 + file_type: route.query.type,
287 + });
288 + if (code) {
289 + showToast('提交成功')
290 + router.back()
291 + }
283 } catch (error) { 292 } catch (error) {
284 showToast('提交失败,请重试') 293 showToast('提交失败,请重试')
285 } finally { 294 } finally {
......