feat(pl): 承运商资质附件改为选填并支持拖拽粘贴上传

取消承运商营业执照/许可证/运输协议的必填校验,附件组件支持拖拽与区域粘贴上传,并同步知识图谱。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 15:47:10 +08:00
1 parent 55410191ec
commit 87bd0d92b9
14 files changed
+733737 -164976

No files matched your search

@@ -1,10 +1,21 @@
<template>
<div
ref="rootRef"
class="attachment-upload"
:class="{
'attachment-upload--compact': compact,
'attachment-upload--readonly': compact && disabled
'attachment-upload--readonly': compact && disabled,
'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
@@ -18,7 +29,7 @@
>
<div class="upload-card">
<el-icon :size="previewIconSize" color="#1ca851"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
<span class="upload-card-text">{{ isDragOver ? '松开上传' : '上传附件' }}</span>
</div>
</el-upload>
<div
@@ -133,7 +144,7 @@
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
Plus,
Loading,
@@ -176,7 +187,7 @@ const props = withDefaults(
folder: 'crm/customer',
limit: 15,
accept: 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar',
hint: '支持图片、视频、PDF、Word、Excel等文件',
hint: '支持图片、视频、PDF、Word、Excel等文件;可拖拽或在此区域粘贴上传',
disabled: false,
compact: false,
categoryLabels: () => ({})
@@ -187,12 +198,16 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const rootRef = ref<HTMLElement>()
const uploadRef = ref<UploadInstance>()
const attachments = ref<AttachmentItem[]>([])
const previewVisible = ref(false)
const previewFile = ref<AttachmentItem | null>(null)
const previewPdfSrc = ref('')
const previewLoading = ref(false)
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
const previewIconSize = computed(() => {
if (!props.compact) return 28
@@ -284,14 +299,61 @@ function canPreview(file: AttachmentItem) {
return Boolean(file.fileUrl) && (isImageFile(file) || isVideoFile(file) || isPdfFile(file))
}
async function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
function isFileAccepted(file: File) {
const accept = String(props.accept || '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
if (!accept.length) return true
const name = String(file.name || '').toLowerCase()
const type = String(file.type || '').toLowerCase()
return accept.some((token) => {
if (token === '*/*') return true
if (token.endsWith('/*')) {
return type.startsWith(token.slice(0, -1))
}
if (token.startsWith('.')) {
return name.endsWith(token)
}
return type === token
})
}
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 files: 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) files.push(normalizeIncomingFile(file, index))
})
}
if (!files.length && event.clipboardData?.files?.length) {
Array.from(event.clipboardData.files).forEach((file, index) => {
files.push(normalizeIncomingFile(file, index))
})
}
return files
}
async function uploadRawFile(rawFile: File) {
if (attachments.value.length >= props.limit) {
ElMessage.warning(`最多上传 ${props.limit} 个附件`)
uploadRef.value?.clearFiles()
return
return false
}
if (!isFileAccepted(rawFile)) {
ElMessage.warning(`不支持的文件类型:${rawFile.name || '未知文件'}`)
return false
}
const item: AttachmentItem = {
@@ -320,15 +382,99 @@ async function handleAttachmentChange(uploadFile: UploadFile) {
item.fileType = res.fileType || item.fileType
item._uploading = false
syncModelValue()
return true
} catch (error: any) {
attachments.value = attachments.value.filter((entry) => entry.id !== item.id)
ElMessage.error(error?.message || '上传失败')
} finally {
await nextTick()
uploadRef.value?.clearFiles()
return false
}
}
async function uploadFiles(files: File[]) {
if (!files.length || props.disabled) return
for (const file of files) {
if (attachments.value.length >= props.limit) {
ElMessage.warning(`最多上传 ${props.limit} 个附件`)
break
}
await uploadRawFile(file)
}
}
async function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
await uploadRawFile(rawFile)
await nextTick()
uploadRef.value?.clearFiles()
}
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
const files = Array.from(event.dataTransfer?.files || [])
await uploadFiles(files)
}
async function handleDocumentPaste(event: ClipboardEvent) {
if (props.disabled || !isPointerInside.value) return
const files = collectClipboardFiles(event)
if (!files.length) return
event.preventDefault()
event.stopPropagation()
await uploadFiles(files)
}
onMounted(() => {
document.addEventListener('paste', handleDocumentPaste, true)
})
onBeforeUnmount(() => {
document.removeEventListener('paste', handleDocumentPaste, true)
})
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
syncModelValue()
@@ -387,6 +533,28 @@ defineExpose({ hasUploading })
<style scoped lang="scss">
.attachment-upload {
width: 100%;
outline: none;
border-radius: 8px;
&:focus-visible {
box-shadow: 0 0 0 2px rgba(28, 168, 81, 0.25);
}
&--drag-over {
.attachment-list {
border-radius: 8px;
background: rgba(16, 185, 129, 0.06);
outline: 2px dashed #1ca851;
outline-offset: 4px;
padding: 4px;
}
.upload-card {
border-color: #1ca851;
border-style: solid;
background: #e6f8ed;
}
}
}
.attachment-list {
@@ -27,14 +27,6 @@
folder="pl/carrier/license"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">道路经营许可证</div>
<AttachmentUploadField
@@ -43,6 +35,14 @@
folder="pl/carrier/permit"
/>
</div>
<div class="carrier-view-attachment-item">
<div class="carrier-view-attachment-label">运输协议</div>
<AttachmentUploadField
:model-value="carrier.transportAgreementUrls || ''"
disabled
folder="pl/carrier/agreement"
/>
</div>
</div>
</template>