Files
zkxw/web-admin/src/components/oa-approval/OaAttachmentField.vue
T
2026-07-15 16:20:48 +08:00

267 lines
6.2 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.
<!-- 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>