267 lines
6.2 KiB
Vue
267 lines
6.2 KiB
Vue
<!-- OA 附件字段:值=逗号分隔 URL;卡片布局对齐协议附件 -->
|
||
<template>
|
||
<div class="oa-attach" :class="{ 'is-disabled': disabled }">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
v-if="!disabled && !reachedMax"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
:disabled="disabled || uploading"
|
||
accept="image/*,.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="(item, index) in items" :key="item.url + index" class="attachment-item">
|
||
<div class="attachment-preview" @click="preview(item)">
|
||
<img v-if="isImage(item.url)" :src="item.url" :alt="item.name" />
|
||
<div v-else class="attachment-preview__icon">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="item.name">{{ item.name }}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon class="action-btn" title="下载" @click="download(item)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
v-if="!disabled"
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeAt(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span v-if="!disabled" class="upload-hint">
|
||
{{ hintText }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Plus, Document, Download, Delete } from '@element-plus/icons-vue'
|
||
import { fetchAttachmentDownloadUrl, fetchUploadAttachment } from '@/api/business'
|
||
|
||
defineOptions({ name: 'OaAttachmentField' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
modelValue?: string
|
||
disabled?: boolean
|
||
maxCount?: number
|
||
maxSizeMb?: number
|
||
}>(),
|
||
{
|
||
modelValue: '',
|
||
disabled: false,
|
||
maxCount: 9,
|
||
maxSizeMb: 20
|
||
}
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string]
|
||
change: [value: string]
|
||
}>()
|
||
|
||
const uploading = ref(false)
|
||
|
||
const items = computed(() => {
|
||
const urls = String(props.modelValue || '')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
return urls.map((url) => ({
|
||
url,
|
||
name: decodeURIComponent(url.split('/').pop() || '附件')
|
||
}))
|
||
})
|
||
|
||
const reachedMax = computed(() => items.value.length >= props.maxCount)
|
||
|
||
const hintText = computed(
|
||
() => `支持图片、PDF、Office 等,最多 ${props.maxCount} 个,单文件不超过 ${props.maxSizeMb}MB`
|
||
)
|
||
|
||
function setValue(urls: string[]) {
|
||
const v = urls.filter(Boolean).join(',')
|
||
emit('update:modelValue', v)
|
||
emit('change', v)
|
||
}
|
||
|
||
function isImage(url: string) {
|
||
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(url)
|
||
}
|
||
|
||
async function onFileChange(uploadFile: any) {
|
||
const raw: File | undefined = uploadFile?.raw
|
||
if (!raw) return
|
||
if (reachedMax.value) {
|
||
ElMessage.warning(`最多上传 ${props.maxCount} 个附件`)
|
||
return
|
||
}
|
||
if (raw.size > props.maxSizeMb * 1024 * 1024) {
|
||
ElMessage.warning(`单文件不能超过 ${props.maxSizeMb}MB`)
|
||
return
|
||
}
|
||
uploading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('file', raw)
|
||
const res: any = await fetchUploadAttachment(fd)
|
||
const url = res?.fileUrl || res?.data?.fileUrl
|
||
if (!url) {
|
||
ElMessage.error('上传失败:未返回文件地址')
|
||
return
|
||
}
|
||
setValue([...items.value.map((x) => x.url), url])
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
function removeAt(index: number) {
|
||
const next = items.value.map((x) => x.url)
|
||
next.splice(index, 1)
|
||
setValue(next)
|
||
}
|
||
|
||
function preview(item: { url: string }) {
|
||
if (isImage(item.url)) {
|
||
window.open(item.url, '_blank')
|
||
} else {
|
||
download(item)
|
||
}
|
||
}
|
||
|
||
async function download(item: { url: string; name: string }) {
|
||
try {
|
||
const res: any = await fetchAttachmentDownloadUrl({
|
||
fileUrl: item.url,
|
||
fileName: item.name
|
||
})
|
||
const href = res?.downloadUrl || item.url
|
||
window.open(href, '_blank')
|
||
} catch {
|
||
window.open(item.url, '_blank')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.upload-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 140px;
|
||
height: 96px;
|
||
cursor: pointer;
|
||
background: #fff;
|
||
border: 1px dashed #a7f3d0;
|
||
border-radius: 8px;
|
||
|
||
&:hover {
|
||
background: var(--primary-bg, #ecfdf5);
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
margin-top: 6px;
|
||
color: #047857;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.attachment-item {
|
||
width: 140px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
border: 1px solid var(--mint-border, #e2e8f0);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.attachment-preview {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 72px;
|
||
cursor: pointer;
|
||
background: #f8fafc;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.attachment-preview__icon {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.attachment-footer {
|
||
display: flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.attachment-name {
|
||
overflow: hidden;
|
||
color: var(--mint-text, #334155);
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.attachment-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.action-btn {
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
color: var(--primary, #10b981);
|
||
}
|
||
|
||
&--danger:hover {
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: var(--mint-text-muted, #94a3b8);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.is-disabled {
|
||
.upload-card {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
</style>
|