373 lines
10 KiB
Vue
373 lines
10 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 通用拖拽 + 粘贴上传:Dragger 点击/拖拽,下方外侧「开始监听粘贴」
|
||
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览(blob,避免直接下载)
|
||
*/
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||
|
||
import { preferences } from '@vben/preferences';
|
||
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
|
||
import type { UploadFile } from 'ant-design-vue';
|
||
|
||
import { Icon } from '#/components/icon';
|
||
import { uploadFile } from '#/api/core/upload';
|
||
import { uploadToOss } from '#/utils/oss-upload';
|
||
import {
|
||
matchAccept,
|
||
usePasteUploadListen,
|
||
} from '#/utils/use-paste-upload-listen';
|
||
|
||
defineOptions({ name: 'UploadDraggerPaste' });
|
||
|
||
interface FileItem {
|
||
uid: string;
|
||
name: string;
|
||
status: 'uploading' | 'done' | 'error';
|
||
url: string;
|
||
}
|
||
|
||
interface Props {
|
||
modelValue?: string[];
|
||
accept?: string;
|
||
multiple?: boolean;
|
||
maxCount?: number;
|
||
/** 单文件最大 MB */
|
||
maxSizeMb?: number;
|
||
tip?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: () => [],
|
||
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
|
||
multiple: true,
|
||
maxCount: 9,
|
||
maxSizeMb: 20,
|
||
tip: '点击或拖拽文件到此处上传',
|
||
disabled: false,
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string[]];
|
||
}>();
|
||
|
||
const UploadDragger = Upload.Dragger;
|
||
const fileList = ref<FileItem[]>([]);
|
||
const uploading = ref(false);
|
||
|
||
/** 文件预览弹窗状态 */
|
||
const previewVisible = ref(false);
|
||
const previewTitle = ref('');
|
||
const previewIsImage = ref(false);
|
||
const previewImageUrl = ref('');
|
||
const previewPdfUrl = ref('');
|
||
const previewLoading = ref(false);
|
||
|
||
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
|
||
|
||
function syncFromValue(urls: string[]) {
|
||
fileList.value = (urls || []).map((url) => ({
|
||
uid: url,
|
||
name: url.split('/').pop() || 'file',
|
||
status: 'done' as const,
|
||
url,
|
||
}));
|
||
}
|
||
|
||
syncFromValue(props.modelValue);
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
const current = fileList.value
|
||
.filter((f) => f.status === 'done')
|
||
.map((f) => f.url);
|
||
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
|
||
syncFromValue(val || []);
|
||
}
|
||
},
|
||
{ deep: true },
|
||
);
|
||
|
||
function emitUrls() {
|
||
const urls = fileList.value
|
||
.filter((f) => f.status === 'done' && f.url)
|
||
.map((f) => f.url);
|
||
emit('update:modelValue', urls);
|
||
}
|
||
|
||
function isImageUrl(url: string) {
|
||
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||
}
|
||
|
||
function revokePdfBlob() {
|
||
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||
URL.revokeObjectURL(previewPdfUrl.value);
|
||
}
|
||
previewPdfUrl.value = '';
|
||
}
|
||
|
||
/**
|
||
* 预览已上传文件:图片用 Image;PDF fetch 成 blob 后 iframe,避免 OSS 直接下载
|
||
*/
|
||
async function previewItem(item: FileItem) {
|
||
if (!item.url) return;
|
||
revokePdfBlob();
|
||
previewTitle.value = item.name || '预览';
|
||
if (isImageUrl(item.url)) {
|
||
previewIsImage.value = true;
|
||
previewImageUrl.value = item.url;
|
||
previewVisible.value = true;
|
||
return;
|
||
}
|
||
previewIsImage.value = false;
|
||
previewLoading.value = true;
|
||
previewVisible.value = true;
|
||
try {
|
||
const resp = await fetch(item.url);
|
||
if (!resp.ok) throw new Error('加载失败');
|
||
const blob = await resp.blob();
|
||
const pdfBlob =
|
||
blob.type && blob.type !== 'application/octet-stream'
|
||
? blob
|
||
: new Blob([blob], { type: 'application/pdf' });
|
||
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||
} catch {
|
||
message.error('预览失败');
|
||
previewVisible.value = false;
|
||
} finally {
|
||
previewLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function onPreviewClose() {
|
||
previewVisible.value = false;
|
||
revokePdfBlob();
|
||
previewImageUrl.value = '';
|
||
}
|
||
|
||
/**
|
||
* 上传单个文件并写入列表
|
||
*/
|
||
async function uploadOneFile(file: File) {
|
||
if (props.disabled) return;
|
||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||
if (doneCount >= props.maxCount) {
|
||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||
return;
|
||
}
|
||
if (!matchAccept(file, props.accept)) {
|
||
message.warning('文件类型不符合要求');
|
||
return;
|
||
}
|
||
if (file.size > maxBytes.value) {
|
||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||
return;
|
||
}
|
||
|
||
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
fileList.value = [
|
||
...fileList.value,
|
||
{ uid, name: file.name, status: 'uploading', url: '' },
|
||
];
|
||
uploading.value = true;
|
||
try {
|
||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||
const res =
|
||
uploadMethod === 'direct'
|
||
? await uploadToOss({ file })
|
||
: await uploadFile({ file });
|
||
const url = res?.url || '';
|
||
if (!url) throw new Error('上传失败');
|
||
fileList.value = fileList.value.map((item) =>
|
||
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
|
||
);
|
||
emitUrls();
|
||
} catch (e: any) {
|
||
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||
message.error(e?.message || '上传失败');
|
||
} finally {
|
||
uploading.value = false;
|
||
}
|
||
}
|
||
|
||
/** Ant Design Upload customRequest */
|
||
const customRequest = async (options: any) => {
|
||
const { file, onSuccess, onError } = options;
|
||
try {
|
||
await uploadOneFile(file as File);
|
||
onSuccess?.({});
|
||
} catch (e) {
|
||
onError?.(e);
|
||
}
|
||
};
|
||
|
||
function beforeUpload(file: File) {
|
||
if (props.disabled) return Upload.LIST_IGNORE;
|
||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||
if (doneCount >= props.maxCount) {
|
||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
if (!matchAccept(file, props.accept)) {
|
||
message.warning('文件类型不符合要求');
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
if (file.size > maxBytes.value) {
|
||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function handleRemove(file: UploadFile) {
|
||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||
emitUrls();
|
||
return true;
|
||
}
|
||
|
||
const {
|
||
listening: pasteListening,
|
||
toggle: togglePaste,
|
||
} = usePasteUploadListen({
|
||
get accept() {
|
||
return props.accept;
|
||
},
|
||
get maxSize() {
|
||
return maxBytes.value;
|
||
},
|
||
onFiles: async (files) => {
|
||
for (const file of files) {
|
||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||
if (doneCount >= props.maxCount) {
|
||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||
break;
|
||
}
|
||
await uploadOneFile(file);
|
||
}
|
||
},
|
||
});
|
||
|
||
function onTogglePaste() {
|
||
if (props.disabled) return;
|
||
togglePaste();
|
||
if (pasteListening.value) {
|
||
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
|
||
} else {
|
||
message.info('已停止粘贴监听');
|
||
}
|
||
}
|
||
|
||
const showDragger = computed(() => {
|
||
if (!props.multiple) {
|
||
return fileList.value.filter((f) => f.status === 'done').length === 0;
|
||
}
|
||
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
revokePdfBlob();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="upload-dragger-paste">
|
||
<UploadDragger
|
||
v-if="showDragger"
|
||
:file-list="[]"
|
||
:accept="accept"
|
||
:multiple="multiple"
|
||
:disabled="disabled || uploading"
|
||
:before-upload="beforeUpload"
|
||
:custom-request="customRequest"
|
||
:show-upload-list="false"
|
||
:max-count="maxCount"
|
||
>
|
||
<p class="ant-upload-drag-icon">
|
||
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
|
||
</p>
|
||
<p class="ant-upload-text">{{ tip }}</p>
|
||
<p class="ant-upload-hint text-muted-foreground">
|
||
支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB
|
||
</p>
|
||
</UploadDragger>
|
||
|
||
<div v-if="fileList.length" class="mt-3 space-y-2">
|
||
<div
|
||
v-for="item in fileList"
|
||
:key="item.uid"
|
||
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
|
||
>
|
||
<Icon
|
||
:icon="
|
||
item.status === 'uploading'
|
||
? 'ant-design:loading-outlined'
|
||
: item.status === 'error'
|
||
? 'ant-design:close-circle-outlined'
|
||
: 'ant-design:paper-clip-outlined'
|
||
"
|
||
class="shrink-0"
|
||
/>
|
||
<button
|
||
v-if="item.url"
|
||
type="button"
|
||
class="min-w-0 flex-1 truncate text-left text-primary"
|
||
@click="previewItem(item)"
|
||
>
|
||
{{ item.name }}
|
||
</button>
|
||
<span v-else class="min-w-0 flex-1 truncate text-muted-foreground">{{ item.name }}</span>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
:disabled="disabled"
|
||
@click="handleRemove(item as any)"
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
|
||
<div class="mt-3">
|
||
<Button
|
||
:type="pasteListening ? 'primary' : 'default'"
|
||
:danger="pasteListening"
|
||
:disabled="disabled"
|
||
@click="onTogglePaste"
|
||
>
|
||
<Icon
|
||
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||
class="mr-1"
|
||
/>
|
||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||
</Button>
|
||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||
监听中:请将文件或截图 Ctrl+V 粘贴
|
||
</span>
|
||
</div>
|
||
|
||
<Modal
|
||
:open="previewVisible"
|
||
:title="previewTitle"
|
||
:footer="null"
|
||
width="80%"
|
||
:z-index="4000"
|
||
destroy-on-close
|
||
@cancel="onPreviewClose"
|
||
>
|
||
<div v-if="previewLoading" class="py-16 text-center text-muted-foreground">加载中…</div>
|
||
<div v-else-if="previewIsImage" class="flex justify-center rounded border border-border bg-muted/25 p-2">
|
||
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
|
||
</div>
|
||
<iframe
|
||
v-else-if="previewPdfUrl"
|
||
:src="previewPdfUrl"
|
||
class="h-[70vh] w-full rounded border border-border bg-background"
|
||
title="文件预览"
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
</template>
|