hookehuyr

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

- 新增图片上传打卡页面及路由配置
- 在打卡首页添加图片上传入口
- 引入lodash库用于文件类型校验
- 添加Vant的Loading和Overlay组件声明
- 实现图片上传功能,包括文件校验、七牛云上传和表单提交
......@@ -28,6 +28,7 @@
"@videojs-player/vue": "^1.0.0",
"browser-md5-file": "^1.1.1",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"swiper": "^11.2.6",
"vant": "^4.9.18",
"vconsole": "^3.15.1",
......
......@@ -44,7 +44,9 @@ declare module 'vue' {
VanImage: typeof import('vant/es')['Image']
VanImagePreview: typeof import('vant/es')['ImagePreview']
VanList: typeof import('vant/es')['List']
VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanOverlay: typeof import('vant/es')['Overlay']
VanPicker: typeof import('vant/es')['Picker']
VanPickerGroup: typeof import('vant/es')['PickerGroup']
VanPopup: typeof import('vant/es')['Popup']
......
/*
* @Date: 2025-03-21 13:28:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-29 15:36:20
* @LastEditTime: 2025-06-03 10:08:44
* @FilePath: /mlaj/src/router/checkin.js
* @Description: 文件描述
*/
......@@ -50,5 +50,14 @@ export default [
title: '打卡首页',
requiresAuth: true
}
},
{
path: '/checkin/image',
name: 'ImageCheckIn',
component: () => import('@/views/checkin/upload/image.vue'),
meta: {
title: '打卡图片',
requiresAuth: true
}
}
]
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-30 20:33:15
* @LastEditTime: 2025-06-03 10:10:09
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
......@@ -47,7 +47,7 @@
<div class="text-wrapper">
<div class="text-header">上传附件</div>
<div style="display: flex; margin: 1rem 0; gap: 1rem;">
<div style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div @click="goToCheckinImagePage" style="text-align: center; border: 1px solid #a2d8a3; border-radius: 5px; padding: 1rem 0; flex: 1;">
<div><van-icon name="photo" size="2.5rem" /></div>
<div style="font-size: 0.85rem;">图文上传</div>
</div>
......@@ -145,6 +145,9 @@ import FrostedGlass from "@/components/ui/FrostedGlass.vue";
import VideoPlayer from "@/components/ui/VideoPlayer.vue";
import AudioPlayer from "@/components/ui/AudioPlayer.vue";
const route = useRoute()
const router = useRouter()
// 存储所有视频播放器的引用
const videoPlayers = ref([]);
......@@ -476,6 +479,10 @@ const formatter = (day) => {
const onClickSubtitle = (evt) => {
console.warn('点击了日期标题');
}
const goToCheckinImagePage = () => {
router.push('/checkin/image');
}
</script>
<style lang="less">
......
<template>
<div class="checkin-upload-image p-4">
<!-- 图片上传区域 -->
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="multiple ? 5 : 1"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
:multiple="multiple"
accept="image/*"
result-type="file"
>
<template #upload-text>
<div class="text-center">
<van-icon name="photograph" 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">最多上传{{ multiple ? '5' : '1' }}张图片,每张不超过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'
const props = defineProps({
multiple: {
type: Boolean,
default: true
}
})
const route = useRoute()
const router = useRouter()
// 文件列表
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 imageTypes = "jpg/jpeg/png";
// 文件类型中文页面显示
const type_text = computed(() => {
// return props.item.component_props.image_type;
return imageTypes;
});
// 文件校验
const beforeRead = (file) => {
const image_types = _.map(imageTypes.split("/"), (item) => `image/${item}`);
let flag = true
if (Array.isArray(file)) {
// 多张图片
const invalidTypes = file.filter(item => !imageTypes.includes(item.type))
if (invalidTypes.length) {
flag = false
showToast('请上传指定格式图片')
}
if (fileList.value.length + file.length > (props.multiple ? 5 : 1)) {
flag = false
showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
}
} else {
if (!imageTypes.includes(file.type)) {
showToast('请上传指定格式图片')
flag = false
}
if (fileList.value.length + 1 > (props.multiple ? 5 : 1)) {
flag = false
showToast(`最大上传数量为${props.multiple ? 5 : 1}张`)
}
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, image_info } = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (filekey) {
// 保存文件信息
const { data } = await saveFileAPI({
name: file.file.name,
filekey,
hash: md5,
height: image_info?.height,
width: image_info?.width
})
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-image {
min-height: 100vh;
padding-bottom: 80px;
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>