Files
front-pc/components/kl-uploader/index.vue
2025-09-13 11:50:44 +08:00

419 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="upload-file">
<el-upload
ref="uploadRef"
v-loading="loading"
v-bind="$attrs"
:file-list="fileList"
drag
:disabled="disabled"
:multiple="limit > 1"
:on-error="handleUploadError"
:on-change="handleChange"
:auto-upload="false"
:show-file-list="listType === 'picture-card' ? true : false"
:list-type="listType"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
>
<slot>
<el-button type="primary" :disabled="disabled">选择文件</el-button>
</slot>
<template v-if="showTip" #tip>
<div class="m-t-10px leading-24px">
<slot name="des">
<div>
<!-- <span>附件内容说明</span> -->
<span class="text-[#999999]">{{ tips }}</span>
</div>
</slot>
<!-- <slot name="tips">
<div>
<span>请上传 大小不超过</span>
<span class="color-#FA5940 m-l-2px m-r-2px">{{ size }}MB</span>
<span>格式为</span>
<span class="color-#FA5940 m-l-2px m-r-2px">{{ fileType.join('/') }}</span>
<span>的文件</span>
</div>
</slot> -->
</div>
</template>
<template v-if="listType === 'picture-card'" #file="{ file }">
<div v-if="!handelFileType(file.name)" class="custom-preview-card">
<div class="file-type-icon cursor-pointer" @click="handlePictureCardDown(file)">
<el-icon :size="32"><Document /></el-icon>
</div>
</div>
</template>
</el-upload>
<div v-if="loading" class="text-12px color-#999">{{ loadingtext }}</div>
<transition-group v-if="listType !== 'picture-card'" class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
<li v-for="(file, index) in fileList" :key="file.uid" class="el-upload-list__item ele-upload-list__item-content">
<div class="ele-upload-list__item-content upload-item">
<div class="curpor-pointer" @click="handlePictureCardDown(file)">
<span class="el-icon-document cursor-pointer pl-9px">{{ file.name }} </span>
</div>
<div class="ele-upload-list__item-content-action">
<el-link :underline="false" type="danger" :disabled="disabled" @click="handleDelete(index)">删除 </el-link>
</div>
</div>
</li>
</transition-group>
<el-image-viewer v-if="dialogVisible" :url-list="[dialogImageUrl]" @close="dialogVisible = false" />
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue'
import { ref } from 'vue'
import { uploadV2, creatFile } from '~/api/common/index'
import { Document } from '@element-plus/icons-vue'
import type { UploadUserFile, UploadInstance } from 'element-plus'
import { accDiv } from '~/utils/utils'
// import CryptoJS from 'crypto-js' // 引入 crypto-js
import dayjs from 'dayjs'
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
/** 文件个数限制 */
limit: {
type: Number,
default: 1,
},
/** 文件大小限制(M) */
size: {
type: Number,
default: 100,
},
/** 文件类型 */
fileType: {
type: Array as PropType<string[]>,
// default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'pdf', 'txt'],
default: () => [],
},
/** 上传地址 */
uploadUrl: {
type: String,
default: '/prod-api/app-api/infra/file/presigned-url',
},
/** 提示文案 */
tips: {
type: String,
default: '提供佐证变更有效的证明文件(邮件/聊天截图/情况说明)',
},
/** 提示文案 */
showTip: {
type: Boolean,
default: true,
},
/** 是否预览cad */
cadShow: {
type: Boolean,
default: false,
},
/** 组件类型 */
listType: {
type: String as PropType<'text' | 'picture' | 'picture-card'>,
default: 'text', // picture" | "text" | "picture-card
},
})
const emit = defineEmits(['validate', 'preview'])
const uploadRef = ref<UploadInstance>()
const fileList = defineModel<any>('fileList', {
default: [],
})
const fileChange = ref()
const handleChange = async (uploadFile: UploadUserFile) => {
if (uploadFile.status !== 'ready') return
/** 上传前校验 */
if (handleBeforeUpload(uploadFile)) {
fileChange.value = uploadFile
// 新增:清除上传组件内部缓存
uploadRef.value?.handleRemove(fileChange.value) // 关键主动触发上传组件的remove方法
return
}
/** 上传 */
await handleUploadFile(uploadFile)
}
/** 上传前校验 */
const handleBeforeUpload = (rawFile: UploadUserFile) => {
if (fileList.value.length + 1 > props.limit) {
handleExceed()
return true
}
if (props.size > 0 && rawFile.size && +accDiv(accDiv(rawFile.size, 1024), 1024) > props.size) {
ElMessage.error(`文件大小不能超过${props.size}M`)
return true
}
const ext = rawFile.name.split('.').pop()?.toLowerCase()
if (ext && props.fileType?.length && !props.fileType.includes(ext)) {
ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`)
return true
}
}
const handleExceed = () => {
ElMessage.error(`最多只能上传 ${props.limit} 个文件`)
}
const handleUploadError = (err: any) => {
ElMessage.error('文件上传失败:' + err.message)
}
/** 上传 */
const loading = ref(false)
const loadingtext = ref('上传中...')
const uploadUrlV2 = ref('')
const fileUrl = ref('')
const configId = ref('')
const path = ref('')
const getUploadConfig = async (options: UploadUserFile) => {
path.value = `${options.uid}/${dayjs(new Date()).format('YYYY/MM/DD/HH/mm/ss')}/${options.name}`
try {
const response = await uploadV2(props.uploadUrl, { path: path.value })
if (response.code !== 0) return ElMessage.error(response.message)
uploadUrlV2.value = response.data.uploadUrl
fileUrl.value = response.data.url
configId.value = response.data.configId
} catch (err) {
loading.value = false
console.error('获取上传配置失败:', err)
alert('获取上传参数失败,请重试')
}
}
/** 上传到服务器 */
const UploadFilled = async (options: UploadUserFile) => {
try {
const res = await creatFile({
configId: Number(configId.value),
path: path.value,
name: options.name,
url: fileUrl.value,
size: options.size as number,
})
if (res.code !== 0) return ElMessage.error(res.message)
ElMessage.success('上传成功')
const newFile = {
name: options?.name,
url: fileUrl.value,
size: options?.size,
uid: options.uid,
title: options?.name,
fileId: res.data,
}
// 更新文件列表
fileList.value = [...fileList.value, newFile]
emit('validate')
} catch (error) {
console.error('获取上传配置失败:', error)
alert('creatFile上传失败请重试')
} finally {
loading.value = false
}
}
/** fetch 监听不了进度 需要改成xhr */
const uploadWithProgress = (url: string, file: any, onProgress: (percent: number) => void) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Content-Type', file.raw.type)
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
onProgress(percent)
}
}
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error('上传失败'))
}
}
xhr.onerror = function () {
reject(new Error('网络错误'))
}
xhr.send(file.raw)
})
}
const handleUploadFile = async (options: UploadUserFile) => {
try {
loading.value = true
await getUploadConfig(options)
// 使用PUT或POST方法上传根据后端要求
// const response = await fetch(uploadUrlV2.value, {
// method: 'PUT', // 或 POST根据后端预签名URL的规则
// body: options.raw,
// headers: {
// 'Content-Type': (options.raw as Blob).type, // 设置文件类型
// },
// // onUploadProgress: (progressEvent: ProgressEvent) => {
// // const percentCompleted = Math.round((progressEvent.loaded / progressEvent.total) * 100)
// // loadingtext.value = `上传中...${percentCompleted}%`
// // },
// })
// if (response.ok) {
// await UploadFilled(options)
// // 此时文件已上传至OSSfileUrl即为访问地址
// console.log('文件访问URL:', fileUrl.value)
// } else {
// loading.value = false
// throw new Error('上传文件失败')
// }
const response = await uploadWithProgress(uploadUrlV2.value, options, (percent) => {
loadingtext.value = `上传中...${percent}%`
})
if (response) {
// 错误处理
loading.value = false
return ElMessage.error(options.name + '上传错误')
}
await UploadFilled(options)
} catch (error) {
loading.value = false
ElMessage.error(options.name + '上传错误')
}
}
/*** 删除 */
const handleDelete = (index: number) => {
fileList.value.splice(index, 1)
emit('validate')
}
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
const handleRemove = (uploadFile: any, uploadFiles: any) => {
console.log(uploadFile, uploadFiles)
fileList.value = fileList.value.filter((item: any) => item.uid !== uploadFile.uid)
emit('validate')
}
const handlePictureCardPreview = (uploadFile: any) => {
dialogImageUrl.value = uploadFile.url
dialogVisible.value = true
}
const handlePictureCardDown = (uploadFile: any) => {
if (props.cadShow) {
emit('preview', uploadFile)
} else {
window.open(uploadFile.url)
}
}
// 生成 MD5 唯一标识符
// const generateMd5 = () => {
// const timestamp = new Date().getTime() // 获取当前时间戳
// const randomString = Math.random().toString(36).substring(2) // 生成随机字符串
// const data = `${timestamp}${randomString}` // 组合时间戳和随机字符串
// return CryptoJS.MD5(data).toString() // 生成 MD5 哈希值
// }
// 判断是否是图片
const handelFileType = (fileName: string) => {
const ext = fileName?.split('.').pop()?.toLowerCase() || ''
return ['png', 'jpg', 'jpeg'].includes(ext)
}
</script>
<style lang="scss" scoped>
.custom-preview-card {
position: relative;
width: 100%;
height: 100%;
.file-type-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -40%);
}
}
.upload-file {
::v-deep(.el-upload-dragger) {
border: none !important;
padding: 0;
text-align: left;
}
}
.upload-file-list .el-upload-list__item {
line-height: 2;
margin-bottom: 10px;
position: relative;
min-width: 361px;
}
.upload-file-list .ele-upload-list__item-content {
display: flex;
justify-content: space-between;
align-items: center;
color: inherit;
}
.upload-item {
border: 1px solid #e4e7ed;
border-radius: 5px;
flex: 1;
word-break: break-all;
}
.ele-upload-list__item-content-action .el-link {
margin-right: 10px;
width: 30px;
}
.el-upload-list--picture-card .el-upload-list__item {
height: 81px !important;
}
// ::v-deep(.el-upload--picture-card) {
// height: 81px !important;
// }
// ::v-deep(.el-upload-list--picture-card) {
// .el-upload-list__item {
// height: 82px !important;
// }
// }
::v-deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 161px;
height: 81px;
// margin: 0 8px 8px 0;
}
.el-upload--picture-card {
width: 161px;
height: 81px;
}
}
::v-deep(.el-loading-spinner) {
.circular {
width: 32px !important;
height: 32px !important;
margin-top: 5px !important;
.path {
stroke-width: 3px;
}
}
}
::v-deep(.el-loading-spinner i) {
font-size: 32px !important;
}
</style>