Selaa lähdekoodia

封装上传公共组件

zengy 4 päivää sitten
vanhempi
commit
40af0f563a

+ 7 - 4
.env.production

@@ -4,11 +4,14 @@ VITE_APP_TITLE = 地下管网管控平台
 # 生产环境配置
 VITE_APP_ENV = 'production'
 
-# 若依管理系统/生产环境
-VITE_APP_BASE_API = 'http://172.16.102.52:8301'
+# API 使用同源网关路径,由部署 Nginx 转发到 Gateway
+VITE_APP_BASE_API = '/pipe'
 
 # 是否在打包时开启压缩,支持 gzip 和 brotli
 VITE_BUILD_COMPRESS = gzip
 
-# MinIO文件访问基础地址
-VITE_APP_MINIO_BASE_URL = 'http://172.16.102.52:9000/pipe/'
+# MinIO 统一通过同源 Nginx 路径访问,不在前端绑定服务器 IP
+VITE_APP_MINIO_BASE_URL = '/minio/pipe/'
+
+# PC 工单 WebSocket 由 Nginx 同源转发到后端
+VITE_DEVICE_STATUS_WS_URL = ''

+ 3 - 5
src/api/alarmReview.js

@@ -6,9 +6,7 @@ export const getAlarmReviewStatistics = params => request({ url: '/api/alarm/rev
 export const getAlarmReviewDetail = (id, params) => request({ url: `/api/alarm/reviews/${encodeURIComponent(id)}`, method: 'get', params })
 export const getAlarmLogs = (id, params) => request({ url: `/api/alarm/${encodeURIComponent(id)}/logs`, method: 'get', params })
 export const submitAlarmReview = (id, params, data) => request({ url: `/api/alarm/${encodeURIComponent(id)}/reviews`, method: 'post', params, data })
-export const uploadAlarmReviewAttachment = (id, params, file) => {
-  const data = new FormData()
-  data.append('file', file)
-  // 多附件会并发上传,FormData 不能参与框架 JSON 字符串防重(均被转成 {})。
-  return request({ url: `/api/alarm/${encodeURIComponent(id)}/attachments`, method: 'post', params, data, headers: { repeatSubmit: false }, timeout: 60000 })
+/** 报警核实附件批量上传:一次 /minio/uploads 请求,避免逐张调用单文件接口 */
+export const uploadAlarmReviewAttachments = (id, params, form) => {
+  return request({ url: `/minio/uploads?dir=alarm`, method: 'post', data: form, headers: { repeatSubmit: false }, timeout: 60000 })
 }

+ 3 - 4
src/api/system/user.js

@@ -108,13 +108,12 @@ export function updateUserPwd(oldPassword, newPassword) {
   })
 }
 
-// 用户头像上传
-export function uploadAvatar(data) {
+// 用户头像设置(传入 /minio/upload?dir=user 返回的相对路径)
+export function uploadAvatar(avatar) {
   return request({
     url: '/system/user/profile/avatar',
     method: 'post',
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    data: data
+    params: { avatar }
   })
 }
 

+ 4 - 3
src/components/Editor/index.vue

@@ -30,11 +30,12 @@
 import { QuillEditor } from "@vueup/vue-quill";
 import "@vueup/vue-quill/dist/vue-quill.snow.css";
 import { getToken } from "@/utils/auth";
+import { buildMinioAttachmentUrl } from "@/utils/minioAttachment";
 
 const { proxy } = getCurrentInstance();
 
 const quillEditorRef = ref();
-const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/common/upload"); // 上传的图片服务器地址
+const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/minio/upload?dir=other"); // 上传的图片服务器地址
 const headers = ref({
   Authorization: "Bearer " + getToken()
 });
@@ -161,8 +162,8 @@ function handleUploadSuccess(res, file) {
     let quill = toRaw(quillEditorRef.value).getQuill();
     // 获取光标位置
     let length = quill.selection.savedRange.index;
-    // 插入图片,res.url为服务器返回的图片链接地址
-    quill.insertEmbed(length, "image", import.meta.env.VITE_APP_BASE_API + res.fileName);
+    // Insert the uploaded MinIO object through the shared frontend base URL.
+    quill.insertEmbed(length, "image", buildMinioAttachmentUrl(res.relativePath || res.url));
     // 调整光标到最后
     quill.setSelection(length + 1);
   } else {

+ 67 - 15
src/components/ImageUpload/index.vue

@@ -2,7 +2,7 @@
   <div class="component-upload-image">
     <el-upload
       multiple
-      :action="uploadImgUrl"
+      :http-request="customUpload"
       list-type="picture-card"
       :on-success="handleUploadSuccess"
       :before-upload="handleBeforeUpload"
@@ -37,6 +37,7 @@
 </template>
 
 <script setup>
+import request from "@/utils/request";
 import { getToken } from "@/utils/auth";
 import { buildMinioAttachmentUrl, minioAttachmentPath } from "@/utils/minioAttachment";
 
@@ -57,7 +58,68 @@ const uploadList = ref([]);
 const dialogImageUrl = ref("");
 const dialogVisible = ref(false);
 const baseUrl = import.meta.env.VITE_APP_BASE_API;
-const uploadImgUrl = ref(props.uploadUrl || baseUrl + "/common/upload");
+
+/** 从 uploadUrl 中解析业务目录(dir 参数),默认 other */
+function resolveDir() {
+  const u = props.uploadUrl || "";
+  const m = u.match(/[?&]dir=([^&]+)/i);
+  return m ? decodeURIComponent(m[1]) : "other";
+}
+
+/**
+ * 多文件合并为一次 /minio/uploads 请求,避免逐张调用单文件接口。
+ * el-upload 的 multiple 选择会为每个文件各调一次 http-request,
+ * 这里把同一事件循环内的请求收集到批次,setTimeout 后一次性上传。
+ */
+let uploadBatch = [];
+let uploadBatchTimer = null;
+function customUpload(options) {
+  return new Promise((resolve, reject) => {
+    uploadBatch.push({ options, resolve, reject });
+    if (uploadBatchTimer) clearTimeout(uploadBatchTimer);
+    uploadBatchTimer = setTimeout(flushUploadBatch, 0);
+  });
+}
+function flushUploadBatch() {
+  uploadBatchTimer = null;
+  const group = uploadBatch.splice(0);
+  const form = new FormData();
+  group.forEach(({ options }) => form.append("files", options.file));
+  const dir = resolveDir();
+  request({
+    url: `/minio/uploads?dir=${encodeURIComponent(dir)}`,
+    method: "post",
+    data: form,
+    headers: { repeatSubmit: false },
+    timeout: 60000
+  }).then((res) => {
+    if (res.code === 200) {
+      const infos = res.files || [];
+      group.forEach(({ options, resolve }, i) => {
+        const info = infos[i] || {};
+        options.onSuccess({
+          relativePath: info.relativePath || info.url,
+          url: info.url,
+          fileName: info.fileName,
+          originalFilename: info.originalFilename
+        });
+        resolve();
+      });
+    } else {
+      group.forEach(({ options, reject }) => {
+        number.value--;
+        options.onError();
+        reject(new Error(res.msg || "上传失败"));
+      });
+    }
+  }).catch((err) => {
+    group.forEach(({ options, reject }) => {
+      number.value--;
+      options.onError();
+      reject(err);
+    });
+  });
+}
 const headers = ref({ Authorization: "Bearer " + getToken() });
 const fileList = ref([]);
 const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));
@@ -83,11 +145,9 @@ watch(() => props.modelValue, val => {
 }, { deep: true, immediate: true });
 
 function normalizeImageUrl(value) {
-  if (props.relativePath) return buildMinioAttachmentUrl(value);
   const url = String(value || "").trim();
   if (!url || /^(https?:|blob:|data:)/.test(url)) return url;
-  if (baseUrl && (url === baseUrl || url.startsWith(baseUrl + "/"))) return url;
-  return url.startsWith("/") ? baseUrl + url : baseUrl + "/" + url;
+  return buildMinioAttachmentUrl(url);
 }
 
 function handleBeforeUpload(file) {
@@ -119,11 +179,7 @@ function handleExceed() {
 
 function handleUploadSuccess(res, file) {
   if (res.code === 200) {
-    const storedUrl = props.relativePath
-      ? minioAttachmentPath(res.relativePath || res.url)
-      : uploadImgUrl.value.includes("/minio/")
-        ? (res.url || res.fileName)
-        : (res.fileName || res.url);
+    const storedUrl = minioAttachmentPath(res.relativePath || res.url || res.fileName);
     uploadList.value.push({
       storagePath: storedUrl,
       name: res.originalFilename || storedUrl,
@@ -175,11 +231,7 @@ function handlePictureCardPreview(file) {
 function listToString(list, separator = ",") {
   return list
     .filter(item => item.url && !item.url.startsWith("blob:"))
-    .map(item => {
-      if (props.relativePath) return minioAttachmentPath(item.storagePath || item.url);
-      const url = item.url;
-      return baseUrl && url.startsWith(baseUrl) ? url.slice(baseUrl.length) : url;
-    })
+    .map(item => minioAttachmentPath(item.storagePath || item.url))
     .join(separator);
 }
 </script>

+ 8 - 1
src/layout/components/Navbar.vue

@@ -30,7 +30,7 @@
       <div class="avatar-container">
         <el-dropdown @command="handleCommand" class="right-menu-item hover-effect" trigger="click">
           <div class="avatar-wrapper">
-            <img :src="userStore.avatar" class="user-avatar"/>
+            <img :src="userStore.avatar" class="user-avatar" @error="onAvatarError"/>
             <el-icon>
               <caret-bottom/>
             </el-icon>
@@ -67,6 +67,7 @@ import RuoYiDoc from '@/components/RuoYi/Doc'
 import useAppStore from '@/store/modules/app'
 import useUserStore from '@/store/modules/user'
 import useSettingsStore from '@/store/modules/settings'
+import defAva from '@/assets/images/profile.jpg'
 
 const appStore = useAppStore()
 const userStore = useUserStore()
@@ -76,6 +77,12 @@ function toggleSideBar() {
   appStore.toggleSideBar()
 }
 
+function onAvatarError(e) {
+  if (e && e.target && e.target.src !== defAva) {
+    e.target.src = defAva;
+  }
+}
+
 function goToPortal() {
   window.location.href = '/portal'
 }

+ 5 - 2
src/store/modules/user.js

@@ -10,7 +10,7 @@ const useUserStore = defineStore(
       id: '',
       name: '',
       nickName: '',
-      avatar: '',
+      avatar: defAva,
       roles: [],
       permissions: [],
       deptId: ''
@@ -37,7 +37,10 @@ const useUserStore = defineStore(
         return new Promise((resolve, reject) => {
           getInfo().then(res => {
             const user = res.user
-            const avatar = (user.avatar == "" || user.avatar == null) ? defAva : import.meta.env.VITE_APP_BASE_API + user.avatar;
+            const rawAvatar = user.avatar;
+            const avatar = (rawAvatar == "" || rawAvatar == null)
+                ? defAva
+                : (import.meta.env.VITE_APP_MINIO_BASE_URL || "").replace(/\/+$/, "") + "/" + rawAvatar.replace(/^\/+/, "");
 
             if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
               this.roles = res.roles

+ 1 - 1
src/utils/deviceStatusWebSocket.js

@@ -2,7 +2,7 @@ import { ref } from 'vue'
 import { ElNotification } from 'element-plus'
 import { getToken } from '@/utils/auth'
 
-const DEFAULT_URL = 'ws://localhost:8301/ws/deviceStatus'
+const DEFAULT_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/pipe/ws/deviceStatus`
 
 export const workOrders = ref([])
 export const workOrderTotal = ref(0)

+ 13 - 2
src/utils/minioAttachment.js

@@ -1,7 +1,13 @@
 export function buildMinioAttachmentUrl(value, baseUrl = import.meta.env.VITE_APP_MINIO_BASE_URL) {
   const path = String(value || '').trim()
-  if (!path || /^(https?:|blob:|data:)/i.test(path)) return path
-  return `${String(baseUrl || '').replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
+  if (!path || /^(blob:|data:)/i.test(path)) return path
+  const base = String(baseUrl || '').replace(/\/+$/, '')
+  if (/^https?:\/\//i.test(path)) {
+    const marker = '/pipe/'
+    const index = path.indexOf(marker)
+    return index >= 0 ? `${base}/${path.slice(index + marker.length).replace(/^\/+/, '')}` : path
+  }
+  return `${base}/${path.replace(/^\/+/, '')}`
 }
 
 export function minioAttachmentPath(value, baseUrl = import.meta.env.VITE_APP_MINIO_BASE_URL) {
@@ -9,6 +15,11 @@ export function minioAttachmentPath(value, baseUrl = import.meta.env.VITE_APP_MI
   if (!path) return ''
   const base = String(baseUrl || '').replace(/\/+$/, '')
   if (base && path.startsWith(`${base}/`)) return `/${path.slice(base.length).replace(/^\/+/, '')}`
+  if (/^https?:\/\//i.test(path)) {
+    const marker = '/pipe/'
+    const index = path.indexOf(marker)
+    if (index >= 0) return `/${path.slice(index + marker.length).replace(/^\/+/, '')}`
+  }
   if (/^[a-z][a-z\d+.-]*:/i.test(path)) return path
   return `/${path.replace(/^\/+/, '')}`
 }

+ 46 - 0
src/utils/minioBatchUpload.js

@@ -0,0 +1,46 @@
+import request from '@/utils/request'
+
+/**
+ * 创建一个 MinIO 批量上传器:把同一时刻(同一事件循环)提交的多个文件
+ * 合并为一次 /minio/uploads 请求,避免逐张调用单文件接口。
+ *
+ * enqueue(file) 返回一个 Promise,resolve 时带上该文件的上传返回信息
+ * (含 relativePath / url / fileName 等);
+ * onBatchSettled(cb) 注册「整批文件全部结束(成功或失败)」后的回调,用于关闭 loading。
+ */
+export function createMinioBatchUploader(dir) {
+  let pending = []
+  let timer = null
+  let batchSettled = null
+  const enqueue = (file) => new Promise((resolve, reject) => {
+    pending.push({ file, resolve, reject })
+    if (timer) clearTimeout(timer)
+    timer = setTimeout(flush, 0)
+  })
+  function flush() {
+    timer = null
+    const group = pending.splice(0)
+    const form = new FormData()
+    group.forEach(({ file }) => form.append('files', file.raw))
+    Promise.resolve(
+      request({
+        url: `/minio/uploads?dir=${encodeURIComponent(dir)}`,
+        method: 'post',
+        data: form,
+        timeout: 60000,
+      })
+    )
+      .then((res) => {
+        if (res.code === 200) {
+          const infos = res.files || []
+          group.forEach(({ resolve }, i) => resolve(infos[i] || {}))
+        } else {
+          group.forEach(({ reject }) => reject(new Error(res.msg || '上传失败')))
+        }
+      })
+      .catch((err) => group.forEach(({ reject }) => reject(err)))
+      .finally(() => { if (batchSettled) batchSettled() })
+  }
+  enqueue.onBatchSettled = (cb) => { batchSettled = cb }
+  return enqueue
+}

+ 4 - 2
src/utils/request.js

@@ -27,7 +27,8 @@ service.interceptors.request.use(config => {
   // 是否需要防止数据重复提交
   const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
   // FormData 上传:清除全局默认的 application/json,让 axios 自动设置 multipart/form-data + boundary
-  if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
+  const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData
+  if (isFormData) {
     delete config.headers['Content-Type']
     delete config.headers['content-type']
   }
@@ -41,7 +42,8 @@ service.interceptors.request.use(config => {
     config.params = {};
     config.url = url;
   }
-  if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
+  // FormData(文件上传)JSON 序列化恒为 "{}",无法区分内容且无重复提交语义,跳过防重校验
+  if (!isRepeatSubmit && !isFormData && (config.method === 'post' || config.method === 'put')) {
     const requestObj = {
       url: config.url,
       data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,

+ 2 - 3
src/views/subSystem/alarm/AlarmAttachments.vue

@@ -26,10 +26,9 @@ import { computed } from 'vue'
 import { imageAttachments } from './common/js/alarmReviewModel'
 
 const props = defineProps({
-  value: { type: String, default: '' },
-  baseUrl: { type: String, default: '' }
+  value: { type: String, default: '' }
 })
-const images = computed(() => imageAttachments(props.value, props.baseUrl))
+const images = computed(() => imageAttachments(props.value))
 const previewUrls = computed(() => images.value.map(file => file.url))
 </script>
 

+ 1 - 2
src/views/subSystem/alarm/AlarmDetail.vue

@@ -10,7 +10,7 @@
           <div class="alarm-state-item"><dt>处置状态</dt><dd><el-tag size="small" effect="light" :type="alarm.workOrderId != null && alarm.orderStatus != null ? orderStatusType(alarm.orderStatus) : 'info'">{{ alarm.orderStatusName || '未转工单' }}</el-tag></dd></div>
         </dl>
         <el-descriptions :column="2" border><el-descriptions-item label="关联工单">{{ alarm.orderNo || '—' }}</el-descriptions-item><el-descriptions-item label="处置人">{{ alarm.receiveUserName || '未分配' }}</el-descriptions-item><el-descriptions-item label="完成期限" :span="2">{{ alarm.planFinishTime || '—' }}</el-descriptions-item></el-descriptions>
-        <AlarmHistory :records="timeline" :loading="loading" :error="error" :base-url="baseUrl" />
+        <AlarmHistory :records="timeline" :loading="loading" :error="error" />
       </template>
     </div>
     <template #footer><el-button v-if="alarm && hasActualValue(alarm) && menu('drainageAlarmAnalysis')" @click="navigate('drainageAlarmAnalysis', { alarmId: alarm.id })">进入研判</el-button><el-button v-if="alarm?.alarmStatus === 0 && alarm?.auditStatus === 0 && menu('alarmAudit')" type="primary" @click="navigate('alarmAudit', { alarmId: alarm.id })">前往核实</el-button><el-button v-if="waterShortcuts && alarm?.workOrderId" type="primary" @click="goWaterWorkOrder">前往设备维修派单</el-button><el-button @click="visible = false">关闭</el-button></template>
@@ -33,7 +33,6 @@ const { waterShortcuts = false } = defineProps({ waterShortcuts: Boolean })
 const router = useRouter()
 const emit = defineEmits(['visible-change'])
 const visible = ref(false), loading = ref(false), alarm = ref(null), timeline = ref([]), error = ref('')
-const baseUrl = import.meta.env.VITE_APP_BASE_API
 let generation = 0
 function goWaterWorkOrder() {
   if (!alarm.value?.orderNo) return ElMessage.warning('关联工单编号未返回')

+ 1 - 2
src/views/subSystem/alarm/AlarmHistory.vue

@@ -9,7 +9,7 @@
         <div class="history-body">
           <div class="history-label"><b>{{ item.operUserName || '系统' }}</b><span class="separator">·</span>{{ item.operTypeName || '历史操作' }}</div>
           <div v-if="item.operDesc" class="history-description">{{ item.operDesc }}</div>
-          <AlarmAttachments v-if="item.attachUrl" :value="item.attachUrl" :base-url="baseUrl" />
+          <AlarmAttachments v-if="item.attachUrl" :value="item.attachUrl" />
         </div>
       </article>
     </div>
@@ -27,7 +27,6 @@ const props = defineProps({
   title: { type: String, default: '处置流程' },
   loading: Boolean,
   error: { type: String, default: '' },
-  baseUrl: { type: String, default: '' }
 })
 const groups = computed(() => groupAlarmHistory(props.records))
 </script>

+ 39 - 5
src/views/subSystem/alarm/common/audit.vue

@@ -48,7 +48,7 @@
       <div v-loading="historyLoading">
         <AlarmSummary v-if="historyAlarm" :alarm="historyAlarm" :level-tone="levelTone(historyAlarm.alarmLevel)" />
         <el-alert v-if="historyError" :title="historyError" type="error" :closable="false" />
-        <AlarmHistory :records="logs" :loading="historyLoading" :error="historyError" :base-url="baseUrl" />
+        <AlarmHistory :records="logs" :loading="historyLoading" :error="historyError" />
       </div>
       <template #footer><el-button @click="historyVisible = false">关闭</el-button></template>
     </el-drawer>
@@ -60,7 +60,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
 import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { Refresh, Search, Upload } from '@element-plus/icons-vue'
-import { getAlarmReviewOptions, getAlarmReviewPage, getAlarmReviewStatistics, getAlarmReviewDetail, getAlarmLogs, submitAlarmReview, uploadAlarmReviewAttachment } from '@/api/alarmReview'
+import { getAlarmReviewOptions, getAlarmReviewPage, getAlarmReviewStatistics, getAlarmReviewDetail, getAlarmLogs, submitAlarmReview, uploadAlarmReviewAttachments } from '@/api/alarmReview'
 import { alarmAuditText } from '@/utils/alarmLabels'
 import AlarmSummary from '../AlarmSummary.vue'
 import AlarmHistory from '../AlarmHistory.vue'
@@ -68,7 +68,6 @@ import { useDict } from '@/utils/dict'
 const { alarm_level: alarmLevels } = useDict('alarm_level')
 
 const route = useRoute()
-const baseUrl = import.meta.env.VITE_APP_BASE_API
 const options = reactive({ auditStatuses: [], alarmLevels: [] })
 const filters = reactive({ keyword: '', alarmLevel: undefined, auditStatus: undefined })
 const dateRange = ref([]), rows = ref([]), logs = ref([]), total = ref(0), pageNum = ref(1), pageSize = ref(10)
@@ -134,14 +133,49 @@ function beforeUpload(file) {
   return true
 }
 function exceedFiles() { ElMessage.warning('最多上传5张图片') }
-function uploadFile({ file }) { return uploadAlarmReviewAttachment(current.value.id, { ...scope.value }, file).then(response => response.data) }
+/**
+ * el-upload 的 multiple 选择会为每个文件各调一次 http-request,
+ * 这里把同一批次的请求收集起来,setTimeout 后一次性调用 /minio/uploads,
+ * 避免逐张调用单文件接口。结果按收集顺序回写,保证 file.response.token 可用。
+ */
+let uploadBatch = []
+let uploadBatchTimer = null
+function uploadFile(options) {
+  return new Promise((resolve, reject) => {
+    uploadBatch.push({ options, resolve, reject })
+    if (uploadBatchTimer) clearTimeout(uploadBatchTimer)
+    uploadBatchTimer = setTimeout(flushUploadBatch, 0)
+  })
+}
+function flushUploadBatch() {
+  uploadBatchTimer = null
+  const group = uploadBatch.splice(0)
+  const form = new FormData()
+  group.forEach(({ options }) => form.append('files', options.file))
+  uploadAlarmReviewAttachments(current.value.id, { ...scope.value }, form)
+    .then(res => {
+      const infos = res.files || []
+      group.forEach(({ options, resolve }, i) => {
+        const info = infos[i] || {}
+        const result = { token: info.relativePath || info.url, url: info.url, name: options.file.name }
+        options.onSuccess(result)
+        resolve(result)
+      })
+    })
+    .catch(err => {
+      group.forEach(({ options, reject }) => {
+        options.onError(err)
+        reject(err)
+      })
+    })
+}
 async function submit() {
   if (submitting.value || uploading.value || !current.value) return
   if (!await formRef.value.validate().catch(() => false)) return
   if (files.value.some(file => !file.response?.token)) { formError.value = '存在未上传成功的附件,请移除后重新上传'; return }
   submitting.value = true; formError.value = ''
   try {
-    await submitAlarmReview(current.value.id, { ...scope.value }, { auditStatus: form.auditStatus, opinion: form.opinion.trim(), attachmentTokens: files.value.map(file => file.response.token) })
+    await submitAlarmReview(current.value.id, { ...scope.value }, { auditStatus: form.auditStatus, opinion: form.opinion.trim(), attachments: files.value.map(file => file.response.token) })
     reviewVisible.value = false; files.value = []; Object.assign(form, { auditStatus: undefined, opinion: '' })
     ElMessage.success('核实已提交'); await load()
   } catch (e) { formError.value = message(e) }

+ 4 - 2
src/views/subSystem/alarm/common/js/alarmReviewModel.js

@@ -1,3 +1,5 @@
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
+
 // 报警值及阈值来自事件快照,不使用设备当前阈值重算历史事件。
 const present = value => value !== null && value !== undefined && value !== ''
 export const hasActualValue = row => present(row?.actualValue)
@@ -13,13 +15,13 @@ export function thresholdText(row = {}) {
 export function alarmDescription(row = {}) {
   return present(row.alarmContent) ? row.alarmContent : ''
 }
-export function imageAttachments(value, baseUrl = '') {
+export function imageAttachments(value) {
   return String(value || '').split(',').map(path => path.trim()).filter(Boolean).flatMap(path => {
     // 历史附件也只允许 HTTP(S) 或站内资源,拒绝脚本 URL 和协议相对路径。
     if (!/^https?:\/\//i.test(path) && !/^\/(?!\/)/.test(path)) return []
     const pathname = path.split(/[?#]/)[0]
     if (!/\.(jpe?g|png|gif|webp|bmp|avif)$/i.test(pathname)) return []
-    const url = path.startsWith('/') ? `${baseUrl}${path}` : path
+    const url = buildMinioAttachmentUrl(path)
     const name = pathname.split('/').pop() || '图片'
     return [{ url, name }]
   })

+ 6 - 12
src/views/subSystem/basic/RepairRecord.vue

@@ -168,6 +168,7 @@ import * as echarts from 'echarts'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Close } from '@element-plus/icons-vue'
 import request from '@/utils/request'
+import { createMinioBatchUploader } from '@/utils/minioBatchUpload'
 import { convertCoord } from '@/utils/coordTransform'
 import { getPipeNetworkOptions, getRepairRecordPage, getRepairStats, addRepairRecord, updateRepairRecord, deleteRepairRecord } from '@/api/pipeNetwork/basic'
 
@@ -253,7 +254,9 @@ function parseImageUrls(repairImages) {
   return repairImages.split(',').map(s => s.trim()).filter(Boolean)
 }
 
-/** 图片选择后立即上传到 MinIO */
+/** 图片选择后立即上传到 MinIO(同一批次多张合并为一次 /minio/uploads 请求) */
+const repairImageUploader = createMinioBatchUploader('work-order')
+repairImageUploader.onBatchSettled(() => { imageUploading.value = false })
 async function handleImageChange(file) {
   if (!file.raw) return
   const isImage = file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/jpg'
@@ -262,21 +265,12 @@ async function handleImageChange(file) {
 
   imageUploading.value = true
   try {
-    const uploadFormData = new FormData()
-    uploadFormData.append('file', file.raw)
-    uploadFormData.append('dir', 'upload') // 指定存储目录(可选)
-    const uploadResponse = await request({
-      url: '/minio/upload',
-      method: 'post',
-      data: uploadFormData
-    })
-    const url = uploadResponse.url || uploadResponse.fileName
+    const info = await repairImageUploader(file)
+    const url = info.relativePath || info.url || info.fileName
     imageUrls.value.push(url)
     imageList.value.push(getImageUrl(url))
   } catch (e) {
     ElMessage.error('图片上传失败')
-  } finally {
-    imageUploading.value = false
   }
 }
 

+ 2 - 1
src/views/subSystem/dispatch/disOnDuty/EventLogTimeline.vue

@@ -50,6 +50,7 @@
 import { computed } from 'vue'
 import { Document } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 
 // 事件操作日志时间轴(参考工单系统样式:按日分组 + 时间列 + 虚线轴 + 操作人加粗 + 附件展示)
 // logs:后端 dis_event_log 原始对象数组(operTime/operTypeName/operUserName/operDesc/targets/attachUrl/operType)
@@ -89,7 +90,7 @@ const groups = computed(() => {
     const [y, m, d] = datePart.split('-')
     const dateLabel = `${Number(m)}月${Number(d)}日`
     const hhmm = t.slice(11, 16) || ''
-    const urls = splitAttach(log.attachUrl)
+    const urls = splitAttach(log.attachUrl).map(buildMinioAttachmentUrl)
     const images = urls.filter(u => IMG_EXT.includes(extOf(u)))
     const files = urls.filter(u => !IMG_EXT.includes(extOf(u))).map(u => ({ url: u, name: nameOf(u) }))
     const key = dateLabel

+ 37 - 4
src/views/subSystem/dispatch/disOnDuty/gzjc.vue

@@ -96,8 +96,7 @@
           <el-upload
             ref="operationUploadRef"
             v-model:file-list="operationForm.fileList"
-            :action="uploadUrl"
-            :headers="uploadHeaders"
+            :http-request="gzjcBatchUpload"
             :limit="5"
             multiple
             accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
@@ -255,8 +254,6 @@ const operationForm = ref({ operDesc: '', fileList: [], attachUrls: [] })
 const operationRules = {
   operDesc: [{ required: true, message: '请输入备注', trigger: 'blur' }]
 }
-const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=dispatch'
-const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
 const ALLOWED_ATTACH_EXT = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'mov', 'm4v', 'webm']
 function handleAttachBefore(file) {
   const ext = String(file.name).split('.').pop().toLowerCase()
@@ -268,6 +265,42 @@ function handleAttachBefore(file) {
 }
 function handleAttachError() { ElMessage.error('文件上传失败') }
 
+/** 多附件合并为一次 /minio/uploads 请求,避免逐张调用单文件接口 */
+let gzjcUploadBatch = []
+let gzjcUploadTimer = null
+function gzjcBatchUpload(options) {
+  return new Promise((resolve, reject) => {
+    gzjcUploadBatch.push({ options, resolve, reject })
+    if (gzjcUploadTimer) clearTimeout(gzjcUploadTimer)
+    gzjcUploadTimer = setTimeout(gzjcFlushUploadBatch, 0)
+  })
+}
+function gzjcFlushUploadBatch() {
+  gzjcUploadTimer = null
+  const group = gzjcUploadBatch.splice(0)
+  const form = new FormData()
+  group.forEach(({ options }) => form.append('files', options.file))
+  request({
+    url: `/minio/uploads?dir=event`,
+    method: 'post',
+    data: form,
+    timeout: 60000,
+  }).then((res) => {
+    if (res.code === 200) {
+      const infos = res.files || []
+      group.forEach(({ options, resolve }, i) => {
+        const info = infos[i] || {}
+        options.onSuccess(info)
+        resolve()
+      })
+    } else {
+      group.forEach(({ options, reject }) => { options.onError(); reject(new Error(res.msg || '上传失败')) })
+    }
+  }).catch((err) => {
+    group.forEach(({ options, reject }) => { options.onError(); reject(err) })
+  })
+}
+
 function openOperation(row, type) {
   currentRow.value = { ...row }
   operationType.value = type

+ 10 - 14
src/views/subSystem/hidden/HazardList.vue

@@ -274,8 +274,10 @@ import { computed, ref, reactive, shallowRef, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Close } from '@element-plus/icons-vue'
 import request from '@/utils/request'
+import { createMinioBatchUploader } from '@/utils/minioBatchUpload'
 import { getHazardPage, getHazardById, addHazard, updateHazard, deleteHazard } from '@/api/pipeNetwork/hazard'
 import { getDicts } from '@/api/system/dict/data'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 import { getPipeNetworkOptions, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
 
 const loading = ref(false)
@@ -382,10 +384,11 @@ function getRowImageUrls(row) {
 /** 兼容 MinIO 完整地址、upload 相对路径和历史仅文件名数据 */
 function normalizeImageUrl(value) {
   const url = String(value || '').trim()
-  if (!url || /^(https?:|blob:|data:)/i.test(url)) return url
+  if (!url || /^(blob:|data:)/i.test(url)) return url
   const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
+  if (/^https?:\/\//i.test(url)) return buildMinioAttachmentUrl(url)
   if (minioBase) {
-    if (url.startsWith('/')) return `${minioBase}${url}`
+    if (url.startsWith('/')) return buildMinioAttachmentUrl(url)
     if (url.startsWith('upload/')) return `${minioBase}/${url}`
     if (!url.includes('/')) return `${minioBase}/upload/${url}`
   }
@@ -393,7 +396,9 @@ function normalizeImageUrl(value) {
   return url.startsWith('/') ? `${baseApi}${url}` : `${baseApi}/${url}`
 }
 
-/** 图片选择后立即上传到 MinIO */
+/** 图片选择后立即上传到 MinIO(同一批次多张合并为一次 /minio/uploads 请求) */
+const hazardImageUploader = createMinioBatchUploader('hazard')
+hazardImageUploader.onBatchSettled(() => { imageUploading.value = false })
 async function handleImageChange(file) {
   if (!file.raw) return
   const isImage = file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/jpg'
@@ -402,22 +407,13 @@ async function handleImageChange(file) {
 
   imageUploading.value = true
   try {
-    const uploadFormData = new FormData()
-    uploadFormData.append('file', file.raw)
-    uploadFormData.append('dir', 'upload')
-    const uploadResponse = await request({
-      url: '/minio/upload',
-      method: 'post',
-      data: uploadFormData
-    })
-    const url = normalizeImageUrl(uploadResponse.url || uploadResponse.fileName)
+    const info = await hazardImageUploader(file)
+    const url = normalizeImageUrl(info.relativePath || info.url || info.fileName)
     if (!url) throw new Error('上传接口未返回图片地址')
     imageUrls.value.push(url)
     imageList.value.push(url)
   } catch (e) {
     ElMessage.error('图片上传失败')
-  } finally {
-    imageUploading.value = false
   }
 }
 

+ 4 - 2
src/views/subSystem/hidden/HazardOperationPage.vue

@@ -257,6 +257,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { getDicts } from '@/api/system/dict/data'
 import { getHazardById, getHazardPage, submitHazard, updateHazard } from '@/api/pipeNetwork/hazard'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 
 const props = defineProps({
   mode: {
@@ -367,10 +368,11 @@ function parseImageUrls(value) {
 
 function normalizeImageUrl(value) {
   const url = String(value || '').trim()
-  if (!url || /^(https?:|blob:|data:)/i.test(url)) return url
+  if (!url || /^(blob:|data:)/i.test(url)) return url
   const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
+  if (/^https?:\/\//i.test(url)) return buildMinioAttachmentUrl(url)
   if (minioBase) {
-    if (url.startsWith('/')) return `${minioBase}${url}`
+    if (url.startsWith('/')) return buildMinioAttachmentUrl(url)
     if (url.startsWith('upload/')) return `${minioBase}/${url}`
     if (!url.includes('/')) return `${minioBase}/upload/${url}`
   }

+ 2 - 5
src/views/subSystem/lifeCompany/lAlertInformation/LRelease.vue

@@ -383,6 +383,7 @@ import useUserStore from '@/store/modules/user';
 const currentUser = useUserStore();
 import { Search } from "@element-plus/icons-vue";
 import { parseTime } from "@/utils/ruoyi";
+import { buildMinioAttachmentUrl } from "@/utils/minioAttachment";
 import { getWarningFullDetailData } from "@/api/pipeNetwork/earlyWarning";
 import { listEquipment, listEquipmentTypeAll } from "@/api/pipeNetwork/pdeviceInform";
 import {
@@ -530,11 +531,7 @@ function formatFileSize(bytes) {
 
 function buildAttachmentUrl(path) {
   if (!path) return "";
-  if (/^https?:\/\//i.test(path)) {
-    return path;
-  }
-  const base = minioBaseUrl.endsWith("/") ? minioBaseUrl : `${minioBaseUrl}/`;
-  return `${base}${String(path).replace(/^\/+/, "")}`;
+  return buildMinioAttachmentUrl(path);
 }
 
 function normalizeExistingAttachment(item) {

+ 2 - 5
src/views/subSystem/lifeCompany/lAlertInformation/LReportUpload.vue

@@ -261,6 +261,7 @@ import {
   updateYjxxData
 } from "@/api/smxyjldczxt/yjxxfb/yjfb.js";
 import { getWarningFullDetailData } from "@/api/pipeNetwork/earlyWarning";
+import { buildMinioAttachmentUrl } from "@/utils/minioAttachment";
 
 const pageLoading = ref(false);
 const submitLoading = ref(false);
@@ -413,11 +414,7 @@ function formatFileSize(bytes) {
 
 function buildAttachmentUrl(path) {
   if (!path) return "";
-  if (/^https?:\/\//i.test(path)) {
-    return path;
-  }
-  const base = minioBaseUrl.endsWith("/") ? minioBaseUrl : `${minioBaseUrl}/`;
-  return `${base}${String(path).replace(/^\/+/, "")}`;
+  return buildMinioAttachmentUrl(path);
 }
 
 function normalizeWarningRow(item) {

+ 1 - 1
src/views/subSystem/pipeNetwork/ops/inspectTask/index.vue

@@ -323,7 +323,7 @@ import {
 import ImageUpload from '@/components/ImageUpload/index.vue'
 
 const { proxy } = getCurrentInstance()
-const imageUploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload'
+const imageUploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/uploads?dir=inspect'
 
 const loading = ref(false)
 const taskList = ref([])

+ 1 - 1
src/views/subSystem/pipeNetwork/ops/workOrder/dispose.vue

@@ -471,7 +471,7 @@ import useUserStore from '@/store/modules/user'
 defineOptions({ name: 'Pworkdisposition' })
 
 const { proxy } = getCurrentInstance()
-const imageUploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=workOrder'
+const imageUploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/uploads?dir=work-order'
 const { workOrderLevels, orderLevelText } = useWorkOrderLevelDict()
 const route = useRoute()
 const userStore = useUserStore()

+ 1 - 1
src/views/system/notice/NoticeEditor.vue

@@ -32,7 +32,7 @@ const editorConfig = {
         const data = new FormData()
         data.append('file', file)
         const response = await request({
-          url: '/common/upload',
+          url: '/minio/upload?dir=other',
           method: 'post',
           data
         })

+ 21 - 7
src/views/system/user/profile/userAvatar.vue

@@ -63,6 +63,7 @@ import "vue-cropper/dist/index.css";
 import { VueCropper } from "vue-cropper";
 import { uploadAvatar } from "@/api/system/user";
 import useUserStore from "@/store/modules/user";
+import request from "@/utils/request";
 
 const userStore = useUserStore();
 const { proxy } = getCurrentInstance();
@@ -126,18 +127,31 @@ function beforeUpload(file) {
   }
 }
 
-/** 上传图片 */
+/** 上传图片:先传 MinIO 取相对路径,再调头像设置接口存路径 */
 function uploadImg() {
-  proxy.$refs.cropper.getCropBlob(data => {
-    let formData = new FormData();
-    formData.append("avatarfile", data, options.filename);
-    uploadAvatar(formData).then(response => {
+  proxy.$refs.cropper.getCropBlob(async (data) => {
+    try {
+      const formData = new FormData();
+      formData.append("file", data, (options.filename || "avatar") + ".png");
+      const uploadRes = await request({
+        url: "/minio/upload?dir=user",
+        method: "post",
+        data: formData
+      });
+      const relativePath = uploadRes.relativePath || uploadRes.url;
+      const response = await uploadAvatar(relativePath);
       open.value = false;
-      options.img = import.meta.env.VITE_APP_BASE_API + response.imgUrl;
+      const rawImg = response.imgUrl;
+      options.img = (rawImg == "" || rawImg == null)
+        ? userStore.avatar
+        : (import.meta.env.VITE_APP_MINIO_BASE_URL || "").replace(/\/+$/, "") + "/" + rawImg.replace(/^\/+/, "");
       userStore.avatar = options.img;
       proxy.$modal.msgSuccess("修改成功");
       visible.value = false;
-    });
+    } catch (e) {
+      console.error("头像上传失败:", e);
+      proxy.$modal.msgError(e?.message || "头像上传失败");
+    }
   });
 }