feat(web-admin): add OA bank receipt upload dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-16 16:58:24 +08:00
1 parent 209fb7bff3
commit 6c8b8025d7
1 file changed
+494
@@ -0,0 +1,494 @@
<template>
<el-dialog
:model-value="modelValue"
title="上传银行回执单"
width="720px"
class="dialog-form-layout oa-bank-receipt-dialog"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="receipt-body">
<div class="meta">
<div>
<div class="meta-label">流程名称</div>
<div class="meta-value">{{ meta?.appName || '—' }}</div>
</div>
<div>
<div class="meta-label">申请人</div>
<div class="meta-value">{{ meta?.initiatorUserName || '—' }}</div>
</div>
<div>
<div class="meta-label">申请时间</div>
<div class="meta-value">{{ meta?.applyTime || '—' }}</div>
</div>
<div>
<div class="meta-label">已上传</div>
<div class="meta-value">{{ attachments.length }} 张回执单</div>
</div>
</div>
<p class="section-label">银行回执单</p>
<div
class="attachment-upload"
:class="{ 'is-dragover': isAttachDragover }"
v-bind="attachDropHandlers"
>
<div class="attachment-list">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, index) in attachments"
:key="file.id || index"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreview(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>
<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="handleDownload(file)"
>
<Download />
</el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeAttachment(index)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<span class="upload-hint">
支持拖拽或点击上传图片、PDF 等文件,单文件不超过 20MB;可上传多张回执单
</span>
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
<el-dialog
v-model="previewVisible"
title="预览"
width="800px"
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
>
<img v-if="previewUrl" :src="previewUrl" alt="preview" class="preview-img" />
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
Check,
Close,
Delete,
Document,
Download,
Loading,
Plus
} from '@element-plus/icons-vue'
import {
fetchOaBankReceipts,
fetchReplaceOaBankReceipts,
type OaBankReceiptItem
} from '@/api/oaApproval'
import { fetchUploadAttachment } from '@/api/business'
import { useAttachmentDropZone } from '@/composables/useAttachmentDropZone'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'OaBankReceiptDialog' })
type AttachmentItem = OaBankReceiptItem & { _uploading?: boolean }
const props = defineProps<{
modelValue: boolean
instanceId: string
meta?: {
appName?: string
initiatorUserName?: string
applyTime?: string
}
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
saved: []
}>()
const loading = ref(false)
const saving = ref(false)
const attachments = ref<AttachmentItem[]>([])
const uploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(suffix: string) {
const s = String(suffix || '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
return minioBaseUrl ? `${minioBaseUrl}/${s.replace(/^\//, '')}` : s
}
function isImageFile(file: AttachmentItem) {
const name = String(file.fileName || file.fileUrl || '').toLowerCase()
const type = String(file.fileType || '').toLowerCase()
if (type.startsWith('image/')) return true
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name)
}
async function loadReceipts() {
const id = String(props.instanceId || '').trim()
if (!id) {
attachments.value = []
return
}
loading.value = true
try {
const res = await fetchOaBankReceipts(id)
attachments.value = (res?.items || []).map((it) => ({
id: it.id,
fileName: it.fileName || '',
fileUrl: it.fileUrl || '',
fileType: it.fileType,
fileSize: it.fileSize,
createTime: it.createTime,
_uploading: false
}))
} catch {
attachments.value = []
ElMessage.error('加载银行回执单失败')
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.instanceId] as const,
([visible]) => {
if (visible) void loadReceipts()
}
)
async function handleAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
if (file.size > 20 * 1024 * 1024) {
ElMessage.error('单文件不超过 20MB')
await nextTick()
uploadRef.value?.clearFiles?.()
return
}
const tempId = `temp_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`
attachments.value.push({
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type,
fileSize: file.size,
_uploading: true
})
const idx = attachments.value.length - 1
try {
const fd = new FormData()
fd.append('folder', 'oa-bank-receipt')
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
attachments.value[idx] = {
id: res?.id || tempId,
fileName: file.name || res?.fileName || '',
fileUrl: res?.fileUrl || '',
fileType: res?.fileType || file.type,
fileSize: res?.fileSize ?? file.size,
_uploading: false
}
} catch {
ElMessage.error('文件上传失败')
attachments.value.splice(idx, 1)
} finally {
await nextTick()
uploadRef.value?.clearFiles?.()
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function handleDownload(file: AttachmentItem) {
if (!file.fileUrl) return
downloadAttachmentFile(file, { fullFileUrl })
}
function handlePreview(file: AttachmentItem) {
if (!file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewVisible.value = true
}
const { isDragover: isAttachDragover, dropZoneHandlers: attachDropHandlers } =
useAttachmentDropZone({
onFiles: (files) => {
for (const file of files) {
void handleAttachmentChange({ raw: file })
}
}
})
async function handleSave() {
const id = String(props.instanceId || '').trim()
if (!id) return
if (attachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
saving.value = true
try {
const items = attachments.value
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => ({
fileUrl: f.fileUrl,
fileName: f.fileName,
fileType: f.fileType,
fileSize: f.fileSize
}))
await fetchReplaceOaBankReceipts(id, items)
ElMessage.success('银行回执单已保存')
emit('saved')
emit('update:modelValue', false)
} catch {
// request 层通常已提示
} finally {
saving.value = false
}
}
const meta = computed(() => props.meta || {})
</script>
<style scoped lang="scss">
.receipt-body {
min-height: 120px;
}
.meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
margin-bottom: 20px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.meta-label {
margin-bottom: 4px;
font-size: 11px;
color: #94a3b8;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.section-label {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
width: 140px;
height: 96px;
cursor: pointer;
background: #f0fdf4;
border: 1px dashed #a7f3d0;
border-radius: 8px;
&:hover {
background: #ecfdf5;
border-color: #10b981;
border-style: solid;
}
}
.upload-card-text {
font-size: 12px;
font-weight: 500;
line-height: 1;
color: #10b981;
}
.upload-hint {
display: block;
margin-top: 8px;
font-size: 11px;
line-height: 1.4;
color: #94a3b8;
}
.attachment-item {
width: 140px;
overflow: hidden;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&.is-uploading {
pointer-events: none;
opacity: 0.6;
}
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
}
.attachment-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96px;
overflow: hidden;
cursor: pointer;
background: #f8fafc;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 10px;
}
.attachment-preview__icon--doc {
color: #0284c7;
background: #e0f2fe;
}
.attachment-footer {
padding: 6px 8px 8px;
}
.attachment-name {
display: -webkit-box;
min-height: 30px;
margin-bottom: 4px;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 11px;
line-height: 1.35;
color: #334155;
-webkit-box-orient: vertical;
}
.attachment-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.action-btn {
width: 22px;
height: 22px;
color: #64748b;
cursor: pointer;
border-radius: 4px;
&:hover {
color: #10b981;
background: #ecfdf5;
}
&.action-btn--danger:hover {
color: #ef4444;
background: #fef2f2;
}
}
.preview-img {
display: block;
max-width: 100%;
margin: 0 auto;
}
</style>