Let customer-service quick register write storage, certificate, and insurance separately without extra menu or data-scope checks. List freight by document data scope like the web admin, allow protocol staff outside the collection region, and pad OA datetimes to seconds so PC date pickers can show historical minute values. Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
9.7 KiB
Vue
381 lines
9.7 KiB
Vue
<template>
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isAttachDragover }"
|
||
v-bind="attachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="attachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="onFileChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in files"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</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 class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
|
||
<Download />
|
||
</el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="files.splice(index, 1)"
|
||
>
|
||
<Delete />
|
||
</el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
|
||
<el-dialog
|
||
v-model="previewVisible"
|
||
title="文件预览"
|
||
width="800px"
|
||
align-center
|
||
append-to-body
|
||
destroy-on-close
|
||
>
|
||
<div v-if="previewType === 'image'" class="preview-container">
|
||
<img
|
||
:src="previewUrl"
|
||
alt="预览"
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
<div v-else class="preview-container">
|
||
<video
|
||
:src="previewUrl"
|
||
controls
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Delete, Document, Download, Loading, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { fetchUploadAttachment } from '@/api/business'
|
||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||
import { useAttachmentDropZone } from '@/composables/useAttachmentDropZone'
|
||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||
|
||
export type QuickRegisterAttachmentItem = {
|
||
id: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize?: number
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}
|
||
|
||
const props = defineProps<{ folder: string }>()
|
||
const files = defineModel<QuickRegisterAttachmentItem[]>({ required: true })
|
||
|
||
const MAX_FILE_BYTES = 20 * 1024 * 1024
|
||
const attachmentUploadRef = ref<{ clearFiles?: () => void } | null>(null)
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function isImageFile(file: QuickRegisterAttachmentItem) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||
(file.fileType || '').startsWith('image/')
|
||
)
|
||
}
|
||
|
||
function isVideoFile(file: QuickRegisterAttachmentItem) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||
(file.fileType || '').startsWith('video/')
|
||
)
|
||
}
|
||
|
||
function fullFileUrl(suffix: string) {
|
||
return attachmentFullUrl(suffix)
|
||
}
|
||
|
||
function handlePreviewAttachment(file: QuickRegisterAttachmentItem) {
|
||
if (file._uploading || !file.fileUrl) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: QuickRegisterAttachmentItem) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
async function onFileChange(uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
if (file.size > MAX_FILE_BYTES) {
|
||
ElMessage.error('单文件不超过 20MB')
|
||
nextTick(() => attachmentUploadRef.value?.clearFiles?.())
|
||
return
|
||
}
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
files.value.push({
|
||
id: tempId,
|
||
fileName: file.name || '附件',
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', props.folder)
|
||
fd.append('file', file)
|
||
const res = (await fetchUploadAttachment(fd)) as {
|
||
id?: string
|
||
fileName?: string
|
||
fileUrl?: string
|
||
fileType?: string
|
||
}
|
||
const idx = files.value.findIndex((item) => item.id === tempId)
|
||
if (idx < 0) return
|
||
files.value[idx] = {
|
||
id: String(res.id || tempId),
|
||
fileName: file.name || res.fileName || '附件',
|
||
fileUrl: res.fileUrl || '',
|
||
fileSize: file.size,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
const idx = files.value.findIndex((item) => item.id === tempId)
|
||
if (idx >= 0) files.value.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => attachmentUploadRef.value?.clearFiles?.())
|
||
}
|
||
}
|
||
|
||
const { isDragover: isAttachDragover, dropZoneHandlers: attachDropHandlers } =
|
||
useAttachmentDropZone({
|
||
onFiles: (dropped) => {
|
||
for (const file of dropped) {
|
||
void onFileChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.attachment-upload {
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-card {
|
||
width: 140px;
|
||
height: 96px;
|
||
border: 1px dashed #a8d4c4;
|
||
border-radius: 8px;
|
||
background: #f0fdf4;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #047857;
|
||
border-style: solid;
|
||
background: #e8f4ee;
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
font-size: 12px;
|
||
color: #047857;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--mint-text-muted, #334155);
|
||
line-height: 1;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.attachment-item {
|
||
width: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #047857;
|
||
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.1);
|
||
}
|
||
|
||
&.is-uploading {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.attachment-preview {
|
||
width: 100%;
|
||
height: 96px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8fafc;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.attachment-preview__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&--video {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
&--doc {
|
||
background: #e0f2fe;
|
||
color: #0284c7;
|
||
}
|
||
}
|
||
|
||
.attachment-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.attachment-name {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 11px;
|
||
color: #334155;
|
||
line-height: 1.4;
|
||
word-break: break-all;
|
||
min-height: calc(11px * 1.4 * 2);
|
||
}
|
||
|
||
.attachment-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: var(--mint-text-muted, #334155);
|
||
cursor: pointer;
|
||
background: #f1f5f9;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: #047857;
|
||
color: #fff;
|
||
}
|
||
|
||
&--danger:hover {
|
||
background: #ef4444;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.preview-container {
|
||
text-align: center;
|
||
}
|
||
</style>
|