hookehuyr

feat(打卡): 添加图片上传打卡功能

- 新增图片上传打卡页面及路由配置
- 在打卡首页添加图片上传入口
- 引入lodash库用于文件类型校验
- 添加Vant的Loading和Overlay组件声明
- 实现图片上传功能,包括文件校验、七牛云上传和表单提交
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
28 "@videojs-player/vue": "^1.0.0", 28 "@videojs-player/vue": "^1.0.0",
29 "browser-md5-file": "^1.1.1", 29 "browser-md5-file": "^1.1.1",
30 "dayjs": "^1.11.13", 30 "dayjs": "^1.11.13",
31 + "lodash": "^4.17.21",
31 "swiper": "^11.2.6", 32 "swiper": "^11.2.6",
32 "vant": "^4.9.18", 33 "vant": "^4.9.18",
33 "vconsole": "^3.15.1", 34 "vconsole": "^3.15.1",
......
...@@ -44,7 +44,9 @@ declare module 'vue' { ...@@ -44,7 +44,9 @@ declare module 'vue' {
44 VanImage: typeof import('vant/es')['Image'] 44 VanImage: typeof import('vant/es')['Image']
45 VanImagePreview: typeof import('vant/es')['ImagePreview'] 45 VanImagePreview: typeof import('vant/es')['ImagePreview']
46 VanList: typeof import('vant/es')['List'] 46 VanList: typeof import('vant/es')['List']
47 + VanLoading: typeof import('vant/es')['Loading']
47 VanNavBar: typeof import('vant/es')['NavBar'] 48 VanNavBar: typeof import('vant/es')['NavBar']
49 + VanOverlay: typeof import('vant/es')['Overlay']
48 VanPicker: typeof import('vant/es')['Picker'] 50 VanPicker: typeof import('vant/es')['Picker']
49 VanPickerGroup: typeof import('vant/es')['PickerGroup'] 51 VanPickerGroup: typeof import('vant/es')['PickerGroup']
50 VanPopup: typeof import('vant/es')['Popup'] 52 VanPopup: typeof import('vant/es')['Popup']
......
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-05-29 15:36:20 4 + * @LastEditTime: 2025-06-03 10:08:44
5 * @FilePath: /mlaj/src/router/checkin.js 5 * @FilePath: /mlaj/src/router/checkin.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -50,5 +50,14 @@ export default [ ...@@ -50,5 +50,14 @@ export default [
50 title: '打卡首页', 50 title: '打卡首页',
51 requiresAuth: true 51 requiresAuth: true
52 } 52 }
53 + },
54 + {
55 + path: '/checkin/image',
56 + name: 'ImageCheckIn',
57 + component: () => import('@/views/checkin/upload/image.vue'),
58 + meta: {
59 + title: '打卡图片',
60 + requiresAuth: true
61 + }
53 } 62 }
54 ] 63 ]
......
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-05-30 20:33:15 4 + * @LastEditTime: 2025-06-03 10:10:09
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 文件描述 6 * @Description: 文件描述
7 --> 7 -->
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
47 <div class="text-wrapper"> 47 <div class="text-wrapper">
48 <div class="text-header">上传附件</div> 48 <div class="text-header">上传附件</div>
49 <div style="display: flex; margin: 1rem 0; gap: 1rem;"> 49 <div style="display: flex; margin: 1rem 0; gap: 1rem;">
50 - <div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;"> 50 + <div @click="goToCheckinImagePage" style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
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>
...@@ -145,6 +145,9 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue"; ...@@ -145,6 +145,9 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue";
145 import VideoPlayer from "@/components/ui/VideoPlayer.vue"; 145 import VideoPlayer from "@/components/ui/VideoPlayer.vue";
146 import AudioPlayer from "@/components/ui/AudioPlayer.vue"; 146 import AudioPlayer from "@/components/ui/AudioPlayer.vue";
147 147
148 +const route = useRoute()
149 +const router = useRouter()
150 +
148 // 存储所有视频播放器的引用 151 // 存储所有视频播放器的引用
149 const videoPlayers = ref([]); 152 const videoPlayers = ref([]);
150 153
...@@ -476,6 +479,10 @@ const formatter = (day) => { ...@@ -476,6 +479,10 @@ const formatter = (day) => {
476 const onClickSubtitle = (evt) => { 479 const onClickSubtitle = (evt) => {
477 console.warn('点击了日期标题'); 480 console.warn('点击了日期标题');
478 } 481 }
482 +
483 +const goToCheckinImagePage = () => {
484 + router.push('/checkin/image');
485 +}
479 </script> 486 </script>
480 487
481 <style lang="less"> 488 <style lang="less">
......
1 +<template>
2 + <div class="checkin-upload-image p-4">
3 + <!-- 图片上传区域 -->
4 + <div class="mb-4">
5 + <van-uploader
6 + v-model="fileList"
7 + :max-count="multiple ? 5 : 1"
8 + :max-size="20 * 1024 * 1024"
9 + :before-read="beforeRead"
10 + :after-read="afterRead"
11 + @delete="onDelete"
12 + :multiple="multiple"
13 + accept="image/*"
14 + result-type="file"
15 + >
16 + <template #upload-text>
17 + <div class="text-center">
18 + <van-icon name="photograph" size="24" />
19 + <div class="mt-1 text-sm text-gray-600">上传图片</div>
20 + </div>
21 + </template>
22 + </van-uploader>
23 + <div class="mt-2 text-xs text-gray-500">最多上传{{ multiple ? '5' : '1' }}张图片,每张不超过20M</div>
24 + <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
25 + </div>
26 +
27 + <!-- 文字留言区域 -->
28 + <div class="mb-4">
29 + <van-field
30 + v-model="message"
31 + rows="4"
32 + type="textarea"
33 + placeholder="请输入打卡留言"
34 + />
35 + </div>
36 +
37 + <!-- 提交按钮 -->
38 + <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
39 + <van-button
40 + type="primary"
41 + block
42 + :loading="uploading"
43 + :disabled="!canSubmit"
44 + @click="onSubmit"
45 + >
46 + 提交
47 + </van-button>
48 + </div>
49 +
50 + <!-- 上传加载遮罩 -->
51 + <van-overlay :show="loading">
52 + <div class="wrapper" @click.stop>
53 + <van-loading vertical color="#FFFFFF">上传中...</van-loading>
54 + </div>
55 + </van-overlay>
56 + </div>
57 +</template>
58 +
59 +<script setup>
60 +import { ref, computed } from 'vue'
61 +import { useRoute, useRouter } from 'vue-router'
62 +import { showToast, showLoadingToast } from 'vant'
63 +import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
64 +import BMF from 'browser-md5-file'
65 +import _ from 'lodash'
66 +
67 +const props = defineProps({
68 + multiple: {
69 + type: Boolean,
70 + default: true
71 + }
72 +})
73 +
74 +const route = useRoute()
75 +const router = useRouter()
76 +
77 +// 文件列表
78 +const fileList = ref([])
79 +// 留言内容
80 +const message = ref('')
81 +// 上传状态
82 +const uploading = ref(false)
83 +// 上传loading
84 +const loading = ref(false)
85 +
86 +// 是否可以提交
87 +const canSubmit = computed(() => {
88 + return fileList.value.length > 0 && message.value.trim() !== ''
89 +})
90 +
91 +// 固定类型限制
92 +const imageTypes = "jpg/jpeg/png";
93 +
94 +// 文件类型中文页面显示
95 +const type_text = computed(() => {
96 + // return props.item.component_props.image_type;
97 + return imageTypes;
98 +});
99 +
100 +// 文件校验
101 +const beforeRead = (file) => {
102 + const image_types = _.map(imageTypes.split("/"), (item) => `image/${item}`);
103 + let flag = true
104 +
105 + if (Array.isArray(file)) {
106 + // 多张图片
107 + const invalidTypes = file.filter(item => !imageTypes.includes(item.type))
108 + if (invalidTypes.length) {
109 + flag = false
110 + showToast('请上传指定格式图片')
111 + }
112 + if (fileList.value.length + file.length > (props.multiple ? 5 : 1)) {
113 + flag = false
114 + showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
115 + }
116 + } else {
117 + if (!imageTypes.includes(file.type)) {
118 + showToast('请上传指定格式图片')
119 + flag = false
120 + }
121 + if (fileList.value.length + 1 > (props.multiple ? 5 : 1)) {
122 + flag = false
123 + showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
124 + }
125 + if ((file.size / 1024 / 1024).toFixed(2) > 20) {
126 + flag = false
127 + showToast('最大文件体积为20MB')
128 + }
129 + }
130 + return flag
131 +}
132 +
133 +// 获取文件MD5
134 +const getFileMD5 = (file) => {
135 + return new Promise((resolve, reject) => {
136 + const bmf = new BMF()
137 + bmf.md5(file, (err, md5) => {
138 + if (err) {
139 + reject(err)
140 + return
141 + }
142 + resolve(md5)
143 + })
144 + })
145 +}
146 +
147 +// 上传到七牛云
148 +const uploadToQiniu = async (file, token, fileName) => {
149 + const formData = new FormData()
150 + formData.append('file', file)
151 + formData.append('token', token)
152 + formData.append('key', fileName)
153 +
154 + const config = {
155 + headers: { 'Content-Type': 'multipart/form-data' }
156 + }
157 +
158 + // 根据协议选择上传地址
159 + const qiniuUploadUrl = window.location.protocol === 'https:'
160 + ? 'https://up.qbox.me'
161 + : 'http://upload.qiniu.com'
162 +
163 + return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
164 +}
165 +
166 +// 处理单个文件上传
167 +const handleUpload = async (file) => {
168 + loading.value = true
169 + try {
170 + // 获取MD5值
171 + const md5 = await getFileMD5(file.file)
172 +
173 + // 获取七牛token
174 + const tokenResult = await qiniuTokenAPI({
175 + name: file.file.name,
176 + hash: md5
177 + })
178 +
179 + // 文件已存在,直接返回
180 + if (tokenResult.data) {
181 + return tokenResult.data
182 + }
183 +
184 + // 新文件上传
185 + if (tokenResult.token) {
186 + const suffix = /.[^.]+$/.exec(file.file.name) || ''
187 + const fileName = `uploadForm/${route.query.code || 'checkin'}/${md5}${suffix}`
188 +
189 + const { filekey, image_info } = await uploadToQiniu(
190 + file.file,
191 + tokenResult.token,
192 + fileName
193 + )
194 +
195 + if (filekey) {
196 + // 保存文件信息
197 + const { data } = await saveFileAPI({
198 + name: file.file.name,
199 + filekey,
200 + hash: md5,
201 + height: image_info?.height,
202 + width: image_info?.width
203 + })
204 + return data
205 + }
206 + }
207 + return null
208 + } catch (error) {
209 + console.error('Upload error:', error)
210 + return null
211 + } finally {
212 + loading.value = false
213 + }
214 +}
215 +
216 +// 文件读取后的处理
217 +const afterRead = async (file) => {
218 + if (Array.isArray(file)) {
219 + // 多文件上传
220 + for (const item of file) {
221 + item.status = 'uploading'
222 + item.message = '上传中...'
223 + const result = await handleUpload(item)
224 + if (result) {
225 + item.status = 'done'
226 + item.message = '上传成功'
227 + item.url = result.url
228 + } else {
229 + item.status = 'failed'
230 + item.message = '上传失败'
231 + showToast('上传失败,请重试')
232 + }
233 + }
234 + } else {
235 + // 单文件上传
236 + file.status = 'uploading'
237 + file.message = '上传中...'
238 + const result = await handleUpload(file)
239 + if (result) {
240 + file.status = 'done'
241 + file.message = '上传成功'
242 + file.url = result.url
243 + } else {
244 + file.status = 'failed'
245 + file.message = '上传失败'
246 + showToast('上传失败,请重试')
247 + }
248 + }
249 +}
250 +
251 +// 删除文件
252 +const onDelete = (file) => {
253 + const index = fileList.value.indexOf(file)
254 + if (index !== -1) {
255 + fileList.value.splice(index, 1)
256 + }
257 +}
258 +
259 +// 提交表单
260 +const onSubmit = async () => {
261 + if (uploading.value) return
262 +
263 + // 检查是否所有文件都上传完成
264 + const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
265 + if (hasUploadingFiles) {
266 + showToast('请等待所有文件上传完成')
267 + return
268 + }
269 +
270 + uploading.value = true
271 + const toast = showLoadingToast({
272 + message: '提交中...',
273 + forbidClick: true,
274 + })
275 +
276 + try {
277 + // TODO: 调用提交打卡接口
278 + await new Promise(resolve => setTimeout(resolve, 1000))
279 + showToast('提交成功')
280 + router.back()
281 + } catch (error) {
282 + showToast('提交失败,请重试')
283 + } finally {
284 + toast.close()
285 + uploading.value = false
286 + }
287 +}
288 +</script>
289 +
290 +<style lang="less" scoped>
291 +.checkin-upload-image {
292 + min-height: 100vh;
293 + padding-bottom: 80px;
294 +}
295 +
296 +.wrapper {
297 + display: flex;
298 + align-items: center;
299 + justify-content: center;
300 + height: 100%;
301 +}
302 +</style>