435 lines
13 KiB
Vue
435 lines
13 KiB
Vue
<template>
|
|
<div
|
|
ref="rootRef"
|
|
class="attachment-upload"
|
|
:class="{ 'attachment-upload--drag-over': isDragOver && !disabled }"
|
|
:tabindex="disabled ? undefined : 0"
|
|
@mouseenter="handlePointerEnter"
|
|
@mouseleave="handlePointerLeave"
|
|
@focusin="handlePointerEnter"
|
|
@focusout="handleFocusOut"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="handleDragLeave"
|
|
@dragover="handleDragOver"
|
|
@drop="handleDrop"
|
|
>
|
|
<div class="attachment-list">
|
|
<el-upload
|
|
ref="uploadRef"
|
|
:auto-upload="false"
|
|
:show-file-list="false"
|
|
:multiple="true"
|
|
:disabled="disabled || globalUploading"
|
|
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
|
@change="handleAttachmentChange"
|
|
>
|
|
<div class="upload-card">
|
|
<el-icon :size="28" color="#1ca851"><Plus /></el-icon>
|
|
<span class="upload-card-text">{{ isDragOver ? '松开上传' : '上传附件' }}</span>
|
|
</div>
|
|
</el-upload>
|
|
<div
|
|
v-for="(file, index) in files"
|
|
:key="String(file.id)"
|
|
class="attachment-item"
|
|
:class="{ 'is-uploading': file._uploading }"
|
|
>
|
|
<div
|
|
class="attachment-preview"
|
|
:class="{ 'is-clickable': canBxPreview(file) }"
|
|
:title="canBxPreview(file) ? '点击预览' : undefined"
|
|
@click="canBxPreview(file) ? handlePreview(file) : undefined"
|
|
>
|
|
<template v-if="isBxImageFile(file) && (file.previewUrl || file._uploading)">
|
|
<img v-if="file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
|
|
<el-icon v-else :size="24" color="#94a3b8"><Loading /></el-icon>
|
|
</template>
|
|
<template v-else-if="isBxImageFile(file) && !file.previewUrl">
|
|
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
|
</template>
|
|
<template v-else-if="isBxVideoFile(file)">
|
|
<div class="attachment-preview__icon attachment-preview__icon--video">
|
|
<el-icon :size="28"><VideoPlay /></el-icon>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="isBxPdfFile(file)">
|
|
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
|
<el-icon :size="28"><Document /></el-icon>
|
|
<span class="attachment-preview__ext">PDF</span>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
|
<el-icon :size="28"><Document /></el-icon>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="attachment-footer">
|
|
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
|
|
<div class="attachment-actions">
|
|
<el-icon
|
|
v-if="canBxPreview(file)"
|
|
class="action-btn"
|
|
title="预览"
|
|
@click="handlePreview(file)"
|
|
>
|
|
<ZoomIn />
|
|
</el-icon>
|
|
<el-icon class="action-btn" title="下载" @click="handleDownload(file)">
|
|
<Download />
|
|
</el-icon>
|
|
<el-icon
|
|
class="action-btn action-btn--danger"
|
|
title="删除"
|
|
@click="removeAttachment(index)"
|
|
>
|
|
<Delete />
|
|
</el-icon>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span v-if="showHint" class="upload-hint">{{ hint }}</span>
|
|
|
|
<el-dialog
|
|
v-model="previewVisible"
|
|
:title="previewFileName || '附件预览'"
|
|
:width="previewMode === 'pdf' ? '860px' : '800px'"
|
|
align-center
|
|
destroy-on-close
|
|
append-to-body
|
|
modal-class="order-module-dialog-mask"
|
|
>
|
|
<img
|
|
v-if="previewMode === 'image' && previewUrl"
|
|
:src="previewUrl"
|
|
:alt="previewFileName"
|
|
class="bx-attachment-preview-media"
|
|
/>
|
|
<video
|
|
v-else-if="previewMode === 'video' && previewUrl"
|
|
:src="previewUrl"
|
|
controls
|
|
class="bx-attachment-preview-media"
|
|
/>
|
|
<iframe
|
|
v-else-if="previewMode === 'pdf' && previewUrl"
|
|
:src="previewUrl"
|
|
class="bx-attachment-preview-media bx-attachment-preview-media--iframe"
|
|
title="PDF 预览"
|
|
/>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import {
|
|
Plus,
|
|
Loading,
|
|
VideoPlay,
|
|
Document,
|
|
Download,
|
|
Delete,
|
|
ZoomIn
|
|
} from '@element-plus/icons-vue'
|
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { attachmentApi, type BxId } from '@/api/bx'
|
|
import {
|
|
type BxAttachmentItem,
|
|
isBxImageFile,
|
|
isBxVideoFile,
|
|
isBxPdfFile,
|
|
canBxPreview,
|
|
downloadBxAttachment,
|
|
previewBxAttachment
|
|
} from '@/utils/bx/attachment'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: BxAttachmentItem[]
|
|
bizType: string
|
|
bizId?: BxId
|
|
hint?: string
|
|
showHint?: boolean
|
|
disabled?: boolean
|
|
}>(),
|
|
{
|
|
bizId: 0,
|
|
hint: '支持图片、视频、PDF、Word、Excel等文件;可拖拽或在此区域粘贴上传',
|
|
showHint: true,
|
|
disabled: false
|
|
}
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: BxAttachmentItem[]]
|
|
}>()
|
|
|
|
const rootRef = ref<HTMLElement>()
|
|
const uploadRef = ref<UploadInstance>()
|
|
const globalUploading = ref(false)
|
|
const previewVisible = ref(false)
|
|
const previewUrl = ref('')
|
|
const previewFileName = ref('')
|
|
const previewMode = ref<'image' | 'video' | 'pdf'>('image')
|
|
const isPointerInside = ref(false)
|
|
const isDragOver = ref(false)
|
|
let dragEnterDepth = 0
|
|
let clearUploadScheduled = false
|
|
/**
|
|
* 本地缓冲:el-upload 多选会同步连续触发 @change;
|
|
* 若基于尚未回写的 props.modelValue 展开累加,会只留下最后一个文件。
|
|
*/
|
|
const files = ref<BxAttachmentItem[]>([])
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
files.value = Array.isArray(val) ? val.slice() : []
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function syncFiles(next: BxAttachmentItem[]) {
|
|
files.value = next
|
|
emit('update:modelValue', next)
|
|
}
|
|
|
|
function normalizeIncomingFile(file: File, index = 0) {
|
|
if (file.name && file.name.trim()) return file
|
|
const ext = (file.type || '').split('/')[1] || 'bin'
|
|
return new File([file], `paste-${Date.now()}-${index}.${ext}`, {
|
|
type: file.type || 'application/octet-stream'
|
|
})
|
|
}
|
|
|
|
function collectClipboardFiles(event: ClipboardEvent) {
|
|
const out: File[] = []
|
|
const items = event.clipboardData?.items
|
|
if (items?.length) {
|
|
Array.from(items).forEach((item, index) => {
|
|
if (item.kind !== 'file') return
|
|
const file = item.getAsFile()
|
|
if (file) out.push(normalizeIncomingFile(file, index))
|
|
})
|
|
}
|
|
if (!out.length && event.clipboardData?.files?.length) {
|
|
Array.from(event.clipboardData.files).forEach((file, index) => {
|
|
out.push(normalizeIncomingFile(file, index))
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function uploadRawFile(rawFile: File) {
|
|
if (props.disabled) return
|
|
const item: BxAttachmentItem = {
|
|
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
fileName: rawFile.name,
|
|
fileType: rawFile.type,
|
|
fileSize: rawFile.size,
|
|
previewUrl: '',
|
|
_uploading: true
|
|
}
|
|
syncFiles([...files.value, item])
|
|
globalUploading.value = true
|
|
|
|
try {
|
|
const res: any = await attachmentApi.upload(rawFile, props.bizType, props.bizId ?? 0)
|
|
item.id = res.data.id
|
|
item.fileName = res.data.fileName || item.fileName
|
|
item.fileType = res.data.fileType || item.fileType
|
|
item.fileSize = res.data.fileSize ?? item.fileSize
|
|
item._uploading = false
|
|
if (isBxImageFile(item)) {
|
|
try {
|
|
const previewRes: any = await attachmentApi.preview(item.id)
|
|
item.previewUrl = previewRes.data?.url || ''
|
|
} catch {
|
|
/* preview optional */
|
|
}
|
|
}
|
|
syncFiles([...files.value])
|
|
} catch (error: any) {
|
|
syncFiles(files.value.filter((entry) => entry.id !== item.id))
|
|
ElMessage.error(error?.message || '上传失败')
|
|
} finally {
|
|
globalUploading.value = files.value.some((entry) => entry._uploading)
|
|
}
|
|
}
|
|
|
|
async function uploadFiles(list: File[]) {
|
|
for (const file of list) {
|
|
await uploadRawFile(file)
|
|
}
|
|
}
|
|
|
|
async function scheduleClearUploadFiles() {
|
|
if (clearUploadScheduled) return
|
|
clearUploadScheduled = true
|
|
await nextTick()
|
|
uploadRef.value?.clearFiles()
|
|
clearUploadScheduled = false
|
|
}
|
|
|
|
function handleAttachmentChange(uploadFile: UploadFile) {
|
|
const rawFile = uploadFile.raw
|
|
if (!rawFile) return
|
|
void uploadRawFile(rawFile)
|
|
void scheduleClearUploadFiles()
|
|
}
|
|
|
|
function handlePointerEnter() {
|
|
if (props.disabled) return
|
|
isPointerInside.value = true
|
|
}
|
|
|
|
function handlePointerLeave() {
|
|
isPointerInside.value = false
|
|
}
|
|
|
|
function handleFocusOut(event: FocusEvent) {
|
|
const next = event.relatedTarget as Node | null
|
|
if (next && rootRef.value?.contains(next)) return
|
|
isPointerInside.value = false
|
|
}
|
|
|
|
function handleDragEnter(event: DragEvent) {
|
|
if (props.disabled) return
|
|
event.preventDefault()
|
|
dragEnterDepth += 1
|
|
isDragOver.value = true
|
|
isPointerInside.value = true
|
|
}
|
|
|
|
function handleDragLeave(event: DragEvent) {
|
|
if (props.disabled) return
|
|
event.preventDefault()
|
|
dragEnterDepth = Math.max(0, dragEnterDepth - 1)
|
|
if (dragEnterDepth === 0) isDragOver.value = false
|
|
}
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
if (props.disabled) return
|
|
event.preventDefault()
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
}
|
|
|
|
async function handleDrop(event: DragEvent) {
|
|
if (props.disabled) return
|
|
event.preventDefault()
|
|
dragEnterDepth = 0
|
|
isDragOver.value = false
|
|
await uploadFiles(Array.from(event.dataTransfer?.files || []))
|
|
}
|
|
|
|
async function handleDocumentPaste(event: ClipboardEvent) {
|
|
if (props.disabled || !isPointerInside.value) return
|
|
const clipboardFiles = collectClipboardFiles(event)
|
|
if (!clipboardFiles.length) return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
await uploadFiles(clipboardFiles)
|
|
}
|
|
|
|
async function removeAttachment(index: number) {
|
|
const file = files.value[index]
|
|
if (!file || file._uploading) return
|
|
try {
|
|
await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
|
|
if (typeof file.id === 'number' || /^\d+$/.test(String(file.id))) {
|
|
await attachmentApi.delete(file.id)
|
|
}
|
|
const next = [...files.value]
|
|
next.splice(index, 1)
|
|
syncFiles(next)
|
|
ElMessage.success('已删除')
|
|
} catch {
|
|
/* cancelled */
|
|
}
|
|
}
|
|
|
|
function handleDownload(file: BxAttachmentItem) {
|
|
if (file._uploading) return
|
|
downloadBxAttachment(file)
|
|
}
|
|
|
|
async function handlePreview(file: BxAttachmentItem) {
|
|
const result = await previewBxAttachment(file)
|
|
if (!result) return
|
|
previewMode.value = result.type
|
|
previewUrl.value = result.url
|
|
previewFileName.value = result.fileName
|
|
previewVisible.value = true
|
|
}
|
|
|
|
function hasUploading() {
|
|
return files.value.some((item) => item._uploading)
|
|
}
|
|
|
|
function getFileIds() {
|
|
return files.value.filter((item) => !item._uploading).map((item) => item.id)
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('paste', handleDocumentPaste, true)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('paste', handleDocumentPaste, true)
|
|
})
|
|
|
|
defineExpose({ hasUploading, getFileIds })
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@use '@/styles/bx/attachment.scss';
|
|
|
|
.attachment-upload {
|
|
outline: none;
|
|
border-radius: 8px;
|
|
|
|
&:focus-visible {
|
|
box-shadow: 0 0 0 2px rgba(28, 168, 81, 0.25);
|
|
}
|
|
|
|
&--drag-over {
|
|
.attachment-list {
|
|
padding: 4px;
|
|
border-radius: 8px;
|
|
outline: 2px dashed #1ca851;
|
|
outline-offset: 4px;
|
|
background: rgba(16, 185, 129, 0.06);
|
|
}
|
|
|
|
.upload-card {
|
|
border-color: #1ca851;
|
|
border-style: solid;
|
|
background: #e6f8ed;
|
|
}
|
|
}
|
|
}
|
|
|
|
.attachment-preview.is-clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.attachment-preview__ext {
|
|
margin-top: 4px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
}
|
|
|
|
.bx-attachment-preview-media--iframe {
|
|
width: 100%;
|
|
min-height: 480px;
|
|
height: calc(100vh - 240px);
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
}
|
|
</style>
|