Files
shanxia2.0/web-admin/src/components/bx/base/BxAttachmentUpload.vue
T
2026-08-09 21:07:28 +08:00

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>