Files
xk-admin/apps/web-antd/src/components/form/components/upload-dragger-paste.vue
李琦 879efb8f29 feat: 管理端公账支付与确认、锁店遮罩及表单提交修复
新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板;
统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
2026-08-21 12:51:42 +08:00

373 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 = '';
}
/**
* 预览已上传文件:图片用 ImagePDF 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>