Files
zkxw/web-admin/src/views/cell-storage/sample-info/QuickRegisterAttachmentField.vue
T
zlwuandCursor ac8e471aca fix: align sample registration, freight scope, and OA datetime display
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>
2026-10-20 17:30:30 +08:00

381 lines
9.7 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.
<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>