Pārlūkot izejas kodu

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 nedēļas atpakaļ
vecāks
revīzija
6c8b8025d7

+ 494 - 0
web-admin/src/components/oa-approval/OaBankReceiptDialog.vue

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