<script lang="ts" setup name="upload-oss">
import type { UploadFile, UploadFiles, UploadProps, UploadRawFile, UploadRequestOptions } from 'element-plus'
import { Delete, Document, Plus, Upload, ZoomIn } from '@element-plus/icons-vue'
import { useGetOssPrivateUrlList, useOssUpload } from '@fl/api/anjutong'
import { useMallOssUpload } from '@fl/api/supplier'
import { ALLOW_CONTENT_TYPE_DICT } from '@fl/constants'
import { useMessage } from '@fl/hooks/web/use-message'
import { cloneDeep, isEmpty, isString } from 'lodash-es'
// 本文件只允许一张张勾选图片, 不准多选
type IProps = {
accept?: string // 允许上传的文件类型, * 代表所有文件类型
disabled?: boolean
fileSize?: number // 限制的大小(MB)
hidePlusIcon?: boolean
isMallAdmin?: boolean // 是否为商城运营端, 商城运营端和非运营是两个不同的接口调用
isPrivate?: boolean // 默认公域
isVideo?: boolean
limit?: number // 文件个数
listType?: 'picture-card' | 'text' // 覆盖默认 list-type, 没有 picture 类型, 加入 video 类型
tips?: boolean | string // 提示语
}
const props = withDefaults(defineProps<IProps>(), {
accept: '.jpg,.jpeg,.png,.webp',
disabled: false,
fileSize: 5, // MB
hidePlusIcon: false,
isPrivate: false,
isVideo: false,
limit: 1,
listType: 'picture-card',
tips: false,
})
const emits = defineEmits(['success', 'remove'])
const { createErrorModal } = useMessage()
const { mutate: mutateMall } = useMallOssUpload()
const { mutate: mutateOssUpload } = useOssUpload()
const ONE_Megabit = 1024 * 1024
const attrs = useAttrs() as UploadProps
const getBindValue = computed(() => {
const obj = {
...attrs,
...props,
}
return obj
})
const { accept, fileSize, hidePlusIcon, isMallAdmin, isPrivate, isVideo, limit, listType, tips } = toRefs(getBindValue.value)
const isText = computed(() => listType.value === 'text')
const fileTypeText = computed(() => isText.value ? '文件' : '图片')
// 默认提示, 用户可以覆盖
const finalTips = computed(() => {
// 如果存在 tips
if (tips.value) {
// 进一步判断是否为字符串
if (isString(tips.value)) {
return tips.value
}
if (accept.value === '*') {
return `支持任意格式,所选${fileTypeText.value}大小不能超过 ${fileSize.value} MB,最多上传 ${limit.value} 个文件`
}
return `支持扩展名: ${accept.value},所选${fileTypeText.value}大小不能超过 ${fileSize.value} MB,最多上传 ${limit.value} 个文件`
}
return false
})
const hiddenAdd = computed(() => {
// 传普通文件时, 始终显示上传按钮
if (isText.value) {
return false
}
// 如果为视频, 只能一次传一个
if (isVideo.value) {
return true
}
// 上传数量超限
if (fileList.value.length >= limit.value!) {
return true
}
return false
})
const hidePlusIconFirstTime = computed(() => {
if (hidePlusIcon.value && fileList.value.length === 0) {
return true
}
else {
return false
}
})
// 外部传入的文件 url
// 如果是多个文件, 用逗号分割;
// 私域文件只有后面半截, 需要一个拼接流程
type ISingleFile = { name: string, url: string }
const model = defineModel<ISingleFile[] | string>({ required: true })
const modelIsString = computed(() => {
return isString(model.value)
})
const initialModelValue = cloneDeep(model.value)
// 回显的文件列表
const fileList = ref<UploadFiles>([] as UploadFiles)
// 初始传入的图片回显
onMounted(() => {
// 类型守卫
const vStr = initialModelValue as string
const vArr = initialModelValue as ISingleFile[]
if (modelIsString.value) {
const urlList = vStr ? vStr.split(',') : []
fileList.value = urlList.map(
(fullPath: string) => ({
name: fullPath,
url: fullPath,
validUrl: fullPath,
}),
) as any
}
else {
fileList.value = vArr.map(
(item: ISingleFile) => ({
name: item.name,
url: item.url,
validUrl: item.url,
}),
) as any
}
})
watchEffect(() => {
// 如果传入的值为空, 清掉 fileList, 这在新建表单的取消过程中会十分有用
if (isEmpty(model.value)) {
fileList.value = []
}
})
// 私有化图片只有半截, 回显的图片是定死的, 只在初次生效
const results = useGetOssPrivateUrlList(initialModelValue as string, isPrivate.value)
watchEffect(() => {
if (results.value.isSuccess) {
fileList.value = results.value.data.map(
(fullPath: string) => ({
name: fullPath,
url: fullPath,
validUrl: fullPath,
}),
) as any
}
})
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
// 判断文件超大
function checkOverSize(paramsSize: number) {
if (paramsSize / ONE_Megabit > fileSize.value) {
ElMessage.error(`所选${fileTypeText.value}大小不能超过 ${fileSize.value} MB`)
return true
}
return false
}
const contentTypeArr = computed(() => {
let tmpArr = [] as string[]
if (accept.value) {
const acceptArr = accept.value.split(',').map(str => str.toLowerCase())
tmpArr = acceptArr.map((x: string) => ALLOW_CONTENT_TYPE_DICT[x])
}
return tmpArr
})
// 判断文件类型
function checkFileType(fileType: string) {
if (!contentTypeArr.value.includes(fileType)) {
ElMessage.error(`${fileTypeText.value}类型不正确,接收类型为 ${accept.value}`)
return true
}
return false
}
// 文件超出数量限制回调
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.error(`最多上传${limit.value}个文件!`)
}
// 上传前的检查
function handleBeforeUpload(rawFile: UploadRawFile) {
// 文件限制大小
if (checkOverSize(rawFile.size!))
return false
// 文件限制类型
if (accept.value === '*') {
return true
}
else {
if (checkFileType(rawFile.type))
return false
}
return true
}
// 上传方法
async function handleHttpRequest(options: UploadRequestOptions) {
// 传到集采商城
if (import.meta.env.VITE_IS_MALL === 'true') {
uploadMall(options.file)
}
else {
// 传到数据组
uploadTob(options.file)
}
}
// 上传失败
function handleUploadError(error: Error, uploadFile: UploadRawFile) {
// 没有上传成功, 把图片给去掉
createErrorModal({ content: error as unknown as string, title: '上传失败' })
const uid = uploadFile.uid
const targetIndex = fileList.value.findIndex(item => item.uid === uid)
fileList.value.splice(targetIndex, 1)
}
// 上传到集采
function uploadMall(uploadFile: UploadRawFile) {
const formData = new FormData()
formData.append('file', uploadFile)
const params = {
data: formData,
isMallAdmin: isMallAdmin.value,
}
mutateMall(params, {
onError: (error) => {
handleUploadError(error, uploadFile)
},
onSuccess: (url: string) => {
handleUploadSuccess(url, uploadFile)
},
})
}
// 上传到数据组
function uploadTob(uploadFile: UploadRawFile) {
const formData = new FormData()
formData.append('file', uploadFile)
const params = {
data: formData,
isPrivate: isPrivate.value,
}
mutateOssUpload(params, {
onError: (error) => {
handleUploadError(error, uploadFile)
},
onSuccess: (url: string) => {
handleUploadSuccess(url, uploadFile)
},
})
}
// 上传成功后的回调
function handleUploadSuccess(url: string, uploadFile: UploadRawFile) {
const uid = uploadFile.uid
const targetIndex = fileList.value.findIndex(item => item.uid === uid)
const targetItem = fileList.value.find(item => item.uid === uid)
fileList.value.splice(targetIndex, 1, {
...targetItem,
validUrl: url,
} as any)
const currentUrls = (fileList.value as any).map(item => item.validUrl)
if (modelIsString.value) {
model.value = currentUrls.join(',')
}
else {
model.value = (fileList.value as any).map(item => ({
name: item.name,
url: item.validUrl,
}))
}
emits('success', { name: uploadFile.name, url })
}
// 删除文件
function handleRemove(file: UploadFile) {
const targetIndex = fileList.value.findIndex(item => item.uid === file.uid)
if (targetIndex >= 0) {
const currentUrls = fileList.value.map(item => item.url)
currentUrls.splice(targetIndex, 1)
fileList.value.splice(targetIndex, 1)
if (modelIsString.value) {
model.value = currentUrls.join(',')
}
else {
model.value = (fileList.value as any).map(item => ({
name: item.name,
url: item.validUrl,
}))
}
}
}
// 预览文件
function handlePreview(file: UploadFile) {
if (isText.value) {
// !text 形式下, fileList 没有 url, 使用自定义的 validUrl
window.open((file as any).validUrl, '_blank')
}
else {
dialogVisible.value = true
dialogImageUrl.value = file.url!
}
}
</script>
<template>
<ElUpload v-bind="getBindValue"
v-model:file-list="fileList"
class="w-full"
:class="{
hidden_add: hiddenAdd || disabled,
hide_plus_icon: hidePlusIconFirstTime,
only_show: disabled,
}"
:on-remove="handleRemove"
:http-request="handleHttpRequest"
:before-upload="handleBeforeUpload"
:disabled="disabled"
:on-exceed="handleExceed"
>
<!-- #region 自定义默认内容 -->
<template v-if="!disabled"
#default
>
<ElButton v-if="isText"
:icon="Upload"
>
上传文件
</ElButton>
<template v-else>
<ElIcon>
<Plus />
</ElIcon>
</template>
</template>
<!-- #endregion -->
<!-- #region 提示说明文字 -->
<template v-if="!disabled"
#tip
>
<div v-if="tips"
class="el-upload__tip"
>
{{ finalTips }}
</div>
</template>
<!-- #endregion -->
<!-- #region 缩略图模板的内容 -->
<template #file="{ file }">
<!-- #region 渲染文件 -->
<template v-if="isText">
<div class="el-upload-list__item-info">
<a class="el-upload-list__item-name"
@click.prevent="handlePreview(file)"
>
<ElIcon>
<Document />
</ElIcon>
<span class="el-upload-list__item-file-name">
{{ file.name }}
</span>
</a>
</div>
<div v-if="!disabled"
class="i-material-symbols:delete-outline text-24 text-red cursor-pointer"
@click="handleRemove(file)"
/>
</template>
<!-- #endregion -->
<template v-else>
<video v-if="isVideo"
:autoplay="false"
name="media"
:src="file.url"
>
<source :src="file.url"
type="video/mp4"
>
</video>
<img v-else
class="el-upload-list__item-thumbnail"
:src="file.url"
alt=""
>
<div class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview"
@click="handlePreview(file)"
>
<ElIcon>
<ZoomIn />
</ElIcon>
</span>
<span v-if="!disabled"
class="el-upload-list__item-delete"
@click="handleRemove(file)"
>
<ElIcon>
<Delete />
</ElIcon>
</span>
</div>
</template>
</template>
<!-- #endregion -->
</ElUpload>
<ElDialog v-model="dialogVisible">
<div class="flex h-800 justify-center">
<video v-if="isVideo"
class="h-full"
autoplay="true"
name="media"
>
<source :src="dialogImageUrl"
type="video/mp4"
>
</video>
<img v-else
class="h-full"
:src="dialogImageUrl"
>
</div>
</ElDialog>
</template>
<style lang="less" scoped>
.hidden_add {
:deep(.el-upload, .el-upload--picture-card) {
display: none;
background: blue;
}
}
.hide_plus_icon {
:deep(.el-icon) {
display: none;
}
}
.only_show {
:deep(.el-upload-list) {
margin-top: 0;
}
}
</style>