Quellcode durchsuchen

feat(pressure2): 添加整改文件上传组件

- 实现文件上传功能支持图片、视频、PDF等多种格式
- 添加文件预览网格显示图片和视频缩略图
- 集成视频播放器支持视频大屏播放弹窗
- 实现文件下载和删除操作功能
- 支持文件大小限制和格式验证
- 添加文件列表管理和上传进度显示
xuzhancheng vor 4 Tagen
Ursprung
Commit
a885e22cef

+ 5 - 7
yudao-ui-admin-vue3/src/views/pressure2/boilerchecker/components/StatusOperationPanel.vue

@@ -772,13 +772,13 @@
           <span class="section-tip">仅支持PDF格式,限1个文件</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationUrl"
             apiUrl="infra/file/upload"
             :limit="1"
             :disabled="ectificationMaterialsData.rectificationUrl.length >= 1 || ectificationMaterialsLoading"
             accept=".pdf"
-            listType="picture"
+            :default-file-name="selectedItem?.reportName || '检验意见通知书'"
           />
         </div>
       </div>
@@ -793,7 +793,7 @@
           <span class="section-tip">支持JPG、PNG格式,最多20张,单个不超过2MB</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationImage"
             apiUrl="infra/file/upload"
             :limit="20"
@@ -801,7 +801,6 @@
             accept=".jpg,.jpeg,.png"
             :multiple="true"
             :maxSize="2097152"
-            listType="picture"
           />
         </div>
       </div>
@@ -816,7 +815,7 @@
           <span class="section-tip">支持MP4格式,最多5个,单个不超过10MB</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationVideo"
             apiUrl="infra/file/upload"
             :limit="5"
@@ -824,7 +823,6 @@
             accept=".mp4"
             :multiple="true"
             :maxSize="10485760"
-            listType="picture"
           />
         </div>
       </div>
@@ -855,7 +853,7 @@
 
 <script setup lang="tsx">
 import SmartTable from '@/components/SmartTable/SmartTable'
