489 lines
16 KiB
Vue
489 lines
16 KiB
Vue
<template>
|
|
<div class="material-select">
|
|
<popup
|
|
ref="popupRef"
|
|
width="1050px"
|
|
custom-class="body-padding"
|
|
:title="`选择${tipsText}`"
|
|
@confirm="handleConfirm"
|
|
@close="handleClose"
|
|
>
|
|
<template v-if="!hiddenUpload" #trigger>
|
|
<div
|
|
class="material-select__trigger clearfix"
|
|
:class="{ 'is-drop-hover': dropActive }"
|
|
tabindex="0"
|
|
@click.stop
|
|
@dragenter.prevent.stop="onTriggerDragEnter"
|
|
@dragleave.prevent.stop="onTriggerDragLeave"
|
|
@dragover.prevent.stop="onTriggerDragOver"
|
|
@drop.prevent.stop="onTriggerDrop"
|
|
@paste="onTriggerPaste"
|
|
>
|
|
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
|
|
<template v-slot:item="{ element, index }">
|
|
<div
|
|
class="material-preview"
|
|
:class="{
|
|
'is-disabled': disabled,
|
|
'is-one': limit == 1
|
|
}"
|
|
@click="showPopup(index)"
|
|
>
|
|
<del-wrap @close="deleteImg(index)">
|
|
<file-item
|
|
:uri="excludeDomain ? getImageUrl(element) : element"
|
|
:file-size="size"
|
|
:width="width"
|
|
:height="height"
|
|
:type="type"
|
|
></file-item>
|
|
</del-wrap>
|
|
<div class="operation-btns text-xs text-center">
|
|
<span>修改</span>
|
|
|
|
|
<span @click.stop="handlePreview(element)">查看</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</draggable>
|
|
<div
|
|
class="material-upload"
|
|
@click="showPopup(-1)"
|
|
v-show="showUpload"
|
|
:class="{
|
|
'is-disabled': disabled,
|
|
'is-one': limit == 1,
|
|
[uploadClass]: true
|
|
}"
|
|
>
|
|
<slot name="upload">
|
|
<div
|
|
class="upload-btn"
|
|
:style="{
|
|
width: width || size,
|
|
height: height || size
|
|
}"
|
|
>
|
|
<icon :size="25" name="el-icon-Plus" />
|
|
<span>添加</span>
|
|
</div>
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<el-scrollbar>
|
|
<div class="material-wrap">
|
|
<material
|
|
ref="materialRef"
|
|
:type="type"
|
|
:file-size="fileSize"
|
|
:limit="meterialLimit"
|
|
@change="selectChange"
|
|
/>
|
|
</div>
|
|
</el-scrollbar>
|
|
</popup>
|
|
<preview v-model="showPreview" :url="previewUrl" :type="type" />
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { useThrottleFn } from '@vueuse/core'
|
|
import Draggable from 'vuedraggable'
|
|
|
|
import { uploadMaterialFile } from '@/api/file'
|
|
import Popup from '@/components/popup/index.vue'
|
|
import useAppStore from '@/stores/modules/app'
|
|
import feedback from '@/utils/feedback'
|
|
|
|
import FileItem from './file.vue'
|
|
import Material from './index.vue'
|
|
import Preview from './preview.vue'
|
|
export default defineComponent({
|
|
components: {
|
|
Popup,
|
|
Draggable,
|
|
FileItem,
|
|
Material,
|
|
Preview
|
|
},
|
|
props: {
|
|
modelValue: {
|
|
type: [String, Array],
|
|
default: () => []
|
|
},
|
|
// 文件类型
|
|
type: {
|
|
type: String,
|
|
default: 'image'
|
|
},
|
|
// 选择器尺寸
|
|
size: {
|
|
type: String,
|
|
default: '100px'
|
|
},
|
|
// 选择器尺寸-宽度(不传则是使用size
|
|
width: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
// 选择器尺寸-高度(不传则是使用size
|
|
height: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
// 文件尺寸
|
|
fileSize: {
|
|
type: String,
|
|
default: '100px'
|
|
},
|
|
// 选择数量限制
|
|
limit: {
|
|
type: Number,
|
|
default: 1
|
|
},
|
|
// 禁用选择
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 隐藏上传框*(目前在富文本中使用到)
|
|
hiddenUpload: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
uploadClass: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
//选择的url排出域名
|
|
excludeDomain: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
|
|
uploadCid: {
|
|
type: [Number, String],
|
|
default: 0
|
|
}
|
|
},
|
|
|
|
emits: ['change', 'update:modelValue'],
|
|
setup(props, { emit }) {
|
|
const popupRef = ref<InstanceType<typeof Popup>>()
|
|
const materialRef = ref<InstanceType<typeof Material>>()
|
|
const previewUrl = ref('')
|
|
const showPreview = ref(false)
|
|
const fileList = ref<any[]>([])
|
|
const select = ref<any[]>([])
|
|
const isAdd = ref(true)
|
|
const currentIndex = ref(-1)
|
|
const dragDepth = ref(0)
|
|
const externalUploading = ref(false)
|
|
const { disabled, limit, modelValue } = toRefs(props)
|
|
const appStore = useAppStore()
|
|
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
|
|
|
const allowDropPaste = computed(
|
|
() =>
|
|
!props.hiddenUpload &&
|
|
(props.type === 'image' || props.type === 'video' || props.type === 'file')
|
|
)
|
|
|
|
const dropActive = computed(() => dragDepth.value > 0)
|
|
|
|
function hasFileDrag(dt: DataTransfer | null) {
|
|
if (!dt) return false
|
|
const types = Array.from(dt.types as unknown as string[])
|
|
return types.includes('Files') || types.includes('application/x-moz-file')
|
|
}
|
|
|
|
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
|
|
if (uploadType === 'file') return true
|
|
const ext = fileName.includes('.')
|
|
? fileName.split('.').pop()?.toLowerCase() || ''
|
|
: ''
|
|
if (uploadType === 'image') {
|
|
if (!ext) return true
|
|
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
|
|
}
|
|
if (uploadType === 'video') {
|
|
if (!ext) return true
|
|
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
|
|
ext
|
|
)
|
|
}
|
|
return true
|
|
}
|
|
|
|
function toRelativeStoredUrl(fullUrl: string): string {
|
|
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
|
|
const oss = String(appStore.config?.oss_domain || '')
|
|
.trim()
|
|
.replace(/\/$/, '')
|
|
if (oss && fullUrl.startsWith(oss)) {
|
|
return fullUrl.slice(oss.length).replace(/^\//, '')
|
|
}
|
|
const origin =
|
|
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
|
|
if (origin && fullUrl.startsWith(origin)) {
|
|
return fullUrl.slice(origin.length).replace(/^\//, '')
|
|
}
|
|
try {
|
|
return new URL(fullUrl).pathname.replace(/^\//, '')
|
|
} catch {
|
|
return fullUrl
|
|
}
|
|
}
|
|
|
|
function clipboardFiles(e: ClipboardEvent): File[] {
|
|
const uploadType = props.type
|
|
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
|
|
isAcceptedExtension(f.name, uploadType)
|
|
)
|
|
if (fromFiles.length) return fromFiles
|
|
if (uploadType !== 'image') return []
|
|
const items = e.clipboardData?.items
|
|
if (!items) return []
|
|
const out: File[] = []
|
|
for (let i = 0; i < items.length; i++) {
|
|
const it = items[i]
|
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
|
const f = it.getAsFile()
|
|
if (f) out.push(f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function runExternalUpload(files: File[]) {
|
|
if (externalUploading.value) return
|
|
const maxSlots =
|
|
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
|
|
if (!maxSlots) {
|
|
feedback.msgWarning('已达到上传数量上限')
|
|
return
|
|
}
|
|
const queue = files.slice(0, maxSlots)
|
|
if (!queue.length) return
|
|
|
|
const uploadType =
|
|
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
|
|
externalUploading.value = true
|
|
try {
|
|
const paths: string[] = []
|
|
for (const file of queue) {
|
|
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
|
|
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
|
|
}
|
|
fileList.value = [...fileList.value, ...paths]
|
|
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
|
|
emit('update:modelValue', valueImg)
|
|
emit('change', valueImg)
|
|
handleClose()
|
|
} catch (err: any) {
|
|
feedback.msgError(err?.message || '上传失败')
|
|
} finally {
|
|
externalUploading.value = false
|
|
}
|
|
}
|
|
|
|
const onTriggerDragEnter = (e: DragEvent) => {
|
|
if (!allowDropPaste.value || disabled.value) return
|
|
if (!hasFileDrag(e.dataTransfer)) return
|
|
dragDepth.value++
|
|
}
|
|
|
|
const onTriggerDragLeave = () => {
|
|
if (!allowDropPaste.value) return
|
|
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
|
}
|
|
|
|
const onTriggerDragOver = (e: DragEvent) => {
|
|
if (!allowDropPaste.value || disabled.value) return
|
|
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
|
|
}
|
|
|
|
const onTriggerDrop = async (e: DragEvent) => {
|
|
dragDepth.value = 0
|
|
if (!allowDropPaste.value || disabled.value) return
|
|
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
|
|
isAcceptedExtension(f.name, props.type)
|
|
)
|
|
if (!files.length) return
|
|
await runExternalUpload(files)
|
|
}
|
|
|
|
const onTriggerPaste = async (e: ClipboardEvent) => {
|
|
if (!allowDropPaste.value || disabled.value) return
|
|
const files = clipboardFiles(e)
|
|
if (!files.length) return
|
|
e.preventDefault()
|
|
await runExternalUpload(files)
|
|
}
|
|
const tipsText = computed(() => {
|
|
switch (props.type) {
|
|
case 'image':
|
|
return '图片'
|
|
case 'video':
|
|
return '视频'
|
|
default:
|
|
return ''
|
|
}
|
|
})
|
|
|
|
const showUpload = computed(() => {
|
|
return props.limit - fileList.value.length > 0
|
|
})
|
|
const meterialLimit: any = computed(() => {
|
|
if (!isAdd.value) {
|
|
return 1
|
|
}
|
|
if (limit.value == -1) return null
|
|
return limit.value - fileList.value.length
|
|
})
|
|
const handleConfirm = useThrottleFn(
|
|
() => {
|
|
const selectUri = select.value.map((item) =>
|
|
props.excludeDomain ? item.uri : item.url
|
|
)
|
|
if (!isAdd.value) {
|
|
fileList.value.splice(currentIndex.value, 1, selectUri.shift())
|
|
} else {
|
|
fileList.value = [...fileList.value, ...selectUri]
|
|
}
|
|
handleChange()
|
|
},
|
|
1000,
|
|
false
|
|
)
|
|
const showPopup = (index: number) => {
|
|
if (disabled.value) return
|
|
if (index >= 0) {
|
|
isAdd.value = false
|
|
currentIndex.value = index
|
|
} else {
|
|
isAdd.value = true
|
|
}
|
|
popupRef.value?.open()
|
|
}
|
|
|
|
const selectChange = (val: any[]) => {
|
|
select.value = val
|
|
}
|
|
const handleChange = () => {
|
|
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
|
|
emit('update:modelValue', valueImg)
|
|
emit('change', valueImg)
|
|
handleClose()
|
|
}
|
|
|
|
const deleteImg = (index: number) => {
|
|
fileList.value.splice(index, 1)
|
|
handleChange()
|
|
}
|
|
|
|
const handlePreview = (url: string) => {
|
|
previewUrl.value = props.excludeDomain ? getImageUrl(url) : url
|
|
showPreview.value = true
|
|
}
|
|
|
|
const handleClose = () => {
|
|
nextTick(() => {
|
|
if (props.hiddenUpload) fileList.value = []
|
|
materialRef.value?.clearSelect()
|
|
})
|
|
}
|
|
|
|
watch(
|
|
modelValue,
|
|
(val: any[] | string) => {
|
|
fileList.value = Array.isArray(val) ? val : val == '' ? [] : [val]
|
|
},
|
|
{
|
|
immediate: true
|
|
}
|
|
)
|
|
provide('limit', props.limit)
|
|
provide('hiddenUpload', props.hiddenUpload)
|
|
return {
|
|
popupRef,
|
|
materialRef,
|
|
fileList,
|
|
tipsText,
|
|
handleConfirm,
|
|
meterialLimit,
|
|
showUpload,
|
|
showPopup,
|
|
selectChange,
|
|
deleteImg,
|
|
previewUrl,
|
|
showPreview,
|
|
handlePreview,
|
|
handleClose,
|
|
getImageUrl,
|
|
dropActive,
|
|
onTriggerDragEnter,
|
|
onTriggerDragLeave,
|
|
onTriggerDragOver,
|
|
onTriggerDrop,
|
|
onTriggerPaste
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.material-select {
|
|
.material-select__trigger {
|
|
outline: none;
|
|
&.is-drop-hover {
|
|
border-radius: 4px;
|
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
|
}
|
|
}
|
|
.material-upload,
|
|
.material-preview {
|
|
position: relative;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-right: 8px;
|
|
margin-bottom: 8px;
|
|
box-sizing: border-box;
|
|
float: left;
|
|
&.is-disabled {
|
|
cursor: not-allowed;
|
|
}
|
|
&.is-one {
|
|
margin-bottom: 0;
|
|
}
|
|
&:hover {
|
|
.operation-btns {
|
|
display: block;
|
|
}
|
|
}
|
|
.operation-btns {
|
|
display: none;
|
|
position: absolute;
|
|
bottom: 0;
|
|
border-radius: 4px;
|
|
width: 100%;
|
|
line-height: 2;
|
|
color: #fff;
|
|
background-color: rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
.material-upload {
|
|
:deep(.upload-btn) {
|
|
@apply text-tx-secondary box-border rounded border-br border-dashed border flex flex-col justify-center items-center;
|
|
}
|
|
}
|
|
}
|
|
.material-wrap {
|
|
min-width: 720px;
|
|
height: 560px;
|
|
@apply border-t border-b border-br;
|
|
}
|
|
</style>
|