hookehuyr

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

- 在打卡路由中添加音视频文件上传页面
- 在打卡首页添加音视频上传入口
- 实现音视频文件上传组件,支持多文件上传和校验
- 优化图片上传组件,使用max_count替代multiple属性
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-03 10:08:44 4 + * @LastEditTime: 2025-06-03 11:29:54
5 * @FilePath: /mlaj/src/router/checkin.js 5 * @FilePath: /mlaj/src/router/checkin.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -59,5 +59,14 @@ export default [ ...@@ -59,5 +59,14 @@ export default [
59 title: '打卡图片', 59 title: '打卡图片',
60 requiresAuth: true 60 requiresAuth: true
61 } 61 }
62 - } 62 + },
63 + {
64 + path: '/checkin/file',
65 + name: 'FileCheckIn',
66 + component: () => import('@/views/checkin/upload/file.vue'),
67 + meta: {
68 + title: '打卡视频音频',
69 + requiresAuth: true
70 + }
71 + },
63 ] 72 ]
......
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-03 10:10:09 4 + * @LastEditTime: 2025-06-03 11:30:33
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
51 <div><van-icon name="photo" size="2.5rem" /></div> 51 <div><van-icon name="photo" size="2.5rem" /></div>
52 <div style="font-size: 0.85rem;">图文上传</div> 52 <div style="font-size: 0.85rem;">图文上传</div>
53 </div> 53 </div>
54 - <div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;"> 54 + <div @click="goToCheckinFilePage" style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
55 <div><van-icon name="video" size="2.5rem" /></div> 55 <div><van-icon name="video" size="2.5rem" /></div>
56 <div style="font-size: 0.85rem;">视频/语音</div> 56 <div style="font-size: 0.85rem;">视频/语音</div>
57 </div> 57 </div>
...@@ -483,6 +483,9 @@ const onClickSubtitle = (evt) => { ...@@ -483,6 +483,9 @@ const onClickSubtitle = (evt) => {
483 const goToCheckinImagePage = () => { 483 const goToCheckinImagePage = () => {
484 router.push('/checkin/image'); 484 router.push('/checkin/image');
485 } 485 }
486 +const goToCheckinFilePage = () => {
487 + router.push('/checkin/file');
488 +}
486 </script> 489 </script>
487 490
488 <style lang="less"> 491 <style lang="less">
......
1 +<!--
2 + * @Date: 2025-06-03 09:41:41
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-06-03 09:54:20
5 + * @FilePath: /mlaj/src/views/checkin/upload/file.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/*,video/*"
21 + result-type="file"
22 + >
23 + <template #upload-text>
24 + <div class="text-center">
25 + <van-icon name="plus" size="24" />
26 + <div class="mt-1 text-sm text-gray-600">上传文件</div>
27 + </div>
28 + </template>
29 + </van-uploader>
30 + <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
31 + <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
32 + </div>
33 +
34 + <!-- 文字留言区域 -->
35 + <div class="mb-4">
36 + <van-field
37 + v-model="message"
38 + rows="4"
39 + type="textarea"
40 + placeholder="请输入打卡留言"
41 + />
42 + </div>
43 +
44 + <!-- 提交按钮 -->
45 + <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
46 + <van-button
47 + type="primary"
48 + block
49 + :loading="uploading"
50 + :disabled="!canSubmit"
51 + @click="onSubmit"
52 + >
53 + 提交
54 + </van-button>
55 + </div>
56 +
57 + <!-- 上传加载遮罩 -->
58 + <van-overlay :show="loading">
59 + <div class="wrapper" @click.stop>
60 + <van-loading vertical color="#FFFFFF">上传中...</van-loading>
61 + </div>
62 + </van-overlay>
63 + </div>
64 +</template>
65 +
66 +<script setup>
67 +import { ref, computed } from 'vue'
68 +import { useRoute, useRouter } from 'vue-router'
69 +import { showToast, showLoadingToast } from 'vant'
70 +import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
71 +import BMF from 'browser-md5-file'
72 +import _ from 'lodash'
73 +import { useTitle } from '@vueuse/core';
74 +
75 +const route = useRoute()
76 +const router = useRouter()
77 +useTitle(route.meta.title);
78 +
79 +const max_count = ref(5);
80 +
81 +// 文件列表
82 +const fileList = ref([])
83 +// 留言内容
84 +const message = ref('')
85 +// 上传状态
86 +const uploading = ref(false)
87 +// 上传loading
88 +const loading = ref(false)
89 +
90 +// 是否可以提交
91 +const canSubmit = computed(() => {
92 + return fileList.value.length > 0 && message.value.trim() !== ''
93 +})
94 +
95 +// 固定类型限制
96 +const fileTypes = "audio/video";
97 +
98 +// 文件类型中文页面显示
99 +const type_text = computed(() => {
100 + return "音频/视频文件";
101 +});
102 +
103 +// 文件校验
104 +const beforeRead = (file) => {
105 + let flag = true
106 +
107 + if (Array.isArray(file)) {
108 + // 多个文件
109 + const invalidTypes = file.filter(item => {
110 + const fileType = item.type.toLowerCase();
111 + return !fileType.startsWith('audio/') && !fileType.startsWith('video/');
112 + })
113 + if (invalidTypes.length) {
114 + flag = false
115 + showToast('请上传音频或视频文件')
116 + }
117 + if (fileList.value.length + file.length > max_count.value) {
118 + flag = false
119 + showToast(`最大上传数量为${max_count.value}个`)
120 + }
121 + } else {
122 + const fileType = file.type.toLowerCase();
123 + if (!fileType.startsWith('audio/') && !fileType.startsWith('video/')) {
124 + showToast('请上传音频或视频文件')
125 + flag = false
126 + }
127 + if (fileList.value.length + 1 > max_count.value) {
128 + flag = false
129 + showToast(`最大上传数量为${max_count.value}个`)
130 + }
131 + if ((file.size / 1024 / 1024).toFixed(2) > 20) {
132 + flag = false
133 + showToast('最大文件体积为20MB')
134 + }
135 + }
136 + return flag
137 +}
138 +
139 +// 获取文件MD5
140 +const getFileMD5 = (file) => {
141 + return new Promise((resolve, reject) => {
142 + const bmf = new BMF()
143 + bmf.md5(file, (err, md5) => {
144 + if (err) {
145 + reject(err)
146 + return
147 + }
148 + resolve(md5)
149 + })
150 + })
151 +}
152 +
153 +// 上传到七牛云
154 +const uploadToQiniu = async (file, token, fileName) => {
155 + const formData = new FormData()
156 + formData.append('file', file)
157 + formData.append('token', token)
158 + formData.append('key', fileName)
159 +
160 + const config = {
161 + headers: { 'Content-Type': 'multipart/form-data' }
162 + }
163 +
164 + // 根据协议选择上传地址
165 + const qiniuUploadUrl = window.location.protocol === 'https:'
166 + ? 'https://up.qbox.me'
167 + : 'http://upload.qiniu.com'
168 +
169 + return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
170 +}
171 +
172 +// 处理单个文件上传
173 +const handleUpload = async (file) => {
174 + loading.value = true
175 + try {
176 + // 获取MD5值
177 + const md5 = await getFileMD5(file.file)
178 +
179 + // 获取七牛token
180 + const tokenResult = await qiniuTokenAPI({
181 + name: file.file.name,
182 + hash: md5
183 + })
184 +
185 + // 文件已存在,直接返回
186 + if (tokenResult.data) {
187 + return tokenResult.data
188 + }
189 +
190 + // 新文件上传
191 + if (tokenResult.token) {
192 + const suffix = /.[^.]+$/.exec(file.file.name) || ''
193 + const fileName = `uploadForm/${route.query.code || 'checkin'}/${md5}${suffix}`
194 +
195 + const { filekey } = await uploadToQiniu(
196 + file.file,
197 + tokenResult.token,
198 + fileName
199 + )
200 +
201 + if (filekey) {
202 + // 保存文件信息
203 + const { data } = await saveFileAPI({
204 + name: file.file.name,
205 + filekey,
206 + hash: md5
207 + })
208 + return data
209 + }
210 + }
211 + return null
212 + } catch (error) {
213 + console.error('Upload error:', error)
214 + return null
215 + } finally {
216 + loading.value = false
217 + }
218 +}
219 +
220 +// 文件读取后的处理
221 +const afterRead = async (file) => {
222 + if (Array.isArray(file)) {
223 + // 多文件上传
224 + for (const item of file) {
225 + item.status = 'uploading'
226 + item.message = '上传中...'
227 + const result = await handleUpload(item)
228 + if (result) {
229 + item.status = 'done'
230 + item.message = '上传成功'
231 + item.url = result.url
232 + } else {
233 + item.status = 'failed'
234 + item.message = '上传失败'
235 + showToast('上传失败,请重试')
236 + }
237 + }
238 + } else {
239 + // 单文件上传
240 + file.status = 'uploading'
241 + file.message = '上传中...'
242 + const result = await handleUpload(file)
243 + if (result) {
244 + file.status = 'done'
245 + file.message = '上传成功'
246 + file.url = result.url
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 + // TODO: 调用提交打卡接口
282 + await new Promise(resolve => setTimeout(resolve, 1000))
283 + showToast('提交成功')
284 + router.back()
285 + } catch (error) {
286 + showToast('提交失败,请重试')
287 + } finally {
288 + toast.close()
289 + uploading.value = false
290 + }
291 +}
292 +</script>
293 +
294 +<style lang="less" scoped>
295 +.checkin-upload-file {
296 + min-height: 100vh;
297 + padding-bottom: 80px;
298 +}
299 +
300 +.wrapper {
301 + display: flex;
302 + align-items: center;
303 + justify-content: center;
304 + height: 100%;
305 +}
306 +</style>
...@@ -4,12 +4,12 @@ ...@@ -4,12 +4,12 @@
4 <div class="mb-4"> 4 <div class="mb-4">
5 <van-uploader 5 <van-uploader
6 v-model="fileList" 6 v-model="fileList"
7 - :max-count="multiple ? 5 : 1" 7 + :max-count="max_count"
8 :max-size="20 * 1024 * 1024" 8 :max-size="20 * 1024 * 1024"
9 :before-read="beforeRead" 9 :before-read="beforeRead"
10 :after-read="afterRead" 10 :after-read="afterRead"
11 @delete="onDelete" 11 @delete="onDelete"
12 - :multiple="multiple" 12 + multiple
13 accept="image/*" 13 accept="image/*"
14 result-type="file" 14 result-type="file"
15 > 15 >
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
20 </div> 20 </div>
21 </template> 21 </template>
22 </van-uploader> 22 </van-uploader>
23 - <div class="mt-2 text-xs text-gray-500">最多上传{{ multiple ? '5' : '1' }}张图片,每张不超过20M</div> 23 + <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}张图片,每张不超过20M</div>
24 <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div> 24 <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
25 </div> 25 </div>
26 26
...@@ -63,16 +63,13 @@ import { showToast, showLoadingToast } from 'vant' ...@@ -63,16 +63,13 @@ import { showToast, showLoadingToast } from 'vant'
63 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' 63 import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
64 import BMF from 'browser-md5-file' 64 import BMF from 'browser-md5-file'
65 import _ from 'lodash' 65 import _ from 'lodash'
66 - 66 +import { useTitle } from '@vueuse/core';
67 -const props = defineProps({
68 - multiple: {
69 - type: Boolean,
70 - default: true
71 - }
72 -})
73 67
74 const route = useRoute() 68 const route = useRoute()
75 const router = useRouter() 69 const router = useRouter()
70 +useTitle(route.meta.title);
71 +
72 +const max_count = ref(5);
76 73
77 // 文件列表 74 // 文件列表
78 const fileList = ref([]) 75 const fileList = ref([])
...@@ -104,23 +101,27 @@ const beforeRead = (file) => { ...@@ -104,23 +101,27 @@ const beforeRead = (file) => {
104 101
105 if (Array.isArray(file)) { 102 if (Array.isArray(file)) {
106 // 多张图片 103 // 多张图片
107 - const invalidTypes = file.filter(item => !imageTypes.includes(item.type)) 104 + const invalidTypes = file.filter(item => {
105 + const fileType = item.type.toLowerCase();
106 + return !image_types.some(type => fileType.includes(type.split('/')[1]));
107 + })
108 if (invalidTypes.length) { 108 if (invalidTypes.length) {
109 flag = false 109 flag = false
110 showToast('请上传指定格式图片') 110 showToast('请上传指定格式图片')
111 } 111 }
112 - if (fileList.value.length + file.length > (props.multiple ? 5 : 1)) { 112 + if (fileList.value.length + file.length > max_count.value) {
113 flag = false 113 flag = false
114 - showToast(`最大上传数量为${props.multiple ? 5 : 1}张`) 114 + showToast(`最大上传数量为${max_count.value}张`)
115 } 115 }
116 } else { 116 } else {
117 - if (!imageTypes.includes(file.type)) { 117 + const fileType = file.type.toLowerCase();
118 + if (!image_types.some(type => fileType.includes(type.split('/')[1]))) {
118 showToast('请上传指定格式图片') 119 showToast('请上传指定格式图片')
119 flag = false 120 flag = false
120 } 121 }
121 - if (fileList.value.length + 1 > (props.multiple ? 5 : 1)) { 122 + if (fileList.value.length + 1 > max_count.value) {
122 flag = false 123 flag = false
123 - showToast(`最大上传数量为${props.multiple ? 5 : 1}张`) 124 + showToast(`最大上传数量为${max_count.value}张`)
124 } 125 }
125 if ((file.size / 1024 / 1024).toFixed(2) > 20) { 126 if ((file.size / 1024 / 1024).toFixed(2) > 20) {
126 flag = false 127 flag = false
......