-import CustomUploadFile from '@/components/CustomUploadFile/index.vue'
+import RectificationUpload from '@/views/pressure2/components/RectificationUpload.vue'
 import { computed, nextTick, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { InfoFilled, View, Back, Right, WarningFilled, VideoPlay } from '@element-plus/icons-vue'

+ 369 - 0
yudao-ui-admin-vue3/src/views/pressure2/components/RectificationUpload.vue

@@ -0,0 +1,369 @@
+<template>
+<div class="rectification-upload">
+  <el-upload
+    action="#"
+    :file-list="uploadFileList"
+    :accept="accept"
+    :before-upload="beforeUpload"
+    :http-request="handlePostFile"
+    :show-file-list="false"
+    :limit="limit"
+    :multiple="multiple"
+  >
+    <slot name="trigger">
+      <el-button :disabled="disabled">
+        <el-icon><Upload /></el-icon>
+        上传文件
+      </el-button>
+    </slot>
+  </el-upload>
+
+  <!-- PDF 文件列表(纯文字,无预览网格) -->
+  <div v-if="isPdfOnly && processedList.length > 0" class="pdf-file-list">
+    <div v-for="(file, index) in processedList" :key="file.uid || index" class="pdf-file-item">
+      <el-icon :size="18"><Document /></el-icon>
+      <span class="pdf-file-name" :title="displayName(file)">{{ displayName(file) }}</span>
+      <span class="pdf-action download" @click="handleDownload(file)">下载</span>
+      <span class="pdf-action" @click="handleRemove(file)">删除</span>
+    </div>
+  </div>
+
+  <!-- 图片/视频预览网格 -->
+  <div v-if="!isPdfOnly && processedList.length > 0" class="preview-grid">
+    <div v-for="(file, index) in processedList" :key="file.uid || index" class="preview-item">
+      <div class="item-body" @click="handleItemClick(file)">
+        <el-image
+          v-if="isImage(file)"
+          class="item-thumb"
+          :src="file.url"
+          :preview-src-list="allImageUrls"
+          fit="cover"
+          preview-teleported
+        />
+        <video v-else class="item-thumb" :src="file.url"></video>
+        <div v-if="isVideo(file)" class="play-overlay">
+          <el-icon :size="28"><VideoPlay /></el-icon>
+        </div>
+      </div>
+      <div class="item-footer">
+        <span class="item-name" :title="file.name">{{ file.name }}</span>
+        <div class="item-actions">
+          <span class="action-btn" @click.stop="handleDownload(file)">下载</span>
+          <span class="action-btn delete" @click.stop="handleRemove(file)">删除</span>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 视频大屏播放弹窗 -->
+  <el-dialog
+    v-model="videoVisible"
+    class="preview-dialog"
+    width="auto"
+    :show-footer="false"
+    :show-close="false"
+    align-center
+    destroy-on-close
+  >
+    <div class="video-preview-container">
+      <video :src="currentVideoUrl" autoplay controls style="max-height: 75vh; max-width: 90vw;"></video>
+    </div>
+  </el-dialog>
+</div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { Upload, Document, VideoPlay } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import request from '@/config/axios'
+import { getAccessToken } from '@/utils/auth'
+import { buildFileUrl } from '@/utils'
+
+const VITE_BASE_URL = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/'
+
+const props = defineProps({
+  accept: { type: String, default: '' },
+  fileList: { type: Array, default: () => [] },
+  limit: { type: Number, default: 0 },
+  disabled: { type: Boolean, default: false },
+  maxSize: { type: Number, default: 0 },
+  multiple: { type: Boolean, default: false },
+  apiUrl: { type: String, required: true },
+  defaultFileName: { type: String, default: '' }
+})
+
+const emit = defineEmits(['update:fileList'])
+
+const videoVisible = ref(false)
+const currentVideoUrl = ref('')
+
+const isPdfOnly = computed(() => props.accept.includes('.pdf'))
+
+const displayName = (file) => {
+  if (isPdfOnly.value && props.defaultFileName) {
+    return props.defaultFileName + '.pdf'
+  }
+  return file.name
+}
+
+const processedList = computed(() => {
+  return (props.fileList || []).map(file => ({
+    ...file,
+    url: file.url ? buildFileUrl(file.url) : (file.path ? buildFileUrl(file.path) : '')
+  }))
+})
+
+const uploadFileList = computed(() => {
+  return processedList.value.map(file => ({
+    name: file.name,
+    url: file.url,
+    uid: file.uid
+  }))
+})
+
+const allImageUrls = computed(() => {
+  return processedList.value.filter(f => isImage(f)).map(f => f.url)
+})
+
+const isImage = (file) => {
+  const ext = (file.name || file.url || '').split('.').pop()?.toLowerCase()
+  return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)
+}
+
+const isVideo = (file) => {
+  const ext = (file.name || file.url || '').split('.').pop()?.toLowerCase()
+  return ['mp4', 'webm', 'ogg', 'mov', 'avi'].includes(ext)
+}
+
+const handleItemClick = (file) => {
+  if (isVideo(file)) {
+    currentVideoUrl.value = file.url
+    videoVisible.value = true
+  }
+}
+
+const handleDownload = (file) => {
+  const link = document.createElement('a')
+  link.href = file.url
+  link.download = file.name
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+}
+
+const handleRemove = (file) => {
+  const newList = [...props.fileList]
+  const index = newList.findIndex(f => {
+    if (file.uid && f.uid) return f.uid === file.uid
+    if (file.url && f.url) return f.url === file.url
+    return f.name === file.name
+  })
+  if (index > -1) {
+    newList.splice(index, 1)
+    emit('update:fileList', newList)
+  }
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+const beforeUpload = (file) => {
+  if (props.limit > 0 && processedList.value.length >= props.limit) {
+    ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
+    return false
+  }
+  if (props.accept !== '*' && props.accept) {
+    const ext = '.' + (file.name || '').split('.').pop()?.toLowerCase()
+    const acceptExts = props.accept.split(',').map(s => s.trim().toLowerCase())
+    if (!acceptExts.includes(ext)) {
+      ElMessage.error('只能上传 ' + props.accept + ' 格式文件')
+      return false
+    }
+  }
+  if (props.maxSize > 0 && file.size > props.maxSize) {
+    ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
+    return false
+  }
+  return true
+}
+
+const handlePostFile = async (options) => {
+  const { file, onProgress, onSuccess, onError } = options
+  try {
+    const formData = new FormData()
+    formData.append('file', file)
+    const result = await request.post({
+      url: VITE_BASE_URL + props.apiUrl,
+      data: formData,
+      headers: {
+        authorization: 'Bearer ' + getAccessToken(),
+        'Content-Type': 'multipart/form-data'
+      },
+      onUploadProgress: (e) => {
+        const percent = Math.round((e.loaded / file.size) * 100)
+        onProgress({ percent })
+      }
+    })
+    if (result) {
+      const fileObj = {
+        name: file.name,
+        url: result,
+        uid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
+      }
+      const newList = [...props.fileList, fileObj]
+      emit('update:fileList', newList)
+      onSuccess(result)
+    }
+  } catch (err) {
+    onError(err)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.rectification-upload {
+  width: 100%;
+}
+
+.pdf-file-list {
+  margin-top: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.pdf-file-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  background: #fafbfc;
+  font-size: 13px;
+
+  .el-icon {
+    color: #409eff;
+    flex-shrink: 0;
+  }
+
+  .pdf-file-name {
+    flex: 1;
+    color: #303133;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .pdf-action {
+    color: #f56c6c;
+    cursor: pointer;
+    font-size: 12px;
+    flex-shrink: 0;
+    &:hover { text-decoration: underline; }
+  }
+}
+
+.preview-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 10px;
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px dashed #e4e7ed;
+}
+
+.preview-item {
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  overflow: hidden;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+
+  .item-body {
+    position: relative;
+    width: 100%;
+    aspect-ratio: 1;
+    cursor: pointer;
+    background: #f5f7fa;
+
+    .item-thumb {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    .play-overlay {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      background: rgba(0, 0, 0, 0.25);
+      color: rgba(255, 255, 255, 0.85);
+      pointer-events: none;
+      transition: background 0.2s;
+    }
+
+    &:hover .play-overlay {
+      background: rgba(0, 0, 0, 0.45);
+    }
+  }
+
+  .item-footer {
+    padding: 6px 8px;
+    background: #fafbfc;
+    border-top: 1px solid #f0f0f0;
+    flex-shrink: 0;
+
+    .item-name {
+      display: block;
+      font-size: 11px;
+      color: #606266;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      margin-bottom: 4px;
+      line-height: 1.4;
+    }
+
+    .item-actions {
+      display: flex;
+      gap: 10px;
+
+      .action-btn {
+        font-size: 11px;
+        color: #409eff;
+        cursor: pointer;
+        &:hover { text-decoration: underline; }
+        &.delete { color: #f56c6c; }
+      }
+    }
+  }
+}
+
+:deep(.preview-dialog) {
+  background: transparent !important;
+  .el-dialog__header { display: none; }
+  .el-dialog__body { padding: 0; }
+}
+
+.video-preview-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: #000;
+  border-radius: 4px;
+  padding: 10px;
+}
+</style>

+ 5 - 7
yudao-ui-admin-vue3/src/views/pressure2/pipechecker/components/StatusOperationPanel.vue

@@ -775,13 +775,13 @@
           <span class="section-tip">仅支持PDF格式,限1个文件</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationUrl"
             apiUrl="infra/file/upload"
             :limit="1"
             :disabled="ectificationMaterialsData.rectificationUrl.length >= 1 || ectificationMaterialsLoading"
             accept=".pdf"
-            listType="picture"
+            :default-file-name="selectedItem?.reportName || '检验意见通知书'"
           />
         </div>
       </div>
@@ -796,7 +796,7 @@
           <span class="section-tip">支持JPG、PNG格式,最多20张,单个不超过2MB</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationImage"
             apiUrl="infra/file/upload"
             :limit="20"
@@ -804,7 +804,6 @@
             accept=".jpg,.jpeg,.png"
             :multiple="true"
             :maxSize="2097152"
-            listType="picture"
           />
         </div>
       </div>
@@ -819,7 +818,7 @@
           <span class="section-tip">支持MP4格式,最多5个,单个不超过10MB</span>
         </div>
         <div class="upload-area">
-          <CustomUploadFile
+          <RectificationUpload
             v-model:fileList="ectificationMaterialsData.rectificationVideo"
             apiUrl="infra/file/upload"
             :limit="5"
@@ -827,7 +826,6 @@
             accept=".mp4"
             :multiple="true"
             :maxSize="10485760"
-            listType="picture"
           />
         </div>
       </div>
@@ -858,7 +856,7 @@
 
 <script setup lang="tsx">
 import SmartTable from '@/components/SmartTable/SmartTable'
-import CustomUploadFile from '@/components/CustomUploadFile/index.vue'
+import RectificationUpload from '@/views/pressure2/components/RectificationUpload.vue'
 import {computed, defineAsyncComponent, nextTick, reactive, ref, watch} from 'vue'
 import {useRoute} from 'vue-router'
 import { InfoFilled, View, Back, Right, WarningFilled, VideoPlay } from '@element-plus/icons-vue'