feat(web-admin): add OA bank receipt upload dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user