|
|
@@ -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>
|