hookehuyr

feat(打卡): 添加音视频文件上传功能

- 在打卡路由中添加音视频文件上传页面
- 在打卡首页添加音视频上传入口
- 实现音视频文件上传组件,支持多文件上传和校验
- 优化图片上传组件,使用max_count替代multiple属性
/*
* @Date: 2025-03-21 13:28:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 10:08:44
* @LastEditTime: 2025-06-03 11:29:54
* @FilePath: /mlaj/src/router/checkin.js
* @Description: 文件描述
*/
......@@ -59,5 +59,14 @@ export default [
title: '打卡图片',
requiresAuth: true
}
}
},
{
path: '/checkin/file',
name: 'FileCheckIn',
component: () => import('@/views/checkin/upload/file.vue'),
meta: {
title: '打卡视频音频',
requiresAuth: true
}
},
]
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 10:10:09
* @LastEditTime: 2025-06-03 11:30:33
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
......@@ -51,7 +51,7 @@
<div><van-icon name="photo" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">图文上传</div>
</div>
<div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div @click="goToCheckinFilePage" style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div><van-icon name="video" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">视频/语音</div>
</div>
......@@ -483,6 +483,9 @@ const onClickSubtitle = (evt) => {
const goToCheckinImagePage = () => {
router.push('/checkin/image');
}
const goToCheckinFilePage = () => {
router.push('/checkin/file');
}
</script>
<style lang="less">
......
<!--
* @Date: 2025-06-03 09:41:41
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-03 09:54:20
* @FilePath: /mlaj/src/views/checkin/upload/file.vue
* @Description: 音视频文件上传组件
-->
<template>
<div class="checkin-upload-file p-4">
<!-- 文件上传区域 -->
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="max_count"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
multiple
accept="audio/*,video/*"
result-type="file"
>
<template #upload-text>
<div class="text-center">
<van-icon name="plus" size="24" />
<div class="mt-1 text-sm text-gray-600">上传文件</div>
</div>
</template>
</van-uploader>
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
</div>
<!-- 文字留言区域 -->
<div class="mb-4">
<van-field
v-model="message"
rows="4"
type="textarea"
placeholder="请输入打卡留言"
/>
</div>
<!-- 提交按钮 -->
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
<van-button
type="primary"
block
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
提交
</van-button>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading">
<div class="wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import BMF from 'browser-md5-file'
import _ from 'lodash'
import { useTitle } from '@vueuse/core';
const route = useRoute()
const router = useRouter()
useTitle(route.meta.title);
const max_count = ref(5);
// 文件列表
const fileList = ref([])
// 留言内容
const message = ref('')
// 上传状态
const uploading = ref(false)
// 上传loading
const loading = ref(false)
// 是否可以提交
const canSubmit = computed(() => {
return fileList.value.length > 0 && message.value.trim() !== ''
})
// 固定类型限制
const fileTypes = "audio/video";
// 文件类型中文页面显示
const type_text = computed(() => {
return "音频/视频文件";
});
// 文件校验
const beforeRead = (file) => {
let flag = true
if (Array.isArray(file)) {
// 多个文件
const invalidTypes = file.filter(item => {
const fileType = item.type.toLowerCase();
return !fileType.startsWith('audio/') && !fileType.startsWith('video/');
})
if (invalidTypes.length) {
flag = false
showToast('请上传音频或视频文件')
}
if (fileList.value.length + file.length > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
} else {
const fileType = file.type.toLowerCase();
if (!fileType.startsWith('audio/') && !fileType.startsWith('video/')) {
showToast('请上传音频或视频文件')
flag = false
}
if (fileList.value.length + 1 > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
if ((file.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
showToast('最大文件体积为20MB')
}
}
return flag
}
// 获取文件MD5
const getFileMD5 = (file) => {
return new Promise((resolve, reject) => {
const bmf = new BMF()
bmf.md5(file, (err, md5) => {
if (err) {
reject(err)
return
}
resolve(md5)
})
})
}
// 上传到七牛云
const uploadToQiniu = async (file, token, fileName) => {
const formData = new FormData()
formData.append('file', file)
formData.append('token', token)
formData.append('key', fileName)
const config = {
headers: { 'Content-Type': 'multipart/form-data' }
}
// 根据协议选择上传地址
const qiniuUploadUrl = window.location.protocol === 'https:'
? 'https://up.qbox.me'
: 'http://upload.qiniu.com'
return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
}
// 处理单个文件上传
const handleUpload = async (file) => {
loading.value = true
try {
// 获取MD5值
const md5 = await getFileMD5(file.file)
// 获取七牛token
const tokenResult = await qiniuTokenAPI({
name: file.file.name,
hash: md5
})
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
}
// 新文件上传
if (tokenResult.token) {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
const fileName = `uploadForm/${route.query.code || 'checkin'}/${md5}${suffix}`
const { filekey } = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (filekey) {
// 保存文件信息
const { data } = await saveFileAPI({
name: file.file.name,
filekey,
hash: md5
})
return data
}
}
return null
} catch (error) {
console.error('Upload error:', error)
return null
} finally {
loading.value = false
}
}
// 文件读取后的处理
const afterRead = async (file) => {
if (Array.isArray(file)) {
// 多文件上传
for (const item of file) {
item.status = 'uploading'
item.message = '上传中...'
const result = await handleUpload(item)
if (result) {
item.status = 'done'
item.message = '上传成功'
item.url = result.url
} else {
item.status = 'failed'
item.message = '上传失败'
showToast('上传失败,请重试')
}
}
} else {
// 单文件上传
file.status = 'uploading'
file.message = '上传中...'
const result = await handleUpload(file)
if (result) {
file.status = 'done'
file.message = '上传成功'
file.url = result.url
} else {
file.status = 'failed'
file.message = '上传失败'
showToast('上传失败,请重试')
}
}
}
// 删除文件
const onDelete = (file) => {
const index = fileList.value.indexOf(file)
if (index !== -1) {
fileList.value.splice(index, 1)
}
}
// 提交表单
const onSubmit = async () => {
if (uploading.value) return
// 检查是否所有文件都上传完成
const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
if (hasUploadingFiles) {
showToast('请等待所有文件上传完成')
return
}
uploading.value = true
const toast = showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
// TODO: 调用提交打卡接口
await new Promise(resolve => setTimeout(resolve, 1000))
showToast('提交成功')
router.back()
} catch (error) {
showToast('提交失败,请重试')
} finally {
toast.close()
uploading.value = false
}
}
</script>
<style lang="less" scoped>
.checkin-upload-file {
min-height: 100vh;
padding-bottom: 80px;
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
......@@ -4,12 +4,12 @@
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="multiple ? 5 : 1"
:max-count="max_count"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
:multiple="multiple"
multiple
accept="image/*"
result-type="file"
>
......@@ -20,7 +20,7 @@
</div>
</template>
</van-uploader>
<div class="mt-2 text-xs text-gray-500">最多上传{{ multiple ? '5' : '1' }}张图片,每张不超过20M</div>
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}张图片,每张不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
</div>
......@@ -63,16 +63,13 @@ import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import BMF from 'browser-md5-file'
import _ from 'lodash'
const props = defineProps({
multiple: {
type: Boolean,
default: true
}
})
import { useTitle } from '@vueuse/core';
const route = useRoute()
const router = useRouter()
useTitle(route.meta.title);
const max_count = ref(5);
// 文件列表
const fileList = ref([])
......@@ -104,23 +101,27 @@ const beforeRead = (file) => {
if (Array.isArray(file)) {
// 多张图片
const invalidTypes = file.filter(item => !imageTypes.includes(item.type))
const invalidTypes = file.filter(item => {
const fileType = item.type.toLowerCase();
return !image_types.some(type => fileType.includes(type.split('/')[1]));
})
if (invalidTypes.length) {
flag = false
showToast('请上传指定格式图片')
}
if (fileList.value.length + file.length > (props.multiple ? 5 : 1)) {
if (fileList.value.length + file.length > max_count.value) {
flag = false
showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
showToast(`最大上传数量为${max_count.value}张`)
}
} else {
if (!imageTypes.includes(file.type)) {
const fileType = file.type.toLowerCase();
if (!image_types.some(type => fileType.includes(type.split('/')[1]))) {
showToast('请上传指定格式图片')
flag = false
}
if (fileList.value.length + 1 > (props.multiple ? 5 : 1)) {
if (fileList.value.length + 1 > max_count.value) {
flag = false
showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
showToast(`最大上传数量为${max_count.value}张`)
}
if ((file.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
